feat: 报告结构支持检索方向/来源与智能新增,并修复协助卡停轮与宿主跳转
为深度研究报告结构增加检索方向与技能来源、对话式智能新增页与详情排版;澄清中间件在 after_model 可靠结束本轮,避免弱模型出卡后继续生成;任务按钮补充 host 跳转与相关深链能力。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
a5a9c952e8
commit
51afebb394
67 files changed
+4511
-699
No files matched your search
@@ -18,11 +18,13 @@ import {
|
||||
readExternalLoginParams,
|
||||
resolveThemeFromQuery,
|
||||
} from '@/core/auth/external-login';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
fetchCopTaskDetail,
|
||||
persistCopTaskDetail,
|
||||
resolveDeeplinkTaskId,
|
||||
resolveTaskDeeplinkTarget,
|
||||
stubCopTaskDetail,
|
||||
} from '@/core/auth/task-deeplink';
|
||||
import { resolveUsernameByOnlineTicket } from '@/core/auth/wujie-ticket-login';
|
||||
import { isWujieEnv, useWujieProps } from '@/core/embed/wujie';
|
||||
@@ -61,6 +63,17 @@ function maskLoginToken(value: string): string {
|
||||
return `${value.slice(0, 6)}...${value.slice(-4)}(${value.length})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash 路由下深链参数可能写在 ``#/login?...``,也可能写在 hash 前的
|
||||
* ``?taskId=&goPath=#/login``。两边都读,避免宿主把 query 放在外层 URL 时丢参落到首页。
|
||||
*/
|
||||
function readLoginQuery(searchParams: URLSearchParams, key: string): string {
|
||||
const fromHash = searchParams.get(key)?.trim() ?? '';
|
||||
if (fromHash) return fromHash;
|
||||
if (typeof window === 'undefined') return '';
|
||||
return new URLSearchParams(window.location.search).get(key)?.trim() ?? '';
|
||||
}
|
||||
|
||||
/** 读取已落地的 userInfo(脱敏 access token 后)供登录后排查。 */
|
||||
function summarizeStoredUserInfo(): Record<string, unknown> | null {
|
||||
try {
|
||||
@@ -206,8 +219,8 @@ export default function LoginPage() {
|
||||
const effectiveGatePassword = configuredRouteLoginPassword || gatePassword;
|
||||
const usesConfiguredRouteLoginPassword = Boolean(configuredRouteLoginPassword);
|
||||
// 任务深链:外部系统带 ?taskId=&goPath= 时,登录后拉任务详情并直送对应问答/会商页
|
||||
const taskId = searchParams.get('taskId')?.trim() ?? '';
|
||||
const goPath = searchParams.get('goPath')?.trim() ?? '';
|
||||
const taskId = readLoginQuery(searchParams, 'taskId');
|
||||
const goPath = readLoginQuery(searchParams, 'goPath');
|
||||
// 通用「登录后跳指定页」:?redirect=page/strategy/dashboard-agent(值可不带前导斜杠,
|
||||
// 也可自带 query,如 page/strategy/dashboard-agent?taskId=1580——记得整体 URL 编码)。
|
||||
// 优先级最高:显式指定即按它跳,先于 taskId/goPath 深链与默认首页。出于安全只接受**站内
|
||||
@@ -447,26 +460,38 @@ export default function LoginPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
// 任务深链:带 taskId + goPath 时拉任务详情并直送对应问答/会商页;
|
||||
// 任一环节失败(无地址/拉取失败/未配置智能体/goPath 无法识别)都回退默认首页。
|
||||
// 任务深链:带 taskId + goPath 时直送对应问答/会商页。
|
||||
// 任务详情拉取失败不再阻断跳转(本地/外网 consumer 常不可达),用仅含 taskId 的 stub 继续。
|
||||
// 真正无法解析(goPath 无法识别 / 单智能体未配置业务映射)才回退默认首页。
|
||||
if (taskId && goPath) {
|
||||
try {
|
||||
// xdfx 深链 ?taskId= 传入的是「行动id」,先换成真正的「任务id」;其它业务原样。
|
||||
const realTaskId = await resolveDeeplinkTaskId(goPath, taskId);
|
||||
// xdfx 深链 ?taskId= 传入的是「行动id」,先换成真正的「任务id」;解析失败回退原始 id。
|
||||
const resolvedTaskId = await resolveDeeplinkTaskId(goPath, taskId);
|
||||
const realTaskId = resolvedTaskId || taskId;
|
||||
if (cancelled) return;
|
||||
const detail = await fetchCopTaskDetail(realTaskId);
|
||||
let detail = await fetchCopTaskDetail(realTaskId);
|
||||
if (cancelled) return;
|
||||
if (detail) {
|
||||
persistCopTaskDetail(detail);
|
||||
const target = await resolveTaskDeeplinkTarget(goPath, detail, realTaskId, taskId);
|
||||
if (cancelled) return;
|
||||
if (target) {
|
||||
navigate(target.path, { replace: true, state: target.state });
|
||||
return;
|
||||
}
|
||||
if (!detail) {
|
||||
console.warn('[login] cop-task-detail missing, continue with stub', {
|
||||
goPath,
|
||||
taskId,
|
||||
realTaskId,
|
||||
});
|
||||
detail = stubCopTaskDetail(realTaskId);
|
||||
}
|
||||
} catch {
|
||||
/* 深链失败不阻断登录,落默认首页 */
|
||||
persistCopTaskDetail(detail);
|
||||
const target = await resolveTaskDeeplinkTarget(goPath, detail, realTaskId, taskId);
|
||||
if (cancelled) return;
|
||||
if (target) {
|
||||
console.info('[login] deeplink →', target.path, { goPath, realTaskId });
|
||||
navigate(target.path, { replace: true, state: target.state });
|
||||
return;
|
||||
}
|
||||
console.warn('[login] deeplink target unresolved', { goPath, realTaskId });
|
||||
toast.warning('深链未配置对应智能体或 goPath 无法识别,已进入首页');
|
||||
} catch (error) {
|
||||
console.warn('[login] deeplink failed, fallback home', error);
|
||||
toast.warning('深链跳转失败,已进入首页');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import PositionRoundtablePage from "@/position-roundtable/pages/PositionRoundtab
|
||||
import EightBfRoundtablePage from "@/eightbf-roundtable/pages/EightBfRoundtablePage";
|
||||
import DashboardAgentPage from "@/roundtable-planning/pages/DashboardAgentPage";
|
||||
import DeepResearchPage from "./DeepResearchPage";
|
||||
import ReportStructureCreatePage from "./deep-research/ReportStructureCreatePage";
|
||||
import EnterpriseResearchPage from "./EnterpriseResearchPage";
|
||||
import BusinessChainListPage from "@/roundtable-planning/pages/BusinessChainListPage";
|
||||
import BusinessChainEditorPage from "@/roundtable-planning/pages/BusinessChainEditorPage";
|
||||
@@ -118,6 +119,14 @@ export default function StrategyRoutes() {
|
||||
}
|
||||
/>
|
||||
<Route path="qa/deep-research" element={<DeepResearchPage />} />
|
||||
<Route
|
||||
path="qa/deep-research/structures/new"
|
||||
element={
|
||||
<ChatRuntime>
|
||||
<ReportStructureCreatePage />
|
||||
</ChatRuntime>
|
||||
}
|
||||
/>
|
||||
<Route path="qa/enterprise-research" element={<EnterpriseResearchPage />} />
|
||||
|
||||
{/* 大屏绘制智能体独立页面(按 taskId 存取;ChatRuntime 提供 ArtifactsProvider 给沙箱预览) */}
|
||||
|
||||
@@ -173,7 +173,7 @@ export default function TaskButtonsPage() {
|
||||
<div>
|
||||
<div className="text-foreground text-base font-semibold">按钮管理</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
配置任务工作区底部「快捷跳转」栏的按钮(名称 / 跳转方式 / 地址 / 启停 / 排序)
|
||||
配置任务工作区底部「快捷跳转」栏的按钮(名称 / 跳转方式 / 地址 / 启停 / 排序)。「宿主路由」用于 iframe 套用时让对方系统跳转。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -265,13 +265,14 @@ export default function TaskButtonsPage() {
|
||||
value={r.linkType}
|
||||
onValueChange={(v) => update(r.id, { linkType: v as TaskButton["linkType"] })}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-36">
|
||||
<SelectTrigger className="h-8 w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="url">外部链接</SelectItem>
|
||||
<SelectItem value="business">切换业务</SelectItem>
|
||||
<SelectItem value="purpose">目的树弹框</SelectItem>
|
||||
<SelectItem value="host">宿主路由</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -282,6 +283,39 @@ export default function TaskButtonsPage() {
|
||||
<span className="text-muted-foreground text-xs">
|
||||
点击弹出当前任务的「任务目的」表格,无需配置目标。
|
||||
</span>
|
||||
) : r.linkType === "host" ? (
|
||||
<>
|
||||
<Input
|
||||
value={r.target}
|
||||
onChange={(e) => update(r.id, { target: e.target.value })}
|
||||
placeholder="/task/…(宿主系统路由)"
|
||||
className="h-8 flex-1 min-w-[16rem]"
|
||||
title="宿主系统路由,点击后通过 postMessage 让父页面跳转"
|
||||
/>
|
||||
<label className="text-muted-foreground ml-2 flex shrink-0 items-center gap-1.5 text-xs">
|
||||
<Switch
|
||||
checked={r.appendTaskId}
|
||||
onCheckedChange={(v) => update(r.id, { appendTaskId: v })}
|
||||
/>
|
||||
追加任务id
|
||||
</label>
|
||||
{r.appendTaskId && (
|
||||
<div className="flex shrink-0 items-center gap-1 text-xs">
|
||||
<span className="text-muted-foreground">queryParams.</span>
|
||||
<Input
|
||||
value={r.idParam}
|
||||
onChange={(e) => update(r.id, { idParam: e.target.value })}
|
||||
placeholder="id"
|
||||
className="h-8 w-24"
|
||||
title="发给宿主的参数名(默认 id)"
|
||||
/>
|
||||
<span className="text-muted-foreground">= 任务id(数字)</span>
|
||||
</div>
|
||||
)}
|
||||
<span className="text-muted-foreground w-full text-[11px]">
|
||||
iframe 嵌入时向父页面发送 HOST_NAVIGATE,由宿主系统跳转;独立打开本系统时无效。
|
||||
</span>
|
||||
</>
|
||||
) : r.linkType === "url" ? (
|
||||
<>
|
||||
<Input
|
||||
|
||||
@@ -63,6 +63,7 @@ import PluginManagementPage from './PluginManagementPage';
|
||||
import RecommendedQuestionsPage from './RecommendedQuestionsPage';
|
||||
import AIWritingPage from '@/open-canvas/pages/AIWritingPage';
|
||||
import DeepResearchPage from './DeepResearchPage';
|
||||
import ReportStructureCreatePage from './deep-research/ReportStructureCreatePage';
|
||||
import EnterpriseResearchPage from './EnterpriseResearchPage';
|
||||
import StudioNotebooksPage from './StudioNotebooksPage';
|
||||
import StudioNotebookDetailPage from './StudioNotebookDetailPage';
|
||||
@@ -564,6 +565,16 @@ export default function WorkspaceRoutes({
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="deep-research/structures/new"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<ChatRuntime>
|
||||
<ReportStructureCreatePage />
|
||||
</ChatRuntime>
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="enterprise-research"
|
||||
element={
|
||||
|
||||
@@ -120,7 +120,13 @@ import {
|
||||
useResearchSessions,
|
||||
useResearchSources,
|
||||
} from "@/strategy-components/api/deep-research";
|
||||
import { useReportStructures } from "@/strategy-components/api/report-structures";
|
||||
import {
|
||||
type ReportStructureMode,
|
||||
composeCollectorQueryWithDirections,
|
||||
normalizeRetrievalDirections,
|
||||
normalizeRetrievalSkills,
|
||||
useReportStructures,
|
||||
} from "@/strategy-components/api/report-structures";
|
||||
import {
|
||||
AdvancedResearchOptions,
|
||||
DEFAULT_ADVANCED_OPTIONS,
|
||||
@@ -442,6 +448,9 @@ function DeepResearchWorkbenchInner() {
|
||||
// Keep only post-start outline choices separately so another research record
|
||||
// cannot leak its workflow selection into this record's writing card.
|
||||
const [reportCardOutlineOverrides, setReportCardOutlineOverrides] = useState<Record<string, string>>({});
|
||||
const [reportCardStructureModeOverrides, setReportCardStructureModeOverrides] = useState<Record<string, ReportStructureMode>>({});
|
||||
const [reportCardRetrievalDirectionsOverrides, setReportCardRetrievalDirectionsOverrides] = useState<Record<string, string[]>>({});
|
||||
const [reportCardRetrievalSkillsOverrides, setReportCardRetrievalSkillsOverrides] = useState<Record<string, string[]>>({});
|
||||
const [classicMode, setClassicMode] = useState(false);
|
||||
const [selectedModel, setSelectedModel] = useState<string | null>(null);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
@@ -534,6 +543,9 @@ function DeepResearchWorkbenchInner() {
|
||||
const streamJobId = activeJobId ?? lastCompletedJobId;
|
||||
const isFollowUp = session?.status === "completed" && Boolean(session.reportMarkdown);
|
||||
const reportCardOutlineOverride = session ? reportCardOutlineOverrides[session.id] : undefined;
|
||||
const reportCardStructureModeOverride = session ? reportCardStructureModeOverrides[session.id] : undefined;
|
||||
const reportCardRetrievalDirectionsOverride = session ? reportCardRetrievalDirectionsOverrides[session.id] : undefined;
|
||||
const reportCardRetrievalSkillsOverride = session ? reportCardRetrievalSkillsOverrides[session.id] : undefined;
|
||||
const availableModes = useMemo<ResearchMode[]>(
|
||||
() => capabilities.data?.modes ?? DEFAULT_AVAILABLE_MODES,
|
||||
[capabilities.data?.modes],
|
||||
@@ -862,12 +874,20 @@ function DeepResearchWorkbenchInner() {
|
||||
// ── collector-agent conversation stream (normal-chat machinery) ───────────
|
||||
|
||||
const collectorContext = useMemo(
|
||||
() => ({
|
||||
...localSettings.context,
|
||||
model_name: selectedModel ?? localSettings.context.model_name,
|
||||
agent_id: COLLECTOR_AGENT_ID,
|
||||
}),
|
||||
[localSettings.context, selectedModel],
|
||||
() => {
|
||||
const skills = normalizeRetrievalSkills(
|
||||
advancedOptions.retrievalSkills.length > 0
|
||||
? advancedOptions.retrievalSkills
|
||||
: session?.config?.retrieval_skills,
|
||||
);
|
||||
return {
|
||||
...localSettings.context,
|
||||
model_name: selectedModel ?? localSettings.context.model_name,
|
||||
agent_id: COLLECTOR_AGENT_ID,
|
||||
...(skills.length > 0 ? { extra_skills: skills } : {}),
|
||||
};
|
||||
},
|
||||
[advancedOptions.retrievalSkills, localSettings.context, selectedModel, session?.config?.retrieval_skills],
|
||||
);
|
||||
|
||||
const [collectorThread, collectorSendMessage] = useThreadStream({
|
||||
@@ -892,17 +912,37 @@ function DeepResearchWorkbenchInner() {
|
||||
if (!options?.silentStatusPatch && session && session.status !== "collecting") {
|
||||
void actions.updateStatus.mutate({ sessionId: session.id, status: "collecting" });
|
||||
}
|
||||
const isFirstTurn = (collectorThread.messages?.length ?? 0) === 0;
|
||||
const payload = isFirstTurn
|
||||
? composeCollectorQueryWithDirections(
|
||||
trimmed,
|
||||
advancedOptions.retrievalDirections.length > 0
|
||||
? advancedOptions.retrievalDirections
|
||||
: session?.config?.retrieval_directions,
|
||||
advancedOptions.retrievalSkills.length > 0
|
||||
? advancedOptions.retrievalSkills
|
||||
: session?.config?.retrieval_skills,
|
||||
)
|
||||
: trimmed;
|
||||
try {
|
||||
await collectorSendMessage(
|
||||
threadId,
|
||||
{ text: trimmed, files: [] },
|
||||
{ text: payload, files: [] },
|
||||
selectedModel ? { model_name: selectedModel } : undefined,
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "发送失败,请重试");
|
||||
}
|
||||
},
|
||||
[actions.updateStatus, collectorSendMessage, selectedModel, session],
|
||||
[
|
||||
actions.updateStatus,
|
||||
advancedOptions.retrievalDirections,
|
||||
advancedOptions.retrievalSkills,
|
||||
collectorSendMessage,
|
||||
collectorThread.messages?.length,
|
||||
selectedModel,
|
||||
session,
|
||||
],
|
||||
);
|
||||
|
||||
const handleClarificationSubmit = useCallback(
|
||||
@@ -1499,6 +1539,7 @@ function DeepResearchWorkbenchInner() {
|
||||
style: "formal_analysis" | "professional" | "concise" | "readable";
|
||||
modelName: string | null;
|
||||
reportOutline: string;
|
||||
structureMode?: ReportStructureMode;
|
||||
createNewReport?: boolean;
|
||||
}) => {
|
||||
if (!session) {
|
||||
@@ -1523,6 +1564,7 @@ function DeepResearchWorkbenchInner() {
|
||||
const parentRunSnapshot = run;
|
||||
targetSession = await createResearchReportVariant(session.id, {
|
||||
reportOutline: input.reportOutline || undefined,
|
||||
structureMode: input.structureMode,
|
||||
});
|
||||
reportDocumentRewrite.reset();
|
||||
const toneByStyle = {
|
||||
@@ -1536,6 +1578,7 @@ function DeepResearchWorkbenchInner() {
|
||||
entry: "regenerate",
|
||||
config: {
|
||||
custom_outline: input.reportOutline || undefined,
|
||||
structure_mode: input.structureMode,
|
||||
report_instruction: input.instruction,
|
||||
tone: toneByStyle[input.style],
|
||||
...(input.modelName ? {
|
||||
@@ -1603,6 +1646,7 @@ function DeepResearchWorkbenchInner() {
|
||||
style: input.style,
|
||||
modelName: input.modelName ?? undefined,
|
||||
reportOutline: input.reportOutline || undefined,
|
||||
structureMode: input.structureMode,
|
||||
},
|
||||
{
|
||||
stream: (signal) =>
|
||||
@@ -1614,6 +1658,7 @@ function DeepResearchWorkbenchInner() {
|
||||
style: input.style,
|
||||
modelName: input.modelName ?? undefined,
|
||||
reportOutline: input.reportOutline || undefined,
|
||||
structureMode: input.structureMode,
|
||||
},
|
||||
signal,
|
||||
(jobId) => {
|
||||
@@ -1818,16 +1863,48 @@ function DeepResearchWorkbenchInner() {
|
||||
const updateAdvancedOption = useCallback(
|
||||
<K extends keyof AdvancedResearchOptions>(key: K, value: AdvancedResearchOptions[K]) => {
|
||||
setAdvancedOptions((current) => ({ ...current, [key]: value }) as AdvancedResearchOptions);
|
||||
if (key !== "customOutline" || !session?.id) return;
|
||||
setReportCardOutlineOverrides((current) => (
|
||||
current[session.id] === value
|
||||
? current
|
||||
: { ...current, [session.id]: value as string }
|
||||
));
|
||||
if (!session?.id) return;
|
||||
if (key === "customOutline") {
|
||||
setReportCardOutlineOverrides((current) => (
|
||||
current[session.id] === value
|
||||
? current
|
||||
: { ...current, [session.id]: value as string }
|
||||
));
|
||||
}
|
||||
if (key === "structureMode") {
|
||||
setReportCardStructureModeOverrides((current) => (
|
||||
current[session.id] === value
|
||||
? current
|
||||
: { ...current, [session.id]: value as ReportStructureMode }
|
||||
));
|
||||
}
|
||||
if (key === "retrievalDirections") {
|
||||
const next = normalizeRetrievalDirections(value);
|
||||
setReportCardRetrievalDirectionsOverrides((current) => {
|
||||
const prev = current[session.id];
|
||||
if (prev && prev.join("\n") === next.join("\n")) return current;
|
||||
return { ...current, [session.id]: next };
|
||||
});
|
||||
}
|
||||
if (key === "retrievalSkills") {
|
||||
const next = normalizeRetrievalSkills(value);
|
||||
setReportCardRetrievalSkillsOverrides((current) => {
|
||||
const prev = current[session.id];
|
||||
if (prev && prev.join("\n") === next.join("\n")) return current;
|
||||
return { ...current, [session.id]: next };
|
||||
});
|
||||
}
|
||||
},
|
||||
[session?.id],
|
||||
);
|
||||
|
||||
const applyReportStructure = useCallback((content: string, structureMode: ReportStructureMode, retrievalDirections: string[] = [], retrievalSkills: string[] = []) => {
|
||||
updateAdvancedOption("customOutline", content);
|
||||
updateAdvancedOption("structureMode", structureMode);
|
||||
updateAdvancedOption("retrievalDirections", normalizeRetrievalDirections(retrievalDirections));
|
||||
updateAdvancedOption("retrievalSkills", normalizeRetrievalSkills(retrievalSkills));
|
||||
}, [updateAdvancedOption]);
|
||||
|
||||
const handleDelete = useCallback(async (id: string) => {
|
||||
try {
|
||||
await actions.delete.mutateAsync(id);
|
||||
@@ -1961,6 +2038,9 @@ function DeepResearchWorkbenchInner() {
|
||||
defaultMode={session.mode}
|
||||
defaultOptions={advancedOptionsFromConfig(session.config)}
|
||||
outlineOverride={reportCardOutlineOverride}
|
||||
structureModeOverride={reportCardStructureModeOverride}
|
||||
retrievalDirectionsOverride={reportCardRetrievalDirectionsOverride}
|
||||
retrievalSkillsOverride={reportCardRetrievalSkillsOverride}
|
||||
defaultGenerateImages={session.config?.generate_images === true}
|
||||
modelLabel={modelLabel}
|
||||
materialCount={materialCount}
|
||||
@@ -2223,7 +2303,7 @@ function DeepResearchWorkbenchInner() {
|
||||
className="h-7 px-2 text-xs"
|
||||
disabled={jobWriting}
|
||||
appliedContent={advancedOptions.customOutline}
|
||||
onApply={(content) => updateAdvancedOption("customOutline", content)}
|
||||
onApply={applyReportStructure}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3197,6 +3277,10 @@ function AdvancedResearchSettingsDialog({
|
||||
id="research-outline"
|
||||
value={options.customOutline}
|
||||
onChange={(value) => onChange("customOutline", value)}
|
||||
structureMode={options.structureMode}
|
||||
onStructureModeChange={(mode) => onChange("structureMode", mode)}
|
||||
onRetrievalDirectionsChange={(directions) => onChange("retrievalDirections", directions)}
|
||||
onRetrievalSkillsChange={(skills) => onChange("retrievalSkills", skills)}
|
||||
/>
|
||||
|
||||
{/* 工作流配置:报告结构库(后台 /api/report-structures),选中卡片即填充上方报告结构 */}
|
||||
@@ -3207,7 +3291,12 @@ function AdvancedResearchSettingsDialog({
|
||||
</span>
|
||||
<WorkflowConfigButton
|
||||
appliedContent={options.customOutline}
|
||||
onApply={(content) => onChange("customOutline", content)}
|
||||
onApply={(content, structureMode, retrievalDirections, retrievalSkills) => {
|
||||
onChange("customOutline", content);
|
||||
onChange("structureMode", structureMode);
|
||||
onChange("retrievalDirections", retrievalDirections);
|
||||
onChange("retrievalSkills", retrievalSkills);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -32,7 +32,15 @@ import {
|
||||
type ResearchMode,
|
||||
type ResearchTone,
|
||||
} from "@/strategy-components/api/deep-research";
|
||||
import { useReportStructures } from "@/strategy-components/api/report-structures";
|
||||
import {
|
||||
DEFAULT_REPORT_STRUCTURE_MODE,
|
||||
normalizeReportStructureMode,
|
||||
normalizeRetrievalDirections,
|
||||
normalizeRetrievalSkills,
|
||||
REPORT_STRUCTURE_MODE_OPTIONS,
|
||||
type ReportStructureMode,
|
||||
useReportStructures,
|
||||
} from "@/strategy-components/api/report-structures";
|
||||
import { WorkflowConfigButton } from "./WorkflowConfigDialog";
|
||||
|
||||
const MODE_LABELS: Record<ResearchMode, string> = {
|
||||
@@ -65,6 +73,9 @@ export type AdvancedResearchOptions = {
|
||||
maxGeneratedImages: string;
|
||||
thinkingEnabled: boolean;
|
||||
customOutline: string;
|
||||
structureMode: ReportStructureMode;
|
||||
retrievalDirections: string[];
|
||||
retrievalSkills: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -204,10 +215,16 @@ export function ReportStructureSelect({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
onStructureModeChange,
|
||||
onRetrievalDirectionsChange,
|
||||
onRetrievalSkillsChange,
|
||||
}: {
|
||||
id: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onStructureModeChange?: (mode: ReportStructureMode) => void;
|
||||
onRetrievalDirectionsChange?: (directions: string[]) => void;
|
||||
onRetrievalSkillsChange?: (skills: string[]) => void;
|
||||
}) {
|
||||
// 与「工作流配置」共用报告结构库;创建/编辑/启停后 React Query 的失效会
|
||||
// 自动刷新本下拉,避免维护一份会逐渐过时的前端硬编码模板。
|
||||
@@ -223,12 +240,20 @@ export function ReportStructureSelect({
|
||||
// “自定义” is an intentional blank starting point. Saved structures are
|
||||
// always available as one-click scaffolds whenever the user wants them.
|
||||
onChange("");
|
||||
onStructureModeChange?.(DEFAULT_REPORT_STRUCTURE_MODE);
|
||||
onRetrievalDirectionsChange?.([]);
|
||||
onRetrievalSkillsChange?.([]);
|
||||
return;
|
||||
}
|
||||
if (!next.startsWith(REPORT_STRUCTURE_VALUE_PREFIX)) return;
|
||||
const structureId = next.slice(REPORT_STRUCTURE_VALUE_PREFIX.length);
|
||||
const structure = structures.find((item) => item.id === structureId);
|
||||
if (structure) onChange(structure.content);
|
||||
if (structure) {
|
||||
onChange(structure.content);
|
||||
onStructureModeChange?.(structure.structureMode);
|
||||
onRetrievalDirectionsChange?.(structure.retrievalDirections);
|
||||
onRetrievalSkillsChange?.(structure.retrievalSkills);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -264,23 +289,82 @@ export function ReportStructureSelect({
|
||||
);
|
||||
}
|
||||
|
||||
export function StructureModeSelect({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
id?: string;
|
||||
value: ReportStructureMode;
|
||||
onChange: (value: ReportStructureMode) => void;
|
||||
}) {
|
||||
const selected = REPORT_STRUCTURE_MODE_OPTIONS.find((option) => option.value === value)
|
||||
?? REPORT_STRUCTURE_MODE_OPTIONS[1];
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor={id} className="mb-1.5 block text-xs font-medium">
|
||||
结构约束
|
||||
</label>
|
||||
<Select value={value} onValueChange={(next) => onChange(normalizeReportStructureMode(next))}>
|
||||
<SelectTrigger id={id} className="h-9 text-sm" aria-label="结构约束">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{REPORT_STRUCTURE_MODE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="mt-1 text-[11px] leading-4 text-muted-foreground">
|
||||
{selected.description}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Shared by the initial settings dialog and the write-confirmation card. */
|
||||
export function ReportOutlineEditor({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
structureMode,
|
||||
onStructureModeChange,
|
||||
onRetrievalDirectionsChange,
|
||||
onRetrievalSkillsChange,
|
||||
showStructureSelect = true,
|
||||
}: {
|
||||
id: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
structureMode?: ReportStructureMode;
|
||||
onStructureModeChange?: (mode: ReportStructureMode) => void;
|
||||
onRetrievalDirectionsChange?: (directions: string[]) => void;
|
||||
onRetrievalSkillsChange?: (skills: string[]) => void;
|
||||
/** The confirmation card already exposes its picker in the primary form. */
|
||||
showStructureSelect?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{showStructureSelect && (
|
||||
<ReportStructureSelect id={id} value={value} onChange={onChange} />
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<ReportStructureSelect
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onStructureModeChange={onStructureModeChange}
|
||||
onRetrievalDirectionsChange={onRetrievalDirectionsChange}
|
||||
onRetrievalSkillsChange={onRetrievalSkillsChange}
|
||||
/>
|
||||
{onStructureModeChange && (
|
||||
<StructureModeSelect
|
||||
id={`${id}-mode`}
|
||||
value={structureMode ?? DEFAULT_REPORT_STRUCTURE_MODE}
|
||||
onChange={onStructureModeChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<label htmlFor={id} className="block pt-1 text-xs font-medium">
|
||||
报告结构(可编辑)
|
||||
@@ -314,6 +398,9 @@ export const DEFAULT_ADVANCED_OPTIONS: AdvancedResearchOptions = {
|
||||
maxGeneratedImages: "2",
|
||||
thinkingEnabled: false,
|
||||
customOutline: DEFAULT_REPORT_OUTLINE,
|
||||
structureMode: DEFAULT_REPORT_STRUCTURE_MODE,
|
||||
retrievalDirections: [],
|
||||
retrievalSkills: [],
|
||||
};
|
||||
|
||||
/** Seed the card's editable options from the frozen session config snapshot. */
|
||||
@@ -338,6 +425,9 @@ export function advancedOptionsFromConfig(config?: Record<string, unknown>): Adv
|
||||
customOutline: typeof config?.custom_outline === "string" && config.custom_outline.trim()
|
||||
? config.custom_outline
|
||||
: DEFAULT_REPORT_OUTLINE,
|
||||
structureMode: normalizeReportStructureMode(config?.structure_mode),
|
||||
retrievalDirections: normalizeRetrievalDirections(config?.retrieval_directions),
|
||||
retrievalSkills: normalizeRetrievalSkills(config?.retrieval_skills),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -375,6 +465,9 @@ export function buildReportCardConfig(
|
||||
}
|
||||
if (generateImages) config.max_generated_images = boundedInteger(options.maxGeneratedImages, 2, 1, 4);
|
||||
if (options.customOutline.trim()) config.custom_outline = options.customOutline.trim().slice(0, 4_000);
|
||||
config.structure_mode = normalizeReportStructureMode(options.structureMode);
|
||||
config.retrieval_directions = normalizeRetrievalDirections(options.retrievalDirections);
|
||||
config.retrieval_skills = normalizeRetrievalSkills(options.retrievalSkills);
|
||||
return config;
|
||||
}
|
||||
|
||||
@@ -388,6 +481,9 @@ export function ReportConfigCard({
|
||||
* already made in this card intact.
|
||||
*/
|
||||
outlineOverride,
|
||||
structureModeOverride,
|
||||
retrievalDirectionsOverride,
|
||||
retrievalSkillsOverride,
|
||||
defaultGenerateImages,
|
||||
modelLabel,
|
||||
materialCount,
|
||||
@@ -401,6 +497,9 @@ export function ReportConfigCard({
|
||||
defaultMode: ResearchMode;
|
||||
defaultOptions: AdvancedResearchOptions;
|
||||
outlineOverride?: string;
|
||||
structureModeOverride?: ReportStructureMode;
|
||||
retrievalDirectionsOverride?: string[];
|
||||
retrievalSkillsOverride?: string[];
|
||||
defaultGenerateImages: boolean;
|
||||
modelLabel: string;
|
||||
/** Number of unique, harvestable materials visible in the collector thread. */
|
||||
@@ -417,6 +516,9 @@ export function ReportConfigCard({
|
||||
const [options, setOptions] = useState<AdvancedResearchOptions>(() => ({
|
||||
...defaultOptions,
|
||||
customOutline: outlineOverride ?? defaultOptions.customOutline,
|
||||
structureMode: structureModeOverride ?? defaultOptions.structureMode,
|
||||
retrievalDirections: retrievalDirectionsOverride ?? defaultOptions.retrievalDirections,
|
||||
retrievalSkills: retrievalSkillsOverride ?? defaultOptions.retrievalSkills,
|
||||
}));
|
||||
const [generateImages, setGenerateImages] = useState(defaultGenerateImages);
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
@@ -426,13 +528,32 @@ export function ReportConfigCard({
|
||||
// must be reflected here, but should not reset the card's mode, image switch,
|
||||
// or any other in-progress edits.
|
||||
useEffect(() => {
|
||||
if (outlineOverride === undefined) return;
|
||||
setOptions((current) => (
|
||||
current.customOutline === outlineOverride
|
||||
? current
|
||||
: { ...current, customOutline: outlineOverride }
|
||||
));
|
||||
}, [outlineOverride]);
|
||||
if (
|
||||
outlineOverride === undefined
|
||||
&& structureModeOverride === undefined
|
||||
&& retrievalDirectionsOverride === undefined
|
||||
&& retrievalSkillsOverride === undefined
|
||||
) return;
|
||||
setOptions((current) => {
|
||||
const nextOutline = outlineOverride ?? current.customOutline;
|
||||
const nextMode = structureModeOverride ?? current.structureMode;
|
||||
const nextDirections = retrievalDirectionsOverride ?? current.retrievalDirections;
|
||||
const nextSkills = retrievalSkillsOverride ?? current.retrievalSkills;
|
||||
if (
|
||||
current.customOutline === nextOutline
|
||||
&& current.structureMode === nextMode
|
||||
&& current.retrievalDirections.join("\n") === nextDirections.join("\n")
|
||||
&& current.retrievalSkills.join("\n") === nextSkills.join("\n")
|
||||
) return current;
|
||||
return {
|
||||
...current,
|
||||
customOutline: nextOutline,
|
||||
structureMode: nextMode,
|
||||
retrievalDirections: nextDirections,
|
||||
retrievalSkills: nextSkills,
|
||||
};
|
||||
});
|
||||
}, [outlineOverride, structureModeOverride, retrievalDirectionsOverride, retrievalSkillsOverride]);
|
||||
|
||||
const modes = useMemo(
|
||||
() => (availableModes.length ? availableModes : (["basic", "quick", "detailed", "deep", "multi_agent"] as ResearchMode[])),
|
||||
@@ -475,6 +596,14 @@ export function ReportConfigCard({
|
||||
id="report-card-structure"
|
||||
value={options.customOutline}
|
||||
onChange={(value) => update("customOutline", value)}
|
||||
onStructureModeChange={(mode) => update("structureMode", mode)}
|
||||
onRetrievalDirectionsChange={(directions) => update("retrievalDirections", directions)}
|
||||
onRetrievalSkillsChange={(skills) => update("retrievalSkills", skills)}
|
||||
/>
|
||||
<StructureModeSelect
|
||||
id="report-card-structure-mode"
|
||||
value={options.structureMode}
|
||||
onChange={(mode) => update("structureMode", mode)}
|
||||
/>
|
||||
{imageGenerationAvailable && (
|
||||
<div>
|
||||
@@ -542,13 +671,22 @@ export function ReportConfigCard({
|
||||
id="report-card-outline"
|
||||
value={options.customOutline}
|
||||
onChange={(value) => update("customOutline", value)}
|
||||
structureMode={options.structureMode}
|
||||
onStructureModeChange={(mode) => update("structureMode", mode)}
|
||||
onRetrievalDirectionsChange={(directions) => update("retrievalDirections", directions)}
|
||||
onRetrievalSkillsChange={(skills) => update("retrievalSkills", skills)}
|
||||
showStructureSelect={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end sm:col-span-2">
|
||||
<WorkflowConfigButton
|
||||
appliedContent={options.customOutline}
|
||||
onApply={(value) => update("customOutline", value)}
|
||||
onApply={(value, mode, directions, skills) => {
|
||||
update("customOutline", value);
|
||||
update("structureMode", mode);
|
||||
update("retrievalDirections", directions);
|
||||
update("retrievalSkills", skills);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<label className="flex cursor-pointer items-start gap-2 rounded-lg border border-border/70 bg-background/60 p-2.5 text-[11px] leading-4 sm:col-span-2">
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { ArrowLeft, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { SetupReportStructureApprovalPayload } from "@/components/workspace/messages";
|
||||
import {
|
||||
normalizeReportStructureMode,
|
||||
normalizeRetrievalDirections,
|
||||
normalizeRetrievalSkills,
|
||||
useReportStructureMutations,
|
||||
} from "@/strategy-components/api/report-structures";
|
||||
|
||||
import {
|
||||
EMPTY_REPORT_STRUCTURE_FORM,
|
||||
ReportStructureFormFields,
|
||||
type ReportStructureFormValue,
|
||||
} from "./ReportStructureFormFields";
|
||||
import { ReportStructureSetupChat } from "./ReportStructureSetupChat";
|
||||
|
||||
/**
|
||||
* 深度研究「智能新增报告结构」页。
|
||||
*
|
||||
* 左对话(几轮问清需求 → 确认卡回填)+ 右表单(可纯手工填写)。保存后回到来源页。
|
||||
*/
|
||||
export default function ReportStructureCreatePage() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const mutations = useReportStructureMutations();
|
||||
const [form, setForm] = useState<ReportStructureFormValue>(EMPTY_REPORT_STRUCTURE_FORM);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const backPath = useMemo(() => {
|
||||
const path = location.pathname.replace(/\/structures\/new\/?$/, "");
|
||||
return path || "/page/workspace/deep-research";
|
||||
}, [location.pathname]);
|
||||
|
||||
const applyProposal = useCallback((payload: SetupReportStructureApprovalPayload) => {
|
||||
setForm((current) => ({
|
||||
title: payload.title?.trim() || current.title,
|
||||
name: payload.name?.trim() || current.name,
|
||||
content: payload.content?.trim() || current.content,
|
||||
structureMode: payload.structure_mode
|
||||
? normalizeReportStructureMode(payload.structure_mode)
|
||||
: current.structureMode,
|
||||
retrievalDirections:
|
||||
payload.retrieval_directions && payload.retrieval_directions.length > 0
|
||||
? normalizeRetrievalDirections(payload.retrieval_directions)
|
||||
: current.retrievalDirections,
|
||||
retrievalSkills:
|
||||
payload.retrieval_skills && payload.retrieval_skills.length > 0
|
||||
? normalizeRetrievalSkills(payload.retrieval_skills)
|
||||
: current.retrievalSkills,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.title.trim() || !form.content.trim()) {
|
||||
toast.error("请填写标题和结构内容");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
await mutations.create.mutateAsync({
|
||||
title: form.title.trim(),
|
||||
name: form.name.trim(),
|
||||
content: form.content,
|
||||
structureMode: form.structureMode,
|
||||
retrievalDirections: form.retrievalDirections,
|
||||
retrievalSkills: form.retrievalSkills,
|
||||
});
|
||||
toast.success(`已保存报告结构「${form.title.trim()}」`);
|
||||
navigate(backPath, { replace: true, state: { openWorkflowConfig: true } });
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "保存失败");
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-background">
|
||||
<header className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-5 py-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Button variant="ghost" size="icon-sm" onClick={() => navigate(backPath)} aria-label="返回">
|
||||
<ArrowLeft className="size-4" />
|
||||
</Button>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-base font-semibold">智能新增报告结构</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
左侧用问答描述需求,出现确认卡后点「应用到表单」;也可直接在右侧手工填写。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => navigate(backPath)} disabled={saving}>
|
||||
取消
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => void handleSave()} disabled={saving}>
|
||||
{saving && <Loader2 className="mr-1.5 size-4 animate-spin" />}
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<div className="min-w-0 flex-[7] border-r border-border">
|
||||
<ReportStructureSetupChat onApprove={applyProposal} />
|
||||
</div>
|
||||
<aside className="flex min-w-0 flex-[3] flex-col overflow-hidden">
|
||||
<div className="shrink-0 border-b border-border px-5 py-3">
|
||||
<h2 className="text-sm font-semibold">报告结构配置</h2>
|
||||
<p className="text-xs text-muted-foreground">对话确认后自动填充,也可随时改。</p>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-5">
|
||||
<ReportStructureFormFields value={form} onChange={setForm} />
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import {
|
||||
type ReportStructureInput,
|
||||
type ReportStructureMode,
|
||||
MAX_RETRIEVAL_DIRECTIONS,
|
||||
MAX_RETRIEVAL_DIRECTION_CHARS,
|
||||
MAX_RETRIEVAL_SKILLS,
|
||||
REPORT_STRUCTURE_MODE_OPTIONS,
|
||||
normalizeReportStructureMode,
|
||||
normalizeRetrievalDirections,
|
||||
normalizeRetrievalSkills,
|
||||
} from "@/strategy-components/api/report-structures";
|
||||
|
||||
export type ReportStructureFormValue = ReportStructureInput;
|
||||
|
||||
export const EMPTY_REPORT_STRUCTURE_FORM: ReportStructureFormValue = {
|
||||
title: "",
|
||||
name: "",
|
||||
content: "",
|
||||
structureMode: "adaptive",
|
||||
retrievalDirections: [],
|
||||
retrievalSkills: [],
|
||||
};
|
||||
|
||||
function skillChipLabel(name: string, nameZh?: string | null) {
|
||||
const zh = nameZh?.trim();
|
||||
return zh || desensitize(name);
|
||||
}
|
||||
|
||||
function RetrievalSkillPicker({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string[];
|
||||
onChange: (next: string[]) => void;
|
||||
}) {
|
||||
const { skills, isLoading } = useSkills();
|
||||
const selected = normalizeRetrievalSkills(value);
|
||||
const byName = useMemo(() => new Map(skills.map((skill) => [skill.name, skill])), [skills]);
|
||||
const remaining = useMemo(
|
||||
() => skills.filter((skill) => skill.enabled !== false && !selected.includes(skill.name)),
|
||||
[skills, selected],
|
||||
);
|
||||
const atCap = selected.length >= MAX_RETRIEVAL_SKILLS;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span className="mb-1.5 block text-sm font-medium">检索来源</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{selected.map((name) => (
|
||||
<span
|
||||
key={name}
|
||||
className="inline-flex max-w-full items-center gap-1 rounded-full border border-border bg-muted/50 px-2 py-0.5 text-xs"
|
||||
>
|
||||
<span className="truncate">{skillChipLabel(name, byName.get(name)?.name_zh)}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`移除检索技能 ${name}`}
|
||||
className="rounded-full p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
onClick={() => onChange(selected.filter((item) => item !== name))}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<Select
|
||||
key={selected.join("|")}
|
||||
onValueChange={(name) => {
|
||||
if (!name || atCap) return;
|
||||
onChange(normalizeRetrievalSkills([...selected, name]));
|
||||
}}
|
||||
disabled={atCap || remaining.length === 0}
|
||||
>
|
||||
<SelectTrigger aria-label="添加检索技能" className="mt-2">
|
||||
<SelectValue placeholder={isLoading ? "正在加载技能…" : atCap ? "已达上限" : "选择技能添加到检索来源"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{remaining.map((skill) => (
|
||||
<SelectItem key={skill.name} value={skill.name}>
|
||||
{skillChipLabel(skill.name, skill.name_zh)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="mt-1 block text-[11px] text-muted-foreground">
|
||||
选填。指定后,搜集素材会调用这些技能检索;不填则仍由收集员使用默认联网/知识库工具。
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReportStructureFormFields({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: ReportStructureFormValue;
|
||||
onChange: (next: ReportStructureFormValue) => void;
|
||||
}) {
|
||||
const [directionDraft, setDirectionDraft] = useState("");
|
||||
|
||||
const set = <K extends keyof ReportStructureFormValue>(key: K, next: ReportStructureFormValue[K]) =>
|
||||
onChange({ ...value, [key]: next });
|
||||
|
||||
const commitDirectionDraft = () => {
|
||||
const next = normalizeRetrievalDirections([...value.retrievalDirections, directionDraft]);
|
||||
set("retrievalDirections", next);
|
||||
setDirectionDraft("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-sm font-medium">标题</span>
|
||||
<Input
|
||||
value={value.title}
|
||||
onChange={(event) => set("title", event.target.value)}
|
||||
maxLength={256}
|
||||
placeholder="例如:行业研究通用结构"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-sm font-medium">描述</span>
|
||||
<Input
|
||||
value={value.name}
|
||||
onChange={(event) => set("name", event.target.value)}
|
||||
maxLength={256}
|
||||
placeholder="可选,一句话描述这套结构的适用场景"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-sm font-medium">结构约束</span>
|
||||
<Select
|
||||
value={value.structureMode}
|
||||
onValueChange={(next) => set("structureMode", normalizeReportStructureMode(next) as ReportStructureMode)}
|
||||
>
|
||||
<SelectTrigger aria-label="结构约束">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{REPORT_STRUCTURE_MODE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="mt-1 block text-[11px] text-muted-foreground">
|
||||
{REPORT_STRUCTURE_MODE_OPTIONS.find((option) => option.value === value.structureMode)?.description}
|
||||
</span>
|
||||
</label>
|
||||
<div>
|
||||
<span className="mb-1.5 block text-sm font-medium">检索方向</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{value.retrievalDirections.map((item) => (
|
||||
<span
|
||||
key={item}
|
||||
className="inline-flex max-w-full items-center gap-1 rounded-full border border-border bg-muted/50 px-2 py-0.5 text-xs"
|
||||
>
|
||||
<span className="truncate">{item}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`移除检索方向 ${item}`}
|
||||
className="rounded-full p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
onClick={() =>
|
||||
set(
|
||||
"retrievalDirections",
|
||||
value.retrievalDirections.filter((entry) => entry !== item),
|
||||
)
|
||||
}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<Input
|
||||
value={directionDraft}
|
||||
onChange={(event) => setDirectionDraft(event.target.value)}
|
||||
maxLength={MAX_RETRIEVAL_DIRECTION_CHARS}
|
||||
placeholder="输入后按回车添加,可写多个;用作拆词方向,不是直接搜索的词"
|
||||
className="mt-2"
|
||||
disabled={value.retrievalDirections.length >= MAX_RETRIEVAL_DIRECTIONS}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === ",") {
|
||||
event.preventDefault();
|
||||
commitDirectionDraft();
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (directionDraft.trim()) commitDirectionDraft();
|
||||
}}
|
||||
/>
|
||||
<span className="mt-1 block text-[11px] text-muted-foreground">
|
||||
选填。指定后,收集员会按这些方向拆分具体检索词,而不是把方向名称直接拿去搜;不填则仍由收集员自行拆词。
|
||||
</span>
|
||||
</div>
|
||||
<RetrievalSkillPicker
|
||||
value={value.retrievalSkills}
|
||||
onChange={(next) => set("retrievalSkills", next)}
|
||||
/>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-sm font-medium">结构内容</span>
|
||||
<Textarea
|
||||
value={value.content}
|
||||
onChange={(event) => set("content", event.target.value)}
|
||||
maxLength={20000}
|
||||
placeholder={"报告结构正文,支持 Markdown 标题,例如:\n# 引言\n# 一、事实与背景\n## 1.1 研究范围"}
|
||||
className="min-h-[260px] resize-y font-mono text-xs"
|
||||
/>
|
||||
<span className="mt-1 block text-[11px] text-muted-foreground">
|
||||
选中卡片时,该内容会填充到高级配置的「报告结构」。
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Sparkles, Zap } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
PromptInput,
|
||||
PromptInputFooter,
|
||||
PromptInputSubmit,
|
||||
PromptInputTextarea,
|
||||
} from "@/components/ai-elements/prompt-input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { ArtifactsProvider } from "@/components/workspace/artifacts";
|
||||
import {
|
||||
MessageList,
|
||||
type SetupReportStructureApprovalPayload,
|
||||
} from "@/components/workspace/messages";
|
||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||
import { extractContentFromMessage, extractReasoningContentFromMessage, hasToolCalls } from "@/core/messages/utils";
|
||||
import { getAPIClient } from "@/core/api";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import { useThreadStream } from "@/core/threads/hooks";
|
||||
import { uuid } from "@/core/utils/uuid";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { ReportStructureWelcome } from "./ReportStructureWelcome";
|
||||
|
||||
/**
|
||||
* 报告结构智能新增的左侧对话(对标 WritingSetupChat / 新建智能体)。
|
||||
*
|
||||
* context 带 ``is_report_structure_setup``,后端只挂 setup_report_structure + ask_clarification。
|
||||
* 模型提交后被拦截成确认卡;点「应用到表单」回填右侧,不落库。
|
||||
*/
|
||||
export function ReportStructureSetupChat({
|
||||
onApprove,
|
||||
}: {
|
||||
onApprove: (payload: SetupReportStructureApprovalPayload) => void;
|
||||
}) {
|
||||
const threadId = useMemo(() => uuid(), []);
|
||||
const [threadPrepared, setThreadPrepared] = useState(false);
|
||||
const [chatModel, setChatModel] = useState("");
|
||||
const [quickMode, setQuickMode] = useState(false);
|
||||
const { models, isLoading: modelsLoading } = useModels();
|
||||
const { skills } = useSkills();
|
||||
|
||||
useEffect(() => {
|
||||
if (models.length === 0) return;
|
||||
setChatModel((prev) => (prev && models.some((m) => m.name === prev) ? prev : models[0]!.name));
|
||||
}, [models]);
|
||||
|
||||
const validRetrievalSkills = useMemo(
|
||||
() =>
|
||||
skills
|
||||
.filter((skill) => skill.enabled !== false)
|
||||
.slice(0, 40)
|
||||
.map((skill) => ({ name: skill.name, name_zh: skill.name_zh ?? "" })),
|
||||
[skills],
|
||||
);
|
||||
|
||||
const [thread, sendMessage] = useThreadStream({
|
||||
threadId,
|
||||
context: {
|
||||
mode: "flash",
|
||||
is_report_structure_setup: true,
|
||||
writing_setup_quick: quickMode,
|
||||
valid_retrieval_skills: validRetrievalSkills,
|
||||
model_name: chatModel || undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const ensureThreadExists = useCallback(async () => {
|
||||
if (threadPrepared) return;
|
||||
await getAPIClient().threads.create({ threadId, metadata: { thread_type: "report_structure_setup" } });
|
||||
setThreadPrepared(true);
|
||||
}, [threadId, threadPrepared]);
|
||||
|
||||
const handleChatSubmit = useCallback(
|
||||
async (text: string) => {
|
||||
if (thread.isLoading) {
|
||||
void thread.stop();
|
||||
return;
|
||||
}
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return;
|
||||
await ensureThreadExists();
|
||||
await sendMessage(threadId, { text: trimmed, files: [] });
|
||||
},
|
||||
[thread, ensureThreadExists, sendMessage, threadId],
|
||||
);
|
||||
|
||||
const handleClarificationSubmit = useCallback(
|
||||
async (text: string) => {
|
||||
if (thread.isLoading) {
|
||||
try {
|
||||
await Promise.resolve(thread.stop());
|
||||
} catch {
|
||||
/* best-effort: still send the answer */
|
||||
}
|
||||
}
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return;
|
||||
await ensureThreadExists();
|
||||
await sendMessage(threadId, { text: trimmed, files: [] });
|
||||
},
|
||||
[thread, ensureThreadExists, sendMessage, threadId],
|
||||
);
|
||||
|
||||
const submitStatus = thread.error ? "error" : thread.isLoading ? "streaming" : "ready";
|
||||
|
||||
const handleApprove = useCallback(
|
||||
async (payload: SetupReportStructureApprovalPayload) => {
|
||||
onApprove(payload);
|
||||
toast.success("已应用到表单,可继续编辑后保存");
|
||||
},
|
||||
[onApprove],
|
||||
);
|
||||
|
||||
const handleReject = useCallback(async () => {
|
||||
await handleChatSubmit("这份配置先不应用,请继续帮我调整。");
|
||||
}, [handleChatSubmit]);
|
||||
|
||||
const showWelcome = thread.messages.length === 0 && !thread.isLoading;
|
||||
|
||||
const showThinkingPlaceholder = useMemo(() => {
|
||||
if (!thread.isLoading) return false;
|
||||
const last = thread.messages[thread.messages.length - 1];
|
||||
if (!last || last.type !== "ai") return true;
|
||||
const hasOutput =
|
||||
!!extractContentFromMessage(last).trim() ||
|
||||
!!extractReasoningContentFromMessage(last) ||
|
||||
hasToolCalls(last);
|
||||
return !hasOutput;
|
||||
}, [thread.isLoading, thread.messages]);
|
||||
|
||||
return (
|
||||
<ThreadContext.Provider value={{ thread }}>
|
||||
<ArtifactsProvider>
|
||||
<div className="flex size-full flex-col bg-background dark:bg-transparent">
|
||||
{!showWelcome && (
|
||||
<header className="flex shrink-0 items-center gap-2 border-b px-4 py-3">
|
||||
<Sparkles className="size-4 text-primary" />
|
||||
<h2 className="text-sm font-semibold">结构助手</h2>
|
||||
<span className="text-muted-foreground text-xs">描述你想要的报告结构,我来帮你填好右侧表单</span>
|
||||
</header>
|
||||
)}
|
||||
|
||||
<div className="min-h-0 flex-1">
|
||||
{showWelcome ? (
|
||||
<ReportStructureWelcome />
|
||||
) : (
|
||||
<MessageList
|
||||
className="size-full"
|
||||
threadId={threadId}
|
||||
thread={thread}
|
||||
onClarificationSubmit={handleClarificationSubmit}
|
||||
onSetupReportStructureApprove={handleApprove}
|
||||
onSetupReportStructureReject={handleReject}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showThinkingPlaceholder && (
|
||||
<div className="shrink-0 border-t bg-background px-4 py-2 dark:bg-transparent">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="inline-block size-1.5 animate-pulse rounded-full bg-blue-500" />
|
||||
正在整理…
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-background dark:bg-transparent shrink-0 border-t px-4 py-4">
|
||||
<div className="space-y-3 rounded-2xl border border-[#ece5e4] bg-card p-3 shadow-sm dark:border-[#1B477E] dark:bg-[#022248]/40">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
|
||||
<Sparkles className="size-3.5 shrink-0 text-primary" />
|
||||
<span className="shrink-0">当前对话模型</span>
|
||||
{modelsLoading || models.length === 0 ? (
|
||||
<div className="border-input bg-background text-muted-foreground flex h-7 min-w-[9rem] items-center rounded-md border px-2 text-xs shadow-xs dark:border-[#1B477E] dark:bg-[#022248]">
|
||||
{modelsLoading ? "加载中…" : "暂无模型"}
|
||||
</div>
|
||||
) : (
|
||||
<Select value={chatModel} onValueChange={setChatModel} disabled={thread.isLoading}>
|
||||
<SelectTrigger
|
||||
className={cn(
|
||||
"border-input bg-background h-7 min-w-[9rem] max-w-[14rem] px-2 text-xs shadow-xs outline-none",
|
||||
"dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEFF]",
|
||||
)}
|
||||
>
|
||||
<SelectValue placeholder="选择模型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{models.map((model) => (
|
||||
<SelectItem key={model.name} value={model.name}>
|
||||
{model.display_name || model.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
<label
|
||||
className={cn(
|
||||
"flex shrink-0 cursor-pointer select-none items-center gap-1.5 rounded-md border px-2 py-1 text-xs transition",
|
||||
quickMode
|
||||
? "border-primary/40 bg-primary/5 text-primary dark:border-[#1B477E] dark:bg-[#022248]"
|
||||
: "border-input bg-background text-muted-foreground dark:border-[#1B477E] dark:bg-[#022248]",
|
||||
)}
|
||||
title="开启后跳过逐项澄清,给出主题即直接生成大纲"
|
||||
>
|
||||
<Zap className={cn("size-3.5 shrink-0", quickMode && "fill-current")} />
|
||||
<span className="shrink-0">快捷模式</span>
|
||||
<Switch
|
||||
checked={quickMode}
|
||||
onCheckedChange={setQuickMode}
|
||||
disabled={thread.isLoading}
|
||||
className="ml-0.5 scale-90"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<PromptInput onSubmit={({ text }) => void handleChatSubmit(text)}>
|
||||
<PromptInputTextarea placeholder="例如:帮我建一套市场分析报告结构,固定大纲,检索方向是各品牌汽车销量" />
|
||||
<PromptInputFooter className="justify-end">
|
||||
<PromptInputSubmit status={submitStatus} size="icon-sm" />
|
||||
</PromptInputFooter>
|
||||
</PromptInput>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ArtifactsProvider>
|
||||
</ThreadContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Compass, ListTree, Sparkles } from "lucide-react";
|
||||
|
||||
/**
|
||||
* 报告结构智能新增页左栏欢迎屏(对标 AI 写作 WritingWelcome)。
|
||||
* 仅在助手对话还没有任何消息时显示。
|
||||
*/
|
||||
|
||||
const STEPS = [
|
||||
{
|
||||
n: "1",
|
||||
title: "对话描述需求",
|
||||
desc: "用一句话说你想要什么报告结构,例如「市场分析固定大纲,检索方向是各品牌销量」。",
|
||||
icon: Sparkles,
|
||||
iconClass: "bg-indigo-50 text-indigo-600 dark:bg-indigo-500/15 dark:text-indigo-300",
|
||||
},
|
||||
{
|
||||
n: "2",
|
||||
title: "确认大纲卡片",
|
||||
desc: "助手会先给出 Markdown 大纲,确认后出现「应用到表单」卡片,一点即可填到右侧。",
|
||||
icon: ListTree,
|
||||
iconClass: "bg-amber-50 text-amber-600 dark:bg-amber-500/15 dark:text-amber-300",
|
||||
},
|
||||
{
|
||||
n: "3",
|
||||
title: "保存入库",
|
||||
desc: "右侧表单可再改标题、约束、检索方向。点保存后回到工作流配置列表。",
|
||||
icon: Compass,
|
||||
iconClass: "bg-emerald-50 text-emerald-600 dark:bg-emerald-500/15 dark:text-emerald-300",
|
||||
},
|
||||
];
|
||||
|
||||
export function ReportStructureWelcome() {
|
||||
return (
|
||||
<div className="size-full overflow-y-auto">
|
||||
<div className="mx-auto flex min-h-full w-full max-w-3xl flex-col px-6 py-7">
|
||||
<div className="flex flex-1 flex-col justify-center gap-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold leading-snug tracking-tight text-slate-800 dark:text-white">
|
||||
智能新增报告结构
|
||||
</h2>
|
||||
<p className="mt-2 max-w-xl text-sm leading-relaxed text-slate-500 dark:text-white/60">
|
||||
左侧用对话把结构聊清楚,确认后填到右侧表单;也可以
|
||||
<strong className="font-semibold text-slate-700 dark:text-white/80">直接在右侧手工填写</strong>
|
||||
,再点保存。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
{STEPS.map((step) => (
|
||||
<div
|
||||
key={step.n}
|
||||
className="flex flex-col gap-2 rounded-xl border border-slate-100/80 bg-slate-50/50 p-4 transition-colors hover:bg-slate-50 dark:border-white/5 dark:bg-white/[0.02] dark:hover:bg-white/5"
|
||||
>
|
||||
<div className={`flex size-8 items-center justify-center rounded-lg ${step.iconClass}`}>
|
||||
<step.icon className="size-4" />
|
||||
</div>
|
||||
<h4 className="text-sm font-semibold text-slate-800 dark:text-white">
|
||||
{step.n}. {step.title}
|
||||
</h4>
|
||||
<p className="text-xs leading-relaxed text-slate-500 dark:text-white/55">{step.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import { DocumentRewriteDialog } from "@/components/workspace/artifacts/document
|
||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||
import type { AgentThreadState } from "@/core/threads";
|
||||
import type { DocumentRewriteStyle } from "@/open-canvas/api/rewrite";
|
||||
import type { ReportStructureMode } from "@/strategy-components/api/report-structures";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const CLOSE_MODE = { chat: 100, artifacts: 0 };
|
||||
@@ -58,6 +59,7 @@ interface ResearchSandboxPanelProps {
|
||||
style: DocumentRewriteStyle;
|
||||
modelName: string | null;
|
||||
reportOutline: string;
|
||||
structureMode?: ReportStructureMode;
|
||||
/** Regeneration forks the report session before it starts the rewrite. */
|
||||
createNewReport?: boolean;
|
||||
}) => void;
|
||||
|
||||
@@ -12,17 +12,29 @@
|
||||
* custom_outline),弹窗切换为展示 标题 + 接口内容,右上角「返回」回列表。
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import {
|
||||
BookOpen,
|
||||
CheckCircle2,
|
||||
ChevronLeft,
|
||||
Compass,
|
||||
Copy,
|
||||
Database,
|
||||
FileText,
|
||||
Info,
|
||||
ListTree,
|
||||
Loader2,
|
||||
Pencil,
|
||||
PencilLine,
|
||||
Plus,
|
||||
Search,
|
||||
Trash2,
|
||||
Workflow,
|
||||
X,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -49,10 +61,22 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
type ReportStructure,
|
||||
type ReportStructureInput,
|
||||
type ReportStructureMode,
|
||||
type ReportStructureStatusFilter,
|
||||
MAX_RETRIEVAL_DIRECTIONS,
|
||||
MAX_RETRIEVAL_DIRECTION_CHARS,
|
||||
MAX_RETRIEVAL_SKILLS,
|
||||
REPORT_STRUCTURE_MODE_OPTIONS,
|
||||
normalizeReportStructureMode,
|
||||
normalizeRetrievalDirections,
|
||||
normalizeRetrievalSkills,
|
||||
reportStructureModeLabel,
|
||||
useReportStructures,
|
||||
useReportStructureMutations,
|
||||
} from "@/strategy-components/api/report-structures";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
|
||||
// ── shared helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -75,6 +99,168 @@ function creatorLabel(structure: ReportStructure): string {
|
||||
return structure.createdBy.split("@")[0] || structure.createdBy;
|
||||
}
|
||||
|
||||
/**
|
||||
* 大纲常写成「每行一条、中间没有空行」,markdown 会合成一段。
|
||||
* 把单个换行转成硬换行,空行仍作为段落分隔。
|
||||
*/
|
||||
function outlineToMarkdown(content: string): string {
|
||||
return content.replace(/\r\n/g, "\n").replace(/([^\n])\n(?!\n)/g, "$1 \n");
|
||||
}
|
||||
|
||||
function textFromChildren(children: ReactNode): string {
|
||||
if (typeof children === "string" || typeof children === "number") return String(children);
|
||||
if (Array.isArray(children)) return children.map(textFromChildren).join("");
|
||||
return "";
|
||||
}
|
||||
|
||||
function outlineParagraphClassName(children: ReactNode): string {
|
||||
const text = textFromChildren(children).trim();
|
||||
|
||||
if (/^《.+》$/.test(text)) {
|
||||
return "my-5 border-b border-primary/25 pb-4 text-xl font-semibold tracking-tight text-foreground first:mt-0";
|
||||
}
|
||||
if (/^(日期|类型|密级)\s*[::]/.test(text)) {
|
||||
return "my-2 rounded-lg border border-border/80 bg-muted/40 px-3.5 py-2 text-base font-medium leading-7 text-foreground";
|
||||
}
|
||||
if (/^第[一二三四五六七八九十百千万零〇\d]+章/.test(text)) {
|
||||
return "mb-3 mt-8 border-l-4 !border-l-primary bg-primary/5 px-4 py-3 text-xl font-semibold leading-7 text-foreground first:mt-0";
|
||||
}
|
||||
if (/^\d+(?:\.\d+)+\s*/.test(text)) {
|
||||
return "mb-2 mt-6 border-b border-border/70 pb-2 text-lg font-semibold leading-7 text-foreground";
|
||||
}
|
||||
return "my-2 text-base leading-8 whitespace-pre-wrap text-foreground";
|
||||
}
|
||||
|
||||
const structureMarkdownComponents = {
|
||||
img: () => null,
|
||||
p: ({ children }: { children?: ReactNode }) => (
|
||||
<p className={outlineParagraphClassName(children)}>{children}</p>
|
||||
),
|
||||
ul: ({ children }: { children?: ReactNode }) => (
|
||||
<ul className="my-3 list-disc space-y-1.5 pl-6 text-base leading-8 marker:text-primary">{children}</ul>
|
||||
),
|
||||
ol: ({ children }: { children?: ReactNode }) => (
|
||||
<ol className="my-3 list-decimal space-y-1.5 pl-6 text-base leading-8 marker:text-primary">{children}</ol>
|
||||
),
|
||||
li: ({ children }: { children?: ReactNode }) => <li className="leading-8">{children}</li>,
|
||||
h1: ({ children }: { children?: ReactNode }) => (
|
||||
<h1 className="mb-4 mt-1 border-b border-primary/25 pb-4 text-2xl font-semibold tracking-tight text-foreground first:mt-0">{children}</h1>
|
||||
),
|
||||
h2: ({ children }: { children?: ReactNode }) => (
|
||||
<h2 className="mb-3 mt-8 border-l-4 bg-primary/5 px-4 py-3 text-xl font-semibold leading-7 text-foreground first:mt-0" style={{ borderLeftColor: "var(--primary)" }}>{children}</h2>
|
||||
),
|
||||
h3: ({ children }: { children?: ReactNode }) => (
|
||||
<h3 className="mb-2 mt-6 border-b border-border/70 pb-2 text-lg font-semibold leading-7 text-foreground first:mt-0">{children}</h3>
|
||||
),
|
||||
h4: ({ children }: { children?: ReactNode }) => (
|
||||
<h4 className="mb-1.5 mt-4 text-base font-semibold leading-7 text-foreground">{children}</h4>
|
||||
),
|
||||
strong: ({ children }: { children?: ReactNode }) => (
|
||||
<strong className="font-semibold text-foreground">{children}</strong>
|
||||
),
|
||||
em: ({ children }: { children?: ReactNode }) => <em className="italic">{children}</em>,
|
||||
blockquote: ({ children }: { children?: ReactNode }) => (
|
||||
<blockquote className="my-3 border-l-2 border-border pl-4 text-base leading-8 text-muted-foreground italic">{children}</blockquote>
|
||||
),
|
||||
a: ({ children, href }: { children?: ReactNode; href?: string }) => (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer" className="text-primary underline underline-offset-2 hover:opacity-80">
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
table: ({ children }: { children?: ReactNode }) => (
|
||||
<div className="my-3 max-w-full overflow-x-auto">
|
||||
<table className="w-full border-collapse text-base">{children}</table>
|
||||
</div>
|
||||
),
|
||||
th: ({ children }: { children?: ReactNode }) => (
|
||||
<th className="border-b border-border px-2 py-1.5 text-left font-semibold">{children}</th>
|
||||
),
|
||||
td: ({ children }: { children?: ReactNode }) => (
|
||||
<td className="border-b border-border/60 px-2 py-1.5">{children}</td>
|
||||
),
|
||||
hr: () => <hr className="my-5 border-border" />,
|
||||
code: ({ children, className }: { children?: ReactNode; className?: string }) => {
|
||||
const isBlock = /language-/.test(className ?? "");
|
||||
if (isBlock) {
|
||||
return (
|
||||
<pre className="my-3 overflow-x-auto rounded-md bg-muted px-3 py-2 font-mono text-base">
|
||||
<code>{children}</code>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<code className="rounded bg-muted px-1 py-0.5 font-mono text-base">{children}</code>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
function DetailSidebarSection({
|
||||
icon: Icon,
|
||||
title,
|
||||
action,
|
||||
children,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
action?: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="border-t border-border/70 px-6 py-5 first:border-t-0">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Icon className="h-5 w-5 shrink-0" strokeWidth={1.8} aria-hidden />
|
||||
<h3 className="text-base font-semibold leading-6">{title}</h3>
|
||||
{action && <div className="ml-auto shrink-0">{action}</div>}
|
||||
</div>
|
||||
<div className="mt-3 text-base leading-7 text-foreground">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailSidebarTitle({ title }: { title: string }) {
|
||||
return (
|
||||
<section className="px-6 py-6">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<FileText className="h-5 w-5 shrink-0" strokeWidth={1.8} aria-hidden />
|
||||
<span className="text-base font-semibold leading-6">报告标题</span>
|
||||
</div>
|
||||
<h3 className="mt-3 text-xl font-semibold leading-8 tracking-tight text-foreground">{title}</h3>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailModeBadge({ value }: { value: string }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-2 rounded-lg border border-primary/25 bg-primary/5 px-3 py-1 text-base font-semibold leading-6 text-primary">
|
||||
<span className="h-2 w-2 rounded-full bg-primary" aria-hidden />
|
||||
{value}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailInfoIcon({ icon: Icon }: { icon: LucideIcon }) {
|
||||
return (
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<Icon className="h-4 w-4" strokeWidth={1.8} aria-hidden />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailTagList({ items }: { items: string[] }) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{items.map((item) => (
|
||||
<span
|
||||
key={item}
|
||||
className="max-w-full rounded-md bg-primary/10 px-2 py-0.5 text-base font-medium leading-6 text-primary"
|
||||
>
|
||||
<span className="block truncate">{item}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── main dialog ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function WorkflowConfigDialog({
|
||||
@@ -82,13 +268,13 @@ export function WorkflowConfigDialog({
|
||||
onOpenChange,
|
||||
/** 当前已应用的报告结构内容(高级配置的 custom_outline),用于高亮「当前使用」。 */
|
||||
appliedContent,
|
||||
/** 点击卡片选择结构时回填内容。 */
|
||||
/** 点击卡片选择结构时回填内容与结构约束。 */
|
||||
onApply,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
appliedContent?: string;
|
||||
onApply?: (content: string) => void;
|
||||
onApply?: (content: string, structureMode: ReportStructureMode, retrievalDirections: string[], retrievalSkills: string[]) => void;
|
||||
}) {
|
||||
const [statusFilter, setStatusFilter] = useState<ReportStructureStatusFilter>("all");
|
||||
const [titleFilter, setTitleFilter] = useState("");
|
||||
@@ -96,6 +282,8 @@ export function WorkflowConfigDialog({
|
||||
const [editorTarget, setEditorTarget] = useState<ReportStructure | null>(null);
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<ReportStructure | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
// 启用/停用筛选走服务端;标题筛选在本地做(即时、免抖动)。
|
||||
const listQuery = useReportStructures({ status: statusFilter });
|
||||
@@ -125,7 +313,7 @@ export function WorkflowConfigDialog({
|
||||
);
|
||||
|
||||
const handleSelect = (structure: ReportStructure) => {
|
||||
onApply?.(structure.content);
|
||||
onApply?.(structure.content, structure.structureMode, structure.retrievalDirections, structure.retrievalSkills);
|
||||
setDetailId(structure.id);
|
||||
toast.success(`已选择报告结构「${structure.title}」,并填充到报告结构配置`);
|
||||
};
|
||||
@@ -152,12 +340,22 @@ export function WorkflowConfigDialog({
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyMarkdown = async () => {
|
||||
if (!detail) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(detail.content);
|
||||
toast.success("Markdown 已复制");
|
||||
} catch {
|
||||
toast.error("复制失败,请检查浏览器剪贴板权限");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="flex h-[85vh] max-h-[85vh] w-[calc(100vw-3rem)] max-w-5xl flex-col gap-0 overflow-hidden p-0 sm:max-w-5xl"
|
||||
className="flex h-[90dvh] max-h-[90dvh] w-[calc(100vw-2rem)] max-w-[96rem] flex-col gap-0 overflow-hidden p-0"
|
||||
>
|
||||
{/* header:列表视图=标题+筛选+新增;详情视图=结构标题,右上角「返回」 */}
|
||||
{!detail ? (
|
||||
@@ -208,8 +406,9 @@ export function WorkflowConfigDialog({
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => {
|
||||
setEditorTarget(null);
|
||||
setEditorOpen(true);
|
||||
onOpenChange(false);
|
||||
const base = location.pathname.replace(/\/+$/, "");
|
||||
navigate(`${base}/structures/new`);
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />新增结构
|
||||
@@ -218,18 +417,48 @@ export function WorkflowConfigDialog({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-5 py-4">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Badge variant="outline" className="shrink-0">报告结构</Badge>
|
||||
<h2 className="truncate text-base font-semibold">{detail.title}</h2>
|
||||
{detail.id === appliedStructureId && (
|
||||
<Badge className="shrink-0">当前使用</Badge>
|
||||
)}
|
||||
{!detail.enabled && <Badge variant="secondary" className="shrink-0">已停用</Badge>}
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-between gap-5 border-b border-border bg-card px-6 py-6 sm:px-9">
|
||||
<div className="flex min-w-0 items-center gap-4">
|
||||
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl bg-primary text-primary-foreground shadow-sm">
|
||||
<FileText className="h-7 w-7" strokeWidth={1.8} aria-hidden />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h2 className="truncate text-2xl font-semibold tracking-tight">{detail.title}</h2>
|
||||
{detail.id === appliedStructureId && (
|
||||
<Badge className="shrink-0 gap-2 rounded-full border border-primary/25 bg-primary/5 px-3 py-1 text-base font-semibold text-primary hover:bg-primary/5">
|
||||
<span className="h-2 w-2 rounded-full bg-primary" aria-hidden />
|
||||
当前使用
|
||||
</Badge>
|
||||
)}
|
||||
{!detail.enabled && <Badge variant="secondary" className="shrink-0 text-base">已停用</Badge>}
|
||||
</div>
|
||||
<p className="mt-1 text-base text-muted-foreground">报告结构详情与信息概览</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2">
|
||||
<Button variant="outline" className="h-11 text-base" onClick={() => setDetailId(null)}>
|
||||
<BookOpen className="mr-2 h-5 w-5" />
|
||||
结构库({structures.length})
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-11 text-base"
|
||||
onClick={() => {
|
||||
setEditorTarget(detail);
|
||||
setEditorOpen(true);
|
||||
}}
|
||||
>
|
||||
<PencilLine className="mr-2 h-5 w-5" />编辑 Markdown
|
||||
</Button>
|
||||
<Button variant="outline" className="h-11 text-base" onClick={() => void handleCopyMarkdown()}>
|
||||
<Copy className="mr-2 h-5 w-5" />复制 Markdown
|
||||
</Button>
|
||||
<div className="mx-1 hidden h-7 w-px bg-border sm:block" aria-hidden />
|
||||
<Button variant="ghost" size="icon" className="h-11 w-11" aria-label="关闭" onClick={() => onOpenChange(false)}>
|
||||
<X className="h-6 w-6 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="shrink-0" onClick={() => setDetailId(null)}>
|
||||
<ChevronLeft className="mr-1 h-4 w-4" />返回
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -277,16 +506,69 @@ export function WorkflowConfigDialog({
|
||||
</ScrollArea>
|
||||
) : (
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="mx-auto max-w-3xl p-5">
|
||||
{detail.name && (
|
||||
<p className="mb-1 text-xs text-muted-foreground">描述:{detail.name}</p>
|
||||
)}
|
||||
<p className="mb-4 text-xs text-muted-foreground">
|
||||
最后修改:{formatDateTime(detail.updatedAt)} · 创建人:{creatorLabel(detail)}
|
||||
</p>
|
||||
<article className="prose prose-sm dark:prose-invert max-w-none rounded-xl border border-border bg-card p-5">
|
||||
<ReactMarkdown components={{ img: () => null }}>{detail.content}</ReactMarkdown>
|
||||
</article>
|
||||
<div className="mx-auto grid max-w-6xl gap-5 px-4 py-5 sm:px-6 sm:py-6 lg:grid-cols-[17.5rem_minmax(0,1fr)] lg:items-start">
|
||||
<aside className="rounded-2xl border border-border bg-card p-3 shadow-sm lg:sticky lg:top-0">
|
||||
<div className="flex items-center gap-2 px-1 pb-3 pt-1">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-muted text-foreground">
|
||||
<ListTree className="h-4 w-4" strokeWidth={1.8} aria-hidden />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">模板信息</h3>
|
||||
<p className="text-base text-muted-foreground">结构与检索配置</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<DetailMetaItem
|
||||
icon={ListTree}
|
||||
label="结构约束"
|
||||
value={reportStructureModeLabel(detail.structureMode)}
|
||||
/>
|
||||
{detail.name ? (
|
||||
<DetailMetaItem icon={FileText} label="适用说明" value={detail.name} />
|
||||
) : null}
|
||||
{detail.retrievalDirections.length > 0 && (
|
||||
<DetailMetaItem
|
||||
icon={Compass}
|
||||
label="检索方向"
|
||||
value={<DetailTagList items={detail.retrievalDirections} />}
|
||||
/>
|
||||
)}
|
||||
{detail.retrievalSkills.length > 0 && (
|
||||
<DetailMetaItem
|
||||
icon={Sparkles}
|
||||
label="检索来源"
|
||||
value={<DetailTagList items={detail.retrievalSkills} />}
|
||||
/>
|
||||
)}
|
||||
<DetailMetaItem
|
||||
icon={Clock}
|
||||
label="最后修改"
|
||||
value={formatDateTime(detail.updatedAt)}
|
||||
/>
|
||||
<DetailMetaItem
|
||||
icon={User}
|
||||
label="创建人"
|
||||
value={creatorLabel(detail)}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className="min-w-0 overflow-hidden rounded-2xl border border-border bg-card shadow-sm">
|
||||
<div className="flex items-center gap-3 border-b border-border bg-muted/35 px-5 py-4 sm:px-6">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<ListTree className="h-4.5 w-4.5" strokeWidth={1.8} aria-hidden />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-lg font-semibold">报告大纲</h3>
|
||||
<p className="mt-0.5 text-base text-muted-foreground">以下结构将作为研究报告的撰写框架</p>
|
||||
</div>
|
||||
</div>
|
||||
<article className="mx-auto max-w-3xl px-5 py-6 sm:px-7 sm:py-7">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={structureMarkdownComponents}>
|
||||
{outlineToMarkdown(detail.content)}
|
||||
</ReactMarkdown>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
@@ -380,6 +662,9 @@ function StructureCard({
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
<span className="truncate text-sm font-semibold">{structure.title}</span>
|
||||
{applied && <Badge className="h-5 shrink-0 text-[10px]">当前使用</Badge>}
|
||||
<Badge variant="outline" className="h-5 shrink-0 text-[10px] text-muted-foreground">
|
||||
{reportStructureModeLabel(structure.structureMode)}
|
||||
</Badge>
|
||||
<Badge variant={structure.enabled ? "default" : "outline"} className={`h-5 shrink-0 text-[10px] ${structure.enabled ? "" : "text-muted-foreground"}`}>
|
||||
{structure.enabled ? "启用" : "停用"}
|
||||
</Badge>
|
||||
@@ -406,6 +691,16 @@ function StructureCard({
|
||||
</div>
|
||||
</div>
|
||||
{structure.name && <p className="mb-2 truncate text-xs text-muted-foreground">{structure.name}</p>}
|
||||
{structure.retrievalDirections.length > 0 && (
|
||||
<p className="mb-2 line-clamp-2 text-[11px] leading-4 text-muted-foreground">
|
||||
检索方向:{structure.retrievalDirections.join("、")}
|
||||
</p>
|
||||
)}
|
||||
{structure.retrievalSkills.length > 0 && (
|
||||
<p className="mb-2 line-clamp-2 text-[11px] leading-4 text-muted-foreground">
|
||||
检索来源:{structure.retrievalSkills.join("、")}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center justify-between gap-3 border-t border-border/60 pt-2.5">
|
||||
<div className="min-w-0 text-[11px] leading-4 text-muted-foreground">
|
||||
<p className="truncate">最后修改:{formatDateTime(structure.updatedAt)}</p>
|
||||
@@ -429,6 +724,74 @@ function StructureCard({
|
||||
);
|
||||
}
|
||||
|
||||
function skillChipLabel(name: string, nameZh?: string | null) {
|
||||
const zh = nameZh?.trim();
|
||||
return zh || desensitize(name);
|
||||
}
|
||||
|
||||
function RetrievalSkillPicker({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string[];
|
||||
onChange: (next: string[]) => void;
|
||||
}) {
|
||||
const { skills, isLoading } = useSkills();
|
||||
const selected = normalizeRetrievalSkills(value);
|
||||
const byName = useMemo(() => new Map(skills.map((skill) => [skill.name, skill])), [skills]);
|
||||
const remaining = useMemo(
|
||||
() => skills.filter((skill) => skill.enabled !== false && !selected.includes(skill.name)),
|
||||
[skills, selected],
|
||||
);
|
||||
const atCap = selected.length >= MAX_RETRIEVAL_SKILLS;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span className="mb-1.5 block text-sm font-medium">检索来源</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{selected.map((name) => (
|
||||
<span
|
||||
key={name}
|
||||
className="inline-flex max-w-full items-center gap-1 rounded-full border border-border bg-muted/50 px-2 py-0.5 text-xs"
|
||||
>
|
||||
<span className="truncate">{skillChipLabel(name, byName.get(name)?.name_zh)}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`移除检索技能 ${name}`}
|
||||
className="rounded-full p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
onClick={() => onChange(selected.filter((item) => item !== name))}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<Select
|
||||
key={selected.join("|")}
|
||||
onValueChange={(name) => {
|
||||
if (!name || atCap) return;
|
||||
onChange(normalizeRetrievalSkills([...selected, name]));
|
||||
}}
|
||||
disabled={atCap || remaining.length === 0}
|
||||
>
|
||||
<SelectTrigger aria-label="添加检索技能" className="mt-2">
|
||||
<SelectValue placeholder={isLoading ? "正在加载技能…" : atCap ? "已达上限" : "选择技能添加到检索来源"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{remaining.map((skill) => (
|
||||
<SelectItem key={skill.name} value={skill.name}>
|
||||
{skillChipLabel(skill.name, skill.name_zh)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="mt-1 block text-[11px] text-muted-foreground">
|
||||
选填。指定后,搜集素材会调用这些技能检索;不填则仍由收集员使用默认联网/知识库工具。
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── editor dialog(新增 / 编辑共用;状态不允许填写,创建默认启用) ─────────────
|
||||
|
||||
function StructureEditorDialog({
|
||||
@@ -441,23 +804,37 @@ function StructureEditorDialog({
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
target: ReportStructure | null;
|
||||
onSubmit: (values: { title: string; name: string; content: string }) => Promise<void>;
|
||||
onSubmit: (values: ReportStructureInput) => Promise<void>;
|
||||
submitting: boolean;
|
||||
}) {
|
||||
const [title, setTitle] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [content, setContent] = useState("");
|
||||
const [structureMode, setStructureMode] = useState<ReportStructureMode>("adaptive");
|
||||
const [retrievalDirections, setRetrievalDirections] = useState<string[]>([]);
|
||||
const [directionDraft, setDirectionDraft] = useState("");
|
||||
const [retrievalSkills, setRetrievalSkills] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setTitle(target?.title ?? "");
|
||||
setName(target?.name ?? "");
|
||||
setContent(target?.content ?? "");
|
||||
setStructureMode(normalizeReportStructureMode(target?.structureMode));
|
||||
setRetrievalDirections(normalizeRetrievalDirections(target?.retrievalDirections));
|
||||
setDirectionDraft("");
|
||||
setRetrievalSkills(normalizeRetrievalSkills(target?.retrievalSkills));
|
||||
}
|
||||
}, [open, target]);
|
||||
|
||||
const valid = title.trim().length > 0 && content.trim().length > 0;
|
||||
|
||||
const commitDirectionDraft = () => {
|
||||
const next = normalizeRetrievalDirections([...retrievalDirections, directionDraft]);
|
||||
setRetrievalDirections(next);
|
||||
setDirectionDraft("");
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[85vh] w-[calc(100vw-2rem)] max-w-2xl overflow-y-auto sm:max-w-2xl">
|
||||
@@ -487,7 +864,70 @@ function StructureEditorDialog({
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-sm font-medium">接口内容</span>
|
||||
<span className="mb-1.5 block text-sm font-medium">结构约束</span>
|
||||
<Select
|
||||
value={structureMode}
|
||||
onValueChange={(value) => setStructureMode(normalizeReportStructureMode(value))}
|
||||
>
|
||||
<SelectTrigger aria-label="结构约束">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{REPORT_STRUCTURE_MODE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="mt-1 block text-[11px] text-muted-foreground">
|
||||
{REPORT_STRUCTURE_MODE_OPTIONS.find((option) => option.value === structureMode)?.description}
|
||||
</span>
|
||||
</label>
|
||||
<div>
|
||||
<span className="mb-1.5 block text-sm font-medium">检索方向</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{retrievalDirections.map((item) => (
|
||||
<span
|
||||
key={item}
|
||||
className="inline-flex max-w-full items-center gap-1 rounded-full border border-border bg-muted/50 px-2 py-0.5 text-xs"
|
||||
>
|
||||
<span className="truncate">{item}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`移除检索方向 ${item}`}
|
||||
className="rounded-full p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
onClick={() => setRetrievalDirections(retrievalDirections.filter((value) => value !== item))}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<Input
|
||||
value={directionDraft}
|
||||
onChange={(event) => setDirectionDraft(event.target.value)}
|
||||
maxLength={MAX_RETRIEVAL_DIRECTION_CHARS}
|
||||
placeholder="输入后按回车添加,可写多个;用作拆词方向,不是直接搜索的词"
|
||||
className="mt-2"
|
||||
disabled={retrievalDirections.length >= MAX_RETRIEVAL_DIRECTIONS}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === ",") {
|
||||
event.preventDefault();
|
||||
commitDirectionDraft();
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (directionDraft.trim()) commitDirectionDraft();
|
||||
}}
|
||||
/>
|
||||
<span className="mt-1 block text-[11px] text-muted-foreground">
|
||||
选填。指定后,收集员会按这些方向拆分具体检索词,而不是把方向名称直接拿去搜;不填则仍由收集员自行拆词。
|
||||
</span>
|
||||
</div>
|
||||
<RetrievalSkillPicker value={retrievalSkills} onChange={setRetrievalSkills} />
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-sm font-medium">结构内容</span>
|
||||
<Textarea
|
||||
value={content}
|
||||
onChange={(event) => setContent(event.target.value)}
|
||||
@@ -504,7 +944,7 @@ function StructureEditorDialog({
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
|
||||
取消
|
||||
</Button>
|
||||
<Button disabled={!valid || submitting} onClick={() => void onSubmit({ title: title.trim(), name: name.trim(), content: content })}>
|
||||
<Button disabled={!valid || submitting} onClick={() => void onSubmit({ title: title.trim(), name: name.trim(), content: content, structureMode, retrievalDirections, retrievalSkills })}>
|
||||
{submitting && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
|
||||
保存
|
||||
</Button>
|
||||
@@ -525,14 +965,23 @@ export function WorkflowConfigButton({
|
||||
disabled = false,
|
||||
}: {
|
||||
appliedContent?: string;
|
||||
onApply: (content: string) => void;
|
||||
onApply: (content: string, structureMode: ReportStructureMode, retrievalDirections: string[], retrievalSkills: string[]) => void;
|
||||
className?: string;
|
||||
variant?: React.ComponentProps<typeof Button>["variant"];
|
||||
size?: React.ComponentProps<typeof Button>["size"];
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const structuresQuery = useReportStructures();
|
||||
|
||||
useEffect(() => {
|
||||
const state = location.state as { openWorkflowConfig?: boolean } | null;
|
||||
if (!state?.openWorkflowConfig) return;
|
||||
setOpen(true);
|
||||
navigate(location.pathname, { replace: true, state: {} });
|
||||
}, [location.pathname, location.state, navigate]);
|
||||
const selectedWorkflowTitle = useMemo(() => {
|
||||
const content = appliedContent?.trim();
|
||||
if (!content) return null;
|
||||
|
||||
Reference in new issue
Block a user