This commit is contained in:
fengxiangdebaozi committed 2026-08-24 09:10:20 +08:00
commit b3aa9f4bac
91 files changed
+6033 -986

No files matched your search

+5
View File
@@ -58,6 +58,11 @@ window.__CM_RUNTIME_CONFIG__ = {
// 部署后直接改 dist/runtime-config.js 即可,无需重新打包。留空则点击时提示未配置。
VITE_8BF_ROUNDTABLE_BACK_URL: "",
// 任务深链「宿主路由」按钮的 postMessage targetOrigin(HOST_NAVIGATE)。
// 填宿主系统 origin,例如 https://localhost:4912;生产改成他们线上地址。
// 留空时尝试用 iframe 的 ancestorOrigins / referrer 自动探测,探测失败则不发送。
VITE_HOST_NAVIGATE_ORIGIN: "",
// 佳来模型对话接口。
VITE_AIPI_CHAT_URL: "https://ch1.b.uat.4.cn/gpt",
@@ -72,6 +72,7 @@ import type { AgentThread, AgentThreadState } from "@/core/threads/types";
import {
bootstrapResumePath,
isBootstrapThread,
isHiddenFromRecentChats,
isThreadFavorite,
isThreadPinned,
sortThreadsPinnedFirst,
@@ -372,17 +373,10 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
const [renameValue, setRenameValue] = useState("");
const [shareThreadId, setShareThreadId] = useState<string | null>(null);
// 排除调度/会商类系统会话,与首页一致;智能体创建(bootstrap)会话保留展示
// (带机器人图标、点击回创建页);置顶优先。
// 排除调度/会商/报告结构&写作配置向导等系统会话,与首页一致;
// 智能体创建(bootstrap)会话保留展示(带机器人图标、点击回创建页);置顶优先。
const visibleThreads = useMemo(
() =>
sortThreadsPinnedFirst(
threads.filter(
(thread) =>
thread.metadata?.thread_type !== "scheduler" &&
thread.metadata?.thread_type !== "roundtable",
),
),
() => sortThreadsPinnedFirst(threads.filter((thread) => !isHiddenFromRecentChats(thread))),
[threads],
);
@@ -5,7 +5,7 @@ import Link from "next/link";
import { usePathname } from "next/navigation";
import { useThreadsPage } from "@/core/threads/hooks";
import { pathOfThread, titleOfThread } from "@/core/threads/utils";
import { pathOfThread, titleOfThread, isHiddenFromRecentChats, isBootstrapThread } from "@/core/threads/utils";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
@@ -22,12 +22,9 @@ export function AgentRecentChats({ agentId }: { agentId: string }) {
pageSize: RECENT_LIMIT,
metadata: { agent_id: agentId },
});
// 该智能体的会话里同样排除调度 / 引导 / 会商类系统会话。
// 该智能体的会话里同样排除引导 / 系统 / 配置向导会话。
const threads = (data?.threads ?? []).filter(
(th) =>
th.metadata?.thread_type !== "bootstrap" &&
th.metadata?.thread_type !== "scheduler" &&
th.metadata?.thread_type !== "roundtable",
(th) => !isBootstrapThread(th) && !isHiddenFromRecentChats(th),
);
return (
@@ -43,11 +43,11 @@ import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { CodeEditor } from "@/components/workspace/code-editor";
import { MarkdownAiEditor } from "@/open-canvas/components/artifact/MarkdownAiEditor";
import { useDocumentRewrite } from "@/open-canvas/hooks/useDocumentRewrite";
import {
useCapabilities as useResearchCapabilities,
useResearchSources,
type DeepResearchSource,
} from "@/strategy-components/api/deep-research";
import {
useCapabilities as useResearchCapabilities,
useResearchSources,
type DeepResearchSource,
} from "@/strategy-components/api/deep-research";
import {
listDurableDocumentRewrites,
streamDurableDocumentRewriteJob,
@@ -58,17 +58,17 @@ import { publishArtifactToKnowledge } from "@/core/agents/api";
import { useAgent } from "@/core/agents/hooks";
import { apiFetch } from "@/core/api";
import { copyTextToClipboard } from "@/core/clipboard";
import {
DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT,
DOCUMENT_REWRITE_STARTED_EVENT,
DOCUMENT_REWRITE_UNDONE_EVENT,
useDocumentRewritePresentation,
} from "@/core/document-rewrite/context";
import {
clearArtifactRewriteFallback,
completeArtifactRewriteFallback,
primeArtifactRewriteFallback,
} from "@/core/document-rewrite/local-fallback";
import {
DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT,
DOCUMENT_REWRITE_STARTED_EVENT,
DOCUMENT_REWRITE_UNDONE_EVENT,
useDocumentRewritePresentation,
} from "@/core/document-rewrite/context";
import {
clearArtifactRewriteFallback,
completeArtifactRewriteFallback,
primeArtifactRewriteFallback,
} from "@/core/document-rewrite/local-fallback";
import {
downloadArtifactFile,
} from "@/core/artifacts/download";
@@ -111,34 +111,55 @@ import { useArtifacts } from "./context";
import { SpreadsheetPreview } from "./spreadsheet-preview";
import { SkillInstallDialog } from "./skill-install-dialog";
const SANDBOX_RESUME_EVENT = "workspace:sandbox-resume-autoscroll";
const RESEARCH_SOURCE_MARKER_RE = /(?:\[\[\s*source\s*[::]\s*([A-Za-z0-9_-]{1,128})\s*\]\]|\[\s*来源\s*[::]\s*([A-Za-z0-9_-]{1,128})\s*\])/gi;
const TRAILING_RESEARCH_SOURCE_MARKER_RE = /(?:\[\[\s*source\s*[::][^\]\n]*(?:\]?)|\[\s*来源\s*[::][^\]\n]*)$/i;
/** Keep durable source ids in Markdown while presenting plain numeric citations. */
function formatResearchCitationsForPreview(
content: string,
sources: DeepResearchSource[],
): string {
const citationNumbers = new Map(
sources.map((source, index) => [source.id, index + 1]),
);
let nextCitationNumber = citationNumbers.size + 1;
return content
.replace(RESEARCH_SOURCE_MARKER_RE, (_marker, legacyId: string, displayId: string) => {
const sourceId = legacyId || displayId;
let citationNumber = citationNumbers.get(sourceId);
if (!citationNumber) {
citationNumber = nextCitationNumber;
nextCitationNumber += 1;
citationNumbers.set(sourceId, citationNumber);
}
return `[${citationNumber}]`;
})
// Hide a citation token while it is still arriving over the live stream.
.replace(TRAILING_RESEARCH_SOURCE_MARKER_RE, "");
}
const SANDBOX_RESUME_EVENT = "workspace:sandbox-resume-autoscroll";
const RESEARCH_SOURCE_MARKER_RE = /(?:\[\[\s*source\s*[::]\s*([A-Za-z0-9_-]{1,128})\s*\]\]|\[\s*来源\s*[::]\s*([A-Za-z0-9_-]{1,128})\s*\])/gi;
const TRAILING_RESEARCH_SOURCE_MARKER_RE = /(?:\[\[\s*source\s*[::][^\]\n]*(?:\]?)|\[\s*来源\s*[::][^\]\n]*)$/i;
/** Keep durable source ids in Markdown while presenting plain numeric citations. */
function formatResearchCitationsForPreview(
content: string,
sources: DeepResearchSource[],
): string {
const citationNumbers = new Map(
sources.map((source, index) => [source.id, index + 1]),
);
let nextCitationNumber = citationNumbers.size + 1;
return preserveMarkdownStandaloneLines(
content
.replace(RESEARCH_SOURCE_MARKER_RE, (_marker, legacyId: string, displayId: string) => {
const sourceId = legacyId || displayId;
let citationNumber = citationNumbers.get(sourceId);
if (!citationNumber) {
citationNumber = nextCitationNumber;
nextCitationNumber += 1;
citationNumbers.set(sourceId, citationNumber);
}
return `[${citationNumber}]`;
})
// Hide a citation token while it is still arriving over the live stream.
.replace(TRAILING_RESEARCH_SOURCE_MARKER_RE, ""),
);
}
/**
* Markdown treats a single newline as a space. Cover fields and chapter
* headings like 「日期:」「第一章」「1.1」must be their own blocks in preview.
*/
function preserveMarkdownStandaloneLines(markdown: string): string {
const standalone =
/^(?:《.+》|(?:日期|类型|密级)\s*[::].+|第[一二三四五六七八九十百千0-9]+章\b.*|\d+\.\d+(?:\.\d+)?\s+\S+)$/;
const lines = markdown.split("\n");
const output: string[] = [];
for (const line of lines) {
const stripped = line.trim();
if (stripped && standalone.test(stripped) && output.length > 0 && output[output.length - 1]!.trim()) {
output.push("");
}
output.push(line);
}
return output.join("\n");
}
async function sha256Content(content: string): Promise<string | undefined> {
if (!globalThis.crypto?.subtle) return undefined;
@@ -324,13 +345,13 @@ export function ArtifactFileDetail({
language === "mermaid"
);
}, [language]);
const {
content,
isApplyingEdit,
isLoading: isArtifactContentLoading,
error: artifactContentError,
refetch: refetchArtifactContent,
} = useArtifactContent({
const {
content,
isApplyingEdit,
isLoading: isArtifactContentLoading,
error: artifactContentError,
refetch: refetchArtifactContent,
} = useArtifactContent({
threadId,
// editVirtualMarkdown:用解码后的真实路径走 artifacts API(而非 write-file 虚拟
// URL),这样保存后 useUpdateArtifactContent 写入的查询缓存才能命中,编辑内容
@@ -370,19 +391,19 @@ export function ArtifactFileDetail({
const documentRewriteActive = documentRewrite.isActive;
const imageGenerationAvailable = imageCapabilities.data?.imageGeneration?.available === true;
const maxGeneratedImages = imageCapabilities.data?.imageGeneration?.maxImages ?? 4;
const displayContent = documentRewriteActive
? documentRewrite.run.draft
: (canEditArtifact ? draftContent : (content ?? ""));
const artifactContentPending =
isCodeFile &&
!useWriteFileStreaming &&
content === undefined &&
isArtifactContentLoading;
const artifactContentUnavailable =
isCodeFile &&
!useWriteFileStreaming &&
content === undefined &&
!isArtifactContentLoading;
const displayContent = documentRewriteActive
? documentRewrite.run.draft
: (canEditArtifact ? draftContent : (content ?? ""));
const artifactContentPending =
isCodeFile &&
!useWriteFileStreaming &&
content === undefined &&
isArtifactContentLoading;
const artifactContentUnavailable =
isCodeFile &&
!useWriteFileStreaming &&
content === undefined &&
!isArtifactContentLoading;
const isAutoPreviewLanguage =
language === "html" || language === "markdown" || language === "mermaid";
const isPreviewStreaming =
@@ -438,12 +459,12 @@ export function ArtifactFileDetail({
if (
!canEditArtifact ||
!threadId ||
!filepath ||
isMock ||
useWriteFileStreaming ||
documentRewriteActive ||
content === undefined ||
artifactContentError
!filepath ||
isMock ||
useWriteFileStreaming ||
documentRewriteActive ||
content === undefined ||
artifactContentError
) {
return;
}
@@ -523,13 +544,13 @@ export function ArtifactFileDetail({
case "commit_started":
rewritePresentation.update(presentationId, { stage: "committing" });
break;
case "committed":
rewritePresentation.update(presentationId, {
rewrittenContent: event.content,
versionId: event.versionId ?? null,
committedHash: event.committedHash,
});
break;
case "committed":
rewritePresentation.update(presentationId, {
rewrittenContent: event.content,
versionId: event.versionId ?? null,
committedHash: event.committedHash,
});
break;
case "done":
rewritePresentation.update(presentationId, {
stage: "completed",
@@ -542,9 +563,9 @@ export function ArtifactFileDetail({
case "cancelled":
rewritePresentation.update(presentationId, { stage: "cancelled", error: event.message });
break;
case "error":
console.error("AI 全文改写历史恢复失败:", event.message);
break;
case "error":
console.error("AI 全文改写历史恢复失败:", event.message);
break;
default:
break;
}
@@ -557,15 +578,15 @@ export function ArtifactFileDetail({
if (disposed || !latest) return;
// Restore prior terminal rewrites into the ordinary message timeline in
// chronological order. The newest job is handled below because it may
// still be active and needs its SSE stream resumed.
for (const historical of jobs.slice(1).reverse()) {
const historicalResult = historical.result;
const historicalCommittedHash = typeof historicalResult.committedHash === "string"
? historicalResult.committedHash
: null;
const historicalSummary = historicalResult.summary && typeof historicalResult.summary === "object"
? historicalResult.summary as DocumentRewriteSummary
: null;
// still be active and needs its SSE stream resumed.
for (const historical of jobs.slice(1).reverse()) {
const historicalResult = historical.result;
const historicalCommittedHash = typeof historicalResult.committedHash === "string"
? historicalResult.committedHash
: null;
const historicalSummary = historicalResult.summary && typeof historicalResult.summary === "object"
? historicalResult.summary as DocumentRewriteSummary
: null;
const historicalPresentationId = rewritePresentation?.create({
threadId,
source: { kind: "artifact", id: threadId, path: filepath },
@@ -576,9 +597,9 @@ export function ArtifactFileDetail({
generateImages: historical.request.generateImages,
maxGeneratedImages: historical.request.maxGeneratedImages,
originalContent: historical.request.originalContent,
rewrittenContent: typeof historicalResult.content === "string" ? historicalResult.content : null,
committedHash: historicalCommittedHash,
requirementsPlan: typeof historicalResult.requirementsPlan === "string"
rewrittenContent: typeof historicalResult.content === "string" ? historicalResult.content : null,
committedHash: historicalCommittedHash,
requirementsPlan: typeof historicalResult.requirementsPlan === "string"
? historicalResult.requirementsPlan
: "",
});
@@ -589,7 +610,7 @@ export function ArtifactFileDetail({
: historical.status === "cancelled" ? "cancelled" : "error",
summary: historicalSummary,
warnings: historicalSummary?.validationWarnings ?? [],
versionId: typeof historicalResult.versionId === "string" ? historicalResult.versionId : null,
versionId: typeof historicalResult.versionId === "string" ? historicalResult.versionId : null,
error: historicalResult.status === "conflict"
? "文件在改写期间已变化,未覆盖原文件。"
: historical.status === "cancelled"
@@ -598,28 +619,28 @@ export function ArtifactFileDetail({
});
}
const result = latest.result;
const summary = result.summary && typeof result.summary === "object"
? result.summary as DocumentRewriteSummary
: null;
const originalContent = latest.request.originalContent || draftContent;
const rewrittenContent = typeof result.content === "string" ? result.content : null;
const versionSnapshotFailed = result.versionSnapshotFailed === true;
const committedHash = typeof result.committedHash === "string" ? result.committedHash : null;
if (!versionSnapshotFailed) {
clearArtifactRewriteFallback(threadId, filepath);
}
const localFallbackAvailable =
versionSnapshotFailed &&
latest.status === "completed" &&
result.status !== "conflict" &&
committedHash !== null &&
completeArtifactRewriteFallback({
threadId,
path: filepath,
originalContent,
committedHash,
});
const presentationId = rewritePresentation?.create({
const summary = result.summary && typeof result.summary === "object"
? result.summary as DocumentRewriteSummary
: null;
const originalContent = latest.request.originalContent || draftContent;
const rewrittenContent = typeof result.content === "string" ? result.content : null;
const versionSnapshotFailed = result.versionSnapshotFailed === true;
const committedHash = typeof result.committedHash === "string" ? result.committedHash : null;
if (!versionSnapshotFailed) {
clearArtifactRewriteFallback(threadId, filepath);
}
const localFallbackAvailable =
versionSnapshotFailed &&
latest.status === "completed" &&
result.status !== "conflict" &&
committedHash !== null &&
completeArtifactRewriteFallback({
threadId,
path: filepath,
originalContent,
committedHash,
});
const presentationId = rewritePresentation?.create({
threadId,
source: { kind: "artifact", id: threadId, path: filepath },
fileName: getFileName(filepath),
@@ -628,10 +649,10 @@ export function ArtifactFileDetail({
modelName: latest.request.modelName,
generateImages: latest.request.generateImages,
maxGeneratedImages: latest.request.maxGeneratedImages,
originalContent,
rewrittenContent,
committedHash,
localFallbackAvailable,
originalContent,
rewrittenContent,
committedHash,
localFallbackAvailable,
requirementsPlan: typeof result.requirementsPlan === "string" ? result.requirementsPlan : "",
});
if (!presentationId || !rewritePresentation) return;
@@ -642,19 +663,19 @@ export function ArtifactFileDetail({
stage: result.status === "conflict" ? "conflict" : "completed",
summary,
warnings: summary?.validationWarnings ?? [],
versionId: typeof result.versionId === "string" ? result.versionId : null,
committedHash,
localFallbackAvailable,
versionId: typeof result.versionId === "string" ? result.versionId : null,
committedHash,
localFallbackAvailable,
error: result.status === "conflict" ? "文件在改写期间已变化,未覆盖原文件。" : null,
});
if (rewrittenContent && result.status !== "conflict") {
setDraftContent(rewrittenContent);
setHasLocalEdits(versionSnapshotFailed);
if (versionSnapshotFailed) {
console.error(
"AI 全文改写已写入文件,但撤销版本快照保存失败;已启用手动保存。",
);
}
if (rewrittenContent && result.status !== "conflict") {
setDraftContent(rewrittenContent);
setHasLocalEdits(versionSnapshotFailed);
if (versionSnapshotFailed) {
console.error(
"AI 全文改写已写入文件,但撤销版本快照保存失败;已启用手动保存。",
);
}
queryClient.setQueriesData(
{ queryKey: ["artifact", filepath, threadId, isMock] },
{ content: rewrittenContent, url: undefined },
@@ -688,28 +709,28 @@ export function ArtifactFileDetail({
signal,
),
onEvent: (event) => updatePresentation(presentationId, event),
onCommitted: (rewritten, { versionSnapshotFailed, committedHash }) => {
setDraftContent(rewritten);
setHasLocalEdits(versionSnapshotFailed);
const localFallbackAvailable = versionSnapshotFailed && completeArtifactRewriteFallback({
threadId,
path: filepath,
originalContent,
committedHash,
});
if (!versionSnapshotFailed) {
clearArtifactRewriteFallback(threadId, filepath);
}
if (versionSnapshotFailed) {
console.error(
"AI 全文改写已写入文件,但撤销版本快照保存失败;已启用手动保存。",
);
}
rewritePresentation.update(presentationId, {
rewrittenContent: rewritten,
committedHash,
localFallbackAvailable,
});
onCommitted: (rewritten, { versionSnapshotFailed, committedHash }) => {
setDraftContent(rewritten);
setHasLocalEdits(versionSnapshotFailed);
const localFallbackAvailable = versionSnapshotFailed && completeArtifactRewriteFallback({
threadId,
path: filepath,
originalContent,
committedHash,
});
if (!versionSnapshotFailed) {
clearArtifactRewriteFallback(threadId, filepath);
}
if (versionSnapshotFailed) {
console.error(
"AI 全文改写已写入文件,但撤销版本快照保存失败;已启用手动保存。",
);
}
rewritePresentation.update(presentationId, {
rewrittenContent: rewritten,
committedHash,
localFallbackAvailable,
});
queryClient.setQueriesData(
{ queryKey: ["artifact", filepath, threadId, isMock] },
{ content: rewritten, url: undefined },
@@ -724,11 +745,11 @@ export function ArtifactFileDetail({
return () => {
disposed = true;
};
}, [
artifactContentError,
canEditArtifact,
content,
documentRewrite,
}, [
artifactContentError,
canEditArtifact,
content,
documentRewrite,
documentRewriteActive,
draftContent,
filepath,
@@ -780,29 +801,29 @@ export function ArtifactFileDetail({
rewritePresentation.update(presentationId, { thinkingText: thinking });
}, [documentRewrite.run.thinkingText, documentRewritePresentationId, rewritePresentation]);
useEffect(() => {
const handleLocalRewriteUndoGuard = (event: Event) => {
const detail = (event as CustomEvent<{
source?: { kind?: string; id?: string; path?: string };
rewrittenContent?: string | null;
unchanged?: boolean;
}>).detail;
if (
detail?.source?.kind !== "artifact" ||
detail.source.id !== threadId ||
detail.source.path !== filepath ||
typeof detail.rewrittenContent !== "string"
) {
return;
}
detail.unchanged = draftContent === detail.rewrittenContent;
};
window.addEventListener(DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT, handleLocalRewriteUndoGuard);
return () => window.removeEventListener(DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT, handleLocalRewriteUndoGuard);
}, [draftContent, filepath, threadId]);
useEffect(() => {
const handleRewriteUndo = (event: Event) => {
useEffect(() => {
const handleLocalRewriteUndoGuard = (event: Event) => {
const detail = (event as CustomEvent<{
source?: { kind?: string; id?: string; path?: string };
rewrittenContent?: string | null;
unchanged?: boolean;
}>).detail;
if (
detail?.source?.kind !== "artifact" ||
detail.source.id !== threadId ||
detail.source.path !== filepath ||
typeof detail.rewrittenContent !== "string"
) {
return;
}
detail.unchanged = draftContent === detail.rewrittenContent;
};
window.addEventListener(DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT, handleLocalRewriteUndoGuard);
return () => window.removeEventListener(DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT, handleLocalRewriteUndoGuard);
}, [draftContent, filepath, threadId]);
useEffect(() => {
const handleRewriteUndo = (event: Event) => {
const detail = (event as CustomEvent<{
source?: { kind?: string; id?: string; path?: string };
content?: string;
@@ -1028,11 +1049,11 @@ export function ArtifactFileDetail({
});
setHasLocalEdits(false);
toast.success("Markdown 已保存");
} catch (error) {
console.error("Failed to save artifact:", error);
// Keep the draft and leave the button enabled. The caller can retry when
// the artifact endpoint recovers; this must never reset visible text.
setHasLocalEdits(true);
} catch (error) {
console.error("Failed to save artifact:", error);
// Keep the draft and leave the button enabled. The caller can retry when
// the artifact endpoint recovers; this must never reset visible text.
setHasLocalEdits(true);
}
}, [
canEditArtifact,
@@ -1046,47 +1067,48 @@ export function ArtifactFileDetail({
const handleStartDocumentRewrite = useCallback(
async (input: {
instruction: string;
style: "formal_analysis" | "professional" | "concise" | "readable";
modelName: string | null;
reportOutline: string;
generateImages: boolean;
instruction: string;
style: "formal_analysis" | "professional" | "concise" | "readable";
modelName: string | null;
reportOutline: string;
structureMode?: "fixed" | "adaptive";
generateImages: boolean;
maxGeneratedImages: number;
}) => {
if (!canEditArtifact) {
toast.error("当前文件暂不支持全文改写。");
return;
}
setDocumentRewriteDialogOpen(false);
setAllowAutoViewSwitch(false);
setAutoScrollEnabled(true);
setViewMode("code");
// Do not make users manually save before using AI rewrite. Persist their
// current draft first so the rewrite starts from exactly what they see,
// and the optimistic hash remains valid on the server.
if (hasLocalEdits) {
try {
await updateArtifact.mutateAsync({
filepath,
threadId,
isMock,
content: draftContent,
});
setHasLocalEdits(false);
} catch (error) {
console.error("Failed to save Markdown before AI rewrite:", error);
setHasLocalEdits(true);
return;
}
}
primeArtifactRewriteFallback({
threadId,
path: filepath,
originalContent: draftContent,
});
const expectedHash = await sha256Content(draftContent);
if (!canEditArtifact) {
toast.error("当前文件暂不支持全文改写。");
return;
}
setDocumentRewriteDialogOpen(false);
setAllowAutoViewSwitch(false);
setAutoScrollEnabled(true);
setViewMode("code");
// Do not make users manually save before using AI rewrite. Persist their
// current draft first so the rewrite starts from exactly what they see,
// and the optimistic hash remains valid on the server.
if (hasLocalEdits) {
try {
await updateArtifact.mutateAsync({
filepath,
threadId,
isMock,
content: draftContent,
});
setHasLocalEdits(false);
} catch (error) {
console.error("Failed to save Markdown before AI rewrite:", error);
setHasLocalEdits(true);
return;
}
}
primeArtifactRewriteFallback({
threadId,
path: filepath,
originalContent: draftContent,
});
const expectedHash = await sha256Content(draftContent);
const presentationId = rewritePresentation?.create({
threadId,
source: { kind: "artifact", id: threadId, path: filepath },
@@ -1097,22 +1119,23 @@ export function ArtifactFileDetail({
generateImages: input.generateImages,
maxGeneratedImages: input.maxGeneratedImages,
originalContent: draftContent,
});
setDocumentRewritePresentationId(presentationId ?? null);
window.dispatchEvent(
new CustomEvent(DOCUMENT_REWRITE_STARTED_EVENT, {
detail: { threadId },
}),
);
void documentRewrite.start(
});
setDocumentRewritePresentationId(presentationId ?? null);
window.dispatchEvent(
new CustomEvent(DOCUMENT_REWRITE_STARTED_EVENT, {
detail: { threadId },
}),
);
void documentRewrite.start(
{
threadId,
path: filepath,
instruction: input.instruction,
style: input.style,
modelName: input.modelName ?? undefined,
reportOutline: input.reportOutline || undefined,
generateImages: input.generateImages,
instruction: input.instruction,
style: input.style,
modelName: input.modelName ?? undefined,
reportOutline: input.reportOutline || undefined,
structureMode: input.structureMode,
generateImages: input.generateImages,
maxGeneratedImages: input.maxGeneratedImages,
expectedHash,
},
@@ -1188,11 +1211,11 @@ export function ArtifactFileDetail({
case "commit_started":
rewritePresentation.update(presentationId, { stage: "committing" });
break;
case "committed":
rewritePresentation.update(presentationId, {
versionId: event.versionId ?? null,
committedHash: event.committedHash,
});
case "committed":
rewritePresentation.update(presentationId, {
versionId: event.versionId ?? null,
committedHash: event.committedHash,
});
break;
case "done":
rewritePresentation.update(presentationId, {
@@ -1206,33 +1229,33 @@ export function ArtifactFileDetail({
error: event.message,
});
break;
case "error":
// The sandbox retains the visible draft and exposes manual
// save instead of showing an error state to the user.
console.error("AI 全文改写接口失败:", event.message);
break;
case "error":
// The sandbox retains the visible draft and exposes manual
// save instead of showing an error state to the user.
console.error("AI 全文改写接口失败:", event.message);
break;
default:
break;
}
},
onCommitted: (rewritten, { versionSnapshotFailed, committedHash }) => {
setDraftContent(rewritten);
setHasLocalEdits(versionSnapshotFailed);
const localFallbackAvailable = versionSnapshotFailed && completeArtifactRewriteFallback({
threadId,
path: filepath,
originalContent: draftContent,
committedHash,
});
if (!versionSnapshotFailed) {
clearArtifactRewriteFallback(threadId, filepath);
}
if (presentationId && rewritePresentation) {
rewritePresentation.update(presentationId, {
rewrittenContent: rewritten,
committedHash,
localFallbackAvailable,
});
onCommitted: (rewritten, { versionSnapshotFailed, committedHash }) => {
setDraftContent(rewritten);
setHasLocalEdits(versionSnapshotFailed);
const localFallbackAvailable = versionSnapshotFailed && completeArtifactRewriteFallback({
threadId,
path: filepath,
originalContent: draftContent,
committedHash,
});
if (!versionSnapshotFailed) {
clearArtifactRewriteFallback(threadId, filepath);
}
if (presentationId && rewritePresentation) {
rewritePresentation.update(presentationId, {
rewrittenContent: rewritten,
committedHash,
localFallbackAvailable,
});
}
queryClient.setQueriesData(
{
@@ -1240,51 +1263,51 @@ export function ArtifactFileDetail({
},
{ content: rewritten, url: undefined },
);
if (versionSnapshotFailed) {
console.error(
"AI 全文改写已写入文件,但撤销版本快照保存失败;已启用手动保存。",
);
} else {
toast.success("Markdown 全文改写完成,已安全替换原文件。");
}
},
onFailure: ({ content, error }) => {
// The rewrite transport can fail after the server has already
// streamed text (or before the first token on a 404). Keep that
// visible content in the sandbox, make Save actionable, and leave
// diagnostics to the developer console only.
const retainedContent = content || draftContent;
setDraftContent(retainedContent);
setHasLocalEdits(true);
queryClient.setQueriesData(
{
queryKey: ["artifact", filepath, threadId, isMock],
},
{ content: retainedContent, url: undefined },
);
if (presentationId && rewritePresentation) {
rewritePresentation.update(presentationId, {
stage: "completed",
rewrittenContent: retainedContent,
error: null,
});
}
console.error("AI 全文改写接口失败,已保留沙箱内容供手动保存:", error);
},
},
);
if (versionSnapshotFailed) {
console.error(
"AI 全文改写已写入文件,但撤销版本快照保存失败;已启用手动保存。",
);
} else {
toast.success("Markdown 全文改写完成,已安全替换原文件。");
}
},
onFailure: ({ content, error }) => {
// The rewrite transport can fail after the server has already
// streamed text (or before the first token on a 404). Keep that
// visible content in the sandbox, make Save actionable, and leave
// diagnostics to the developer console only.
const retainedContent = content || draftContent;
setDraftContent(retainedContent);
setHasLocalEdits(true);
queryClient.setQueriesData(
{
queryKey: ["artifact", filepath, threadId, isMock],
},
{ content: retainedContent, url: undefined },
);
if (presentationId && rewritePresentation) {
rewritePresentation.update(presentationId, {
stage: "completed",
rewrittenContent: retainedContent,
error: null,
});
}
console.error("AI 全文改写接口失败,已保留沙箱内容供手动保存:", error);
},
},
);
},
[
canEditArtifact,
draftContent,
documentRewrite,
filepath,
hasLocalEdits,
isMock,
queryClient,
rewritePresentation,
threadId,
updateArtifact,
canEditArtifact,
draftContent,
documentRewrite,
filepath,
hasLocalEdits,
isMock,
queryClient,
rewritePresentation,
threadId,
updateArtifact,
],
);
@@ -1530,15 +1553,15 @@ export function ArtifactFileDetail({
label={documentRewriteActive ? "正在全文改写" : "AI 全文改写"}
tooltip={
documentRewriteActive
? "正在生成临时草稿,原文件尚未被覆盖"
: hasLocalEdits
? "将先保存当前 Markdown 修改,再开始改写"
: "使用 AI 重写当前完整 Markdown"
? "正在生成临时草稿,原文件尚未被覆盖"
: hasLocalEdits
? "将先保存当前 Markdown 修改,再开始改写"
: "使用 AI 重写当前完整 Markdown"
}
// Keep the entry available after a completed rewrite and when
// the user has local edits. Starting with local edits saves
// them automatically in handleStartDocumentRewrite.
onClick={() => setDocumentRewriteDialogOpen(true)}
// Keep the entry available after a completed rewrite and when
// the user has local edits. Starting with local edits saves
// them automatically in handleStartDocumentRewrite.
onClick={() => setDocumentRewriteDialogOpen(true)}
/>
)}
{!useWriteFileStreaming && filepath.endsWith(".skill") && (
@@ -1710,28 +1733,28 @@ export function ArtifactFileDetail({
activeCursor={isApplyingEdit}
/>
)}
{artifactContentPending && viewMode === "code" && (
<div className="text-muted-foreground flex size-full flex-col items-center justify-center gap-3 text-sm">
<LoaderIcon aria-hidden="true" className="size-5 animate-spin" />
正在加载文档内容…
</div>
)}
{artifactContentUnavailable && viewMode === "code" && (
<div className="text-muted-foreground flex size-full flex-col items-center justify-center gap-3 px-6 text-center text-sm">
<FileType2Icon aria-hidden="true" className="size-6" />
<p>{artifactContentError ? "暂时无法读取文档内容。" : "当前文档没有可显示的内容。"}</p>
<button
className="text-primary rounded-md px-2 py-1 font-medium underline-offset-4 hover:underline focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none"
onClick={() => {
void refetchArtifactContent();
}}
type="button"
>
重新加载
</button>
</div>
)}
{isCodeFile && viewMode === "code" && !artifactContentPending && !artifactContentUnavailable && (
{artifactContentPending && viewMode === "code" && (
<div className="text-muted-foreground flex size-full flex-col items-center justify-center gap-3 text-sm">
<LoaderIcon aria-hidden="true" className="size-5 animate-spin" />
正在加载文档内容…
</div>
)}
{artifactContentUnavailable && viewMode === "code" && (
<div className="text-muted-foreground flex size-full flex-col items-center justify-center gap-3 px-6 text-center text-sm">
<FileType2Icon aria-hidden="true" className="size-6" />
<p>{artifactContentError ? "暂时无法读取文档内容。" : "当前文档没有可显示的内容。"}</p>
<button
className="text-primary rounded-md px-2 py-1 font-medium underline-offset-4 hover:underline focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none"
onClick={() => {
void refetchArtifactContent();
}}
type="button"
>
重新加载
</button>
</div>
)}
{isCodeFile && viewMode === "code" && !artifactContentPending && !artifactContentUnavailable && (
<div className="size-full">
<CodeEditor
className="size-full resize-none rounded-none border-none bg-transparent"
@@ -1946,21 +1969,21 @@ export function ArtifactFilePreview({
researchSessionId?: string;
animated?: boolean;
activeCursor?: boolean;
}) {
const [htmlPreviewUrl, setHtmlPreviewUrl] = useState<string>();
const [mermaidSvg, setMermaidSvg] = useState<string | null>(null);
const [mermaidError, setMermaidError] = useState<string | null>(null);
const researchSourcesQuery = useResearchSources(
language === "markdown" ? (researchSessionId ?? null) : null,
);
const previewContent = useMemo(
() => researchSessionId
? formatResearchCitationsForPreview(content ?? "", researchSourcesQuery.data?.items ?? [])
: (content ?? ""),
[content, researchSessionId, researchSourcesQuery.data?.items],
);
const markdownComponents = useMemo(() => {
const anchorResolver = createMarkdownAnchorResolver(previewContent);
}) {
const [htmlPreviewUrl, setHtmlPreviewUrl] = useState<string>();
const [mermaidSvg, setMermaidSvg] = useState<string | null>(null);
const [mermaidError, setMermaidError] = useState<string | null>(null);
const researchSourcesQuery = useResearchSources(
language === "markdown" ? (researchSessionId ?? null) : null,
);
const previewContent = useMemo(
() => researchSessionId
? formatResearchCitationsForPreview(content ?? "", researchSourcesQuery.data?.items ?? [])
: preserveMarkdownStandaloneLines(content ?? ""),
[content, researchSessionId, researchSourcesQuery.data?.items],
);
const markdownComponents = useMemo(() => {
const anchorResolver = createMarkdownAnchorResolver(previewContent);
const heading = (level: 1 | 2 | 3 | 4 | 5 | 6) => {
const Tag = `h${level}` as const;
return ({ children, id, ...props }: HTMLAttributes<HTMLHeadingElement>) => (
@@ -2016,7 +2039,7 @@ export function ArtifactFilePreview({
);
},
};
}, [previewContent, publicShareCode, researchSessionId, threadId]);
}, [previewContent, publicShareCode, researchSessionId, threadId]);
useEffect(() => {
if (language !== "html") {
@@ -2105,8 +2128,8 @@ export function ArtifactFilePreview({
),
}}
>
{previewContent}
</Streamdown>
{previewContent}
</Streamdown>
{activeCursor && (
<span className="mt-2 inline-block h-5 w-0.5 animate-pulse rounded-full bg-[#9b7a2f]" />
)}
@@ -23,7 +23,7 @@ import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { ModelNameSelect } from "@/components/workspace/model-name-select";
import type { DocumentRewriteStyle } from "@/open-canvas/api/rewrite";
import { useReportStructures } from "@/strategy-components/api/report-structures";
import { useReportStructures, REPORT_STRUCTURE_MODE_OPTIONS, normalizeReportStructureMode, type ReportStructureMode } from "@/strategy-components/api/report-structures";
const KEEP_CURRENT_REPORT_STRUCTURE = "__keep_current_report_structure__";
const DEFAULT_REWRITE_INSTRUCTION = "统一表达风格,减少重复内容,改善段落衔接;不新增原文没有依据的事实。";
@@ -34,6 +34,7 @@ export interface DocumentRewriteDialogInput {
style: DocumentRewriteStyle;
modelName: string | null;
reportOutline: string;
structureMode: "fixed" | "adaptive";
generateImages: boolean;
maxGeneratedImages: number;
}
@@ -99,6 +100,7 @@ export function DocumentRewriteDialog({
const [style, setStyle] = useState<DocumentRewriteStyle>("formal_analysis");
const [modelName, setModelName] = useState<string | null>(null);
const [reportStructureId, setReportStructureId] = useState(KEEP_CURRENT_REPORT_STRUCTURE);
const [structureMode, setStructureMode] = useState<ReportStructureMode>("adaptive");
const [generateImages, setGenerateImages] = useState(false);
const [imageCount, setImageCount] = useState("2");
const reportStructuresQuery = useReportStructures({ status: "enabled" });
@@ -116,6 +118,7 @@ export function DocumentRewriteDialog({
setStyle("formal_analysis");
setModelName(null);
setReportStructureId(KEEP_CURRENT_REPORT_STRUCTURE);
setStructureMode("adaptive");
setGenerateImages(false);
setImageCount("2");
}, [isNewReportGeneration, open]);
@@ -130,6 +133,11 @@ export function DocumentRewriteDialog({
));
}, [initialReportStructureId, open]);
useEffect(() => {
if (!selectedReportStructure) return;
setStructureMode(selectedReportStructure.structureMode);
}, [selectedReportStructure?.id, selectedReportStructure?.structureMode]);
const selectedStyle = STYLE_OPTIONS.find((item) => item.value === style);
const canStart = instruction.trim().length > 0;
const startWith = (handler: (input: DocumentRewriteDialogInput) => void) => {
@@ -139,6 +147,7 @@ export function DocumentRewriteDialog({
style,
modelName,
reportOutline: selectedReportStructure?.content ?? "",
structureMode,
generateImages,
maxGeneratedImages: Math.min(
Math.max(Number(imageCount) || 1, 1),
@@ -179,7 +188,7 @@ export function DocumentRewriteDialog({
/>
</label>
<div className="grid gap-4 sm:grid-cols-3">
<div className="grid gap-4 sm:grid-cols-2">
<label className="grid gap-2 text-sm font-medium">
{isNewReportGeneration ? "报告风格" : "改写风格"}
<Select value={style} onValueChange={(value) => setStyle(value as DocumentRewriteStyle)}>
@@ -227,6 +236,28 @@ export function DocumentRewriteDialog({
</span>
</label>
<label className="grid gap-2 text-sm font-medium">
结构约束
<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="text-muted-foreground text-xs">
{REPORT_STRUCTURE_MODE_OPTIONS.find((option) => option.value === structureMode)?.description}
</span>
</label>
<label className="grid gap-2 text-sm font-medium">
模型
<ModelNameSelect
@@ -76,6 +76,7 @@ import {
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
import {
isBootstrapThread,
isHiddenFromRecentChats,
isThreadFavorite,
isThreadPinned,
listPathOfThread,
@@ -226,14 +227,8 @@ export function GeneralQaSidebar() {
const visibleThreads = useMemo(
() =>
// 智能体创建(bootstrap)会话也展示(带机器人图标、点击回创建页);
// 仅排除调度 / 会商类系统会话。
sortThreadsPinnedFirst(
threads.filter(
(thread) =>
thread.metadata?.thread_type !== "scheduler" &&
thread.metadata?.thread_type !== "roundtable",
),
),
// 排除调度 / 会商 / 报告结构&写作配置向导等系统会话。
sortThreadsPinnedFirst(threads.filter((thread) => !isHiddenFromRecentChats(thread))),
[threads],
);
@@ -131,6 +131,8 @@ export function MessageList({
onSetupWritingApprove,
onSetupWritingReject,
onSetupWritingStart,
onSetupReportStructureApprove,
onSetupReportStructureReject,
researchApprovalSlot,
onOpenArtifact,
onDownloadArtifact,
@@ -157,6 +159,8 @@ export function MessageList({
onSetupWritingReject?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
/** 审批卡「直接开始写作」回调:应用配置(含已确认大纲)并立即启动写作流程(仅写作页提供,模板编辑等场景缺省隐藏该按钮)。 */
onSetupWritingStart?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
onSetupReportStructureApprove?: (payload: SetupReportStructureApprovalPayload) => void | Promise<void>;
onSetupReportStructureReject?: (payload: SetupReportStructureApprovalPayload) => void | Promise<void>;
/**
* 深度研究的交互卡插槽(报告配置、计划确认、失败重试):workbench 向
* displayThread 注入带 `additional_kwargs.setup_research_approval` 的虚拟 ai
@@ -621,6 +625,23 @@ export function MessageList({
/>
</div>
) : null;
} else if (group.type === "assistant:setup-report-structure-approval") {
const message = group.messages[0];
return message ? (
<div key={group.id} className="w-full">
<SetupReportStructureApprovalCard
disabled={thread.isLoading}
message={message}
onApprove={onSetupReportStructureApprove}
onReject={onSetupReportStructureReject}
/>
<MessageTokenUsageList
enabled={tokenUsageEnabled}
isLoading={thread.isLoading}
messages={group.messages}
/>
</div>
) : null;
} else if (group.type === "assistant:research-approval") {
// 深度研究交互卡(配置/计划确认/失败重试):宿主不传渲染函数时该
// 分组不渲染。撰写步骤和完成摘要会经过普通的 processing/assistant 分组。
@@ -860,6 +881,15 @@ export type SetupWritingApprovalPayload = {
outline?: string;
};
export type SetupReportStructureApprovalPayload = {
title?: string;
name?: string;
content?: string;
structure_mode?: string;
retrieval_directions?: string[];
retrieval_skills?: string[];
};
function findPreviousHumanMessageId(
messages: Message[],
beforeMessageId?: string,
@@ -1230,6 +1260,147 @@ function SetupWritingApprovalCard({
);
}
function getSetupReportStructureApprovalPayload(
message: unknown,
): SetupReportStructureApprovalPayload | null {
if (typeof message !== "object" || message === null) {
return null;
}
const additionalKwargs = Reflect.get(message, "additional_kwargs");
if (typeof additionalKwargs !== "object" || additionalKwargs === null) {
return null;
}
const approval = Reflect.get(additionalKwargs, "setup_report_structure_approval");
if (typeof approval !== "object" || approval === null) {
return null;
}
return approval as SetupReportStructureApprovalPayload;
}
const STRUCTURE_MODE_LABELS: Record<string, string> = {
fixed: "固定大纲",
adaptive: "弹性大纲",
};
function SetupReportStructureApprovalCard({
disabled,
message,
onApprove,
onReject,
}: {
disabled: boolean;
message: unknown;
onApprove?: (payload: SetupReportStructureApprovalPayload) => void | Promise<void>;
onReject?: (payload: SetupReportStructureApprovalPayload) => void | Promise<void>;
}) {
const payload = getSetupReportStructureApprovalPayload(message);
const [submitting, setSubmitting] = useState<"approve" | "reject" | null>(null);
if (!payload) {
return null;
}
const approvalPayload = payload;
const busy = disabled || submitting !== null;
const directions = approvalPayload.retrieval_directions ?? [];
const skills = approvalPayload.retrieval_skills ?? [];
async function run(action: "approve" | "reject") {
if (busy) {
return;
}
setSubmitting(action);
try {
if (action === "approve") {
await onApprove?.(approvalPayload);
} else {
await onReject?.(approvalPayload);
}
} finally {
setSubmitting(null);
}
}
return (
<div className="border-border bg-card text-card-foreground w-full rounded-lg border p-4 shadow-sm">
<div className="space-y-3">
<div>
<div className="text-muted-foreground text-xs font-medium">报告结构建议</div>
<div className="text-sm font-medium">确认后将应用到右侧表单,再点保存入库。</div>
</div>
{approvalPayload.title ? (
<div className="space-y-1">
<div className="text-muted-foreground text-xs">标题</div>
<p className="text-sm font-medium">{approvalPayload.title}</p>
</div>
) : null}
{approvalPayload.name ? (
<div className="space-y-1">
<div className="text-muted-foreground text-xs">描述</div>
<p className="text-sm leading-6">{approvalPayload.name}</p>
</div>
) : null}
<div className="flex flex-wrap gap-x-6 gap-y-1.5">
{approvalPayload.structure_mode ? (
<div className="space-y-0.5">
<div className="text-muted-foreground text-xs">结构约束</div>
<p className="text-sm font-medium">
{STRUCTURE_MODE_LABELS[approvalPayload.structure_mode] ?? approvalPayload.structure_mode}
</p>
</div>
) : null}
{directions.length > 0 ? (
<div className="space-y-0.5">
<div className="text-muted-foreground text-xs">检索方向</div>
<p className="text-sm font-medium">{directions.join("、")}</p>
</div>
) : null}
{skills.length > 0 ? (
<div className="space-y-0.5">
<div className="text-muted-foreground text-xs">检索来源</div>
<p className="text-sm font-medium">{skills.join("、")}</p>
</div>
) : null}
</div>
{approvalPayload.content ? (
<div className="space-y-1">
<div className="text-muted-foreground text-xs">报告大纲</div>
<pre className="bg-muted max-h-52 overflow-auto rounded-md p-3 text-xs whitespace-pre-wrap">
{approvalPayload.content}
</pre>
</div>
) : null}
</div>
<div className="mt-4 flex justify-end gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={busy || !onReject}
onClick={() => void run("reject")}
>
<XIcon className="h-4 w-4" />
忽略
</Button>
<Button
type="button"
size="sm"
disabled={busy || !onApprove}
onClick={() => void run("approve")}
>
<CheckIcon className="h-4 w-4" />
应用到表单
</Button>
</div>
</div>
);
}
function getClarificationPayload(message: unknown): ClarificationPayload | null {
if (typeof message !== "object" || message === null) {
return null;
@@ -66,6 +66,7 @@ import {
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
import {
isBootstrapThread,
isHiddenFromRecentChats,
isThreadFavorite,
isThreadPinned,
listPathOfThread,
@@ -193,13 +194,9 @@ export function RecentChatList({
);
// 智能体创建(bootstrap)会话也展示在常用对话里(带机器人图标、点击回创建页);
// 仅排除调度 / 会商类系统会话。
// 排除调度 / 会商 / 报告结构&写作配置向导等系统会话。
const visibleThreads = sortThreadsPinnedFirst(
threads.filter(
(thread) =>
thread.metadata?.thread_type !== "scheduler" &&
thread.metadata?.thread_type !== "roundtable",
),
threads.filter((thread) => !isHiddenFromRecentChats(thread)),
);
if (visibleThreads.length === 0) {
@@ -0,0 +1,97 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
HOST_NAVIGATE_TYPE,
buildHostNavigateMessage,
isHostNavigatePath,
parseHostTaskId,
resolveHostNavigateOrigin,
} from "./host-navigate";
test("isHostNavigatePath accepts a host route", () => {
assert.equal(isHostNavigatePath("/task/item-aiceh/step-analysis-audience"), true);
assert.equal(isHostNavigatePath(" /task/foo "), true);
});
test("isHostNavigatePath rejects urls and protocol-relative paths", () => {
assert.equal(isHostNavigatePath(""), false);
assert.equal(isHostNavigatePath("https://localhost:4912/task"), false);
assert.equal(isHostNavigatePath("//evil.example/task"), false);
assert.equal(isHostNavigatePath("task/foo"), false);
assert.equal(isHostNavigatePath("/task\\foo"), false);
});
test("parseHostTaskId returns a number", () => {
assert.equal(parseHostTaskId("108"), 108);
assert.equal(parseHostTaskId(" 108 "), 108);
assert.equal(parseHostTaskId("0108"), 108);
});
test("parseHostTaskId rejects non-integers", () => {
assert.equal(parseHostTaskId(""), null);
assert.equal(parseHostTaskId("abc"), null);
assert.equal(parseHostTaskId("108.5"), null);
assert.equal(parseHostTaskId("12e3"), null);
assert.equal(parseHostTaskId(null), null);
});
test("resolveHostNavigateOrigin prefers configured origin", () => {
assert.equal(
resolveHostNavigateOrigin({
configured: "https://localhost:4912",
ancestorOrigin: "https://other.example",
referrer: "https://referrer.example/page",
selfOrigin: "http://localhost:5174",
}),
"https://localhost:4912",
);
});
test("resolveHostNavigateOrigin strips a path from configured origin", () => {
assert.equal(
resolveHostNavigateOrigin({ configured: "https://localhost:4912/app" }),
"https://localhost:4912",
);
});
test("resolveHostNavigateOrigin falls back to ancestorOrigins then referrer", () => {
assert.equal(
resolveHostNavigateOrigin({
ancestorOrigin: "https://host.example",
referrer: "https://referrer.example/",
selfOrigin: "http://localhost:5174",
}),
"https://host.example",
);
assert.equal(
resolveHostNavigateOrigin({
referrer: "https://referrer.example/page",
selfOrigin: "http://localhost:5174",
}),
"https://referrer.example",
);
});
test("resolveHostNavigateOrigin never uses * or same-origin referrer", () => {
assert.equal(resolveHostNavigateOrigin({ configured: "*" }), null);
assert.equal(
resolveHostNavigateOrigin({
referrer: "http://localhost:5174/page/foo",
selfOrigin: "http://localhost:5174",
}),
null,
);
assert.equal(resolveHostNavigateOrigin({}), null);
});
test("buildHostNavigateMessage matches the host contract", () => {
assert.deepEqual(
buildHostNavigateMessage("/task/item-aiceh/step-analysis-audience", { id: 108 }),
{
type: HOST_NAVIGATE_TYPE,
path: "/task/item-aiceh/step-analysis-audience",
queryParams: { id: 108 },
},
);
});
@@ -0,0 +1,95 @@
/**
* iframe 嵌入宿主系统时,任务跳转按钮通过 ``window.parent.postMessage`` 让
* **父页面**改路由(宿主协议 ``HOST_NAVIGATE``)。不改 iframe 自己的地址。
*
* 消息形状对齐宿主约定:
* ``{ type: 'HOST_NAVIGATE', path, queryParams: { id: number } }``
* 第二参必须是宿主 origin,禁止 ``'*'``。
*/
export const HOST_NAVIGATE_TYPE = "HOST_NAVIGATE";
export interface HostNavigateMessage {
type: typeof HOST_NAVIGATE_TYPE;
path: string;
queryParams: Record<string, number>;
}
/** 宿主路由:以单个 ``/`` 开头的路径,不含 ``//`` / 反斜杠。 */
export function isHostNavigatePath(path: string): boolean {
const value = path.trim();
if (!value || value.length > 2048) return false;
if (!value.startsWith("/") || value.startsWith("//") || value.includes("\\")) return false;
return true;
}
/** 任务 id 转数字。只接受整数字符串(可带正负号),否则返回 null。 */
export function parseHostTaskId(raw: string | null | undefined): number | null {
const value = (raw ?? "").trim();
if (!value || !/^-?\d+$/.test(value)) return null;
const n = Number(value);
return Number.isFinite(n) ? n : null;
}
function normalizeOrigin(value: string | null | undefined): string | null {
const raw = (value ?? "").trim();
if (!raw || raw === "*") return null;
try {
const url = new URL(raw);
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
return url.origin;
} catch {
return null;
}
}
/**
* 解析 postMessage 的 targetOrigin。
* 优先级:显式配置 → ``ancestorOrigins[0]`` → 跨源 ``document.referrer``。
* 解析失败返回 null(调用方应中止,不得退回 ``'*'``)。
*/
export function resolveHostNavigateOrigin(input: {
configured?: string | null;
ancestorOrigin?: string | null;
referrer?: string | null;
selfOrigin?: string | null;
}): string | null {
const configured = normalizeOrigin(input.configured);
if (configured) return configured;
const ancestor = normalizeOrigin(input.ancestorOrigin);
if (ancestor) return ancestor;
const referrer = normalizeOrigin(input.referrer);
if (!referrer) return null;
const selfOrigin = normalizeOrigin(input.selfOrigin);
if (selfOrigin && referrer === selfOrigin) return null;
return referrer;
}
export function readAncestorOrigin(): string | null {
if (typeof window === "undefined") return null;
try {
return window.location.ancestorOrigins?.[0] ?? null;
} catch {
return null;
}
}
export function isEmbeddedInParent(): boolean {
if (typeof window === "undefined") return false;
try {
return window.parent !== window;
} catch {
return true;
}
}
export function buildHostNavigateMessage(
path: string,
queryParams: Record<string, number> = {},
): HostNavigateMessage {
return {
type: HOST_NAVIGATE_TYPE,
path,
queryParams,
};
}
+51 -5
View File
@@ -7,9 +7,11 @@
* 任务深链配置读出 rwfx 的 4 个历史按钮,3qfx/xdfx 为空)——这样真实跳转地址通过实时
* config 进种子,避免写死;一旦保存过,后端即唯一数据源。
*
* 按钮两类(``linkType``):``url``(外链 ``window.open``;**或应用内路由**——target 以 ``/`` 开头
* 按钮类型(``linkType``):``url``(外链 ``window.open``;**或应用内路由**——target 以 ``/`` 开头
* 时按内部路由处理:``self`` 走 react-router 同窗导航、``blank`` 拼 hash 路由新标签;可选追加 ``?{idParam}=id``)/
* ``business`` 内部切换到另一业务(复用 [[task-deeplink]] 的 ``resolveTaskDeeplinkTarget``)。
* ``business`` 内部切换到另一业务(复用 [[task-deeplink]] 的 ``resolveTaskDeeplinkTarget``)/
* ``purpose`` 弹出任务目的树 / ``host`` iframe 内向父页面 ``postMessage({ type:'HOST_NAVIGATE' })``
* 让宿主系统跳转(见 [[host-navigate]])。
*/
import {
@@ -25,6 +27,15 @@ import {
readLoginToken,
readLoginUsername,
} from "@/core/auth/login-credentials";
import {
buildHostNavigateMessage,
isEmbeddedInParent,
isHostNavigatePath,
parseHostTaskId,
readAncestorOrigin,
resolveHostNavigateOrigin,
} from "@/core/auth/host-navigate";
import { readRuntimeEnv } from "@/env";
/** 「大屏绘制」独立页应用内路由(深链「上一步」默认指向它,跳转追加 ?taskId=)。 */
export const DASHBOARD_AGENT_ROUTE = "/page/strategy/dashboard-agent";
@@ -62,8 +73,8 @@ export const TASK_BUSINESSES: {
},
];
/** ``url``=外链 / ``business``=切换业务 / ``purpose``=弹出任务目的树表格(无需 target)。 */
export type TaskButtonLinkType = "url" | "business" | "purpose";
/** ``url``=外链 / ``business``=切换业务 / ``purpose``=弹出任务目的树表格 / ``host``=宿主路由(postMessage)。 */
export type TaskButtonLinkType = "url" | "business" | "purpose" | "host";
/** 追加的 id 取哪种:``task``=任务id(默认)/ ``action``=行动id(仅 xdfx 二者不同)。 */
export type TaskButtonIdKind = "task" | "action";
@@ -91,7 +102,7 @@ export interface TaskButton {
business: TaskBusiness;
label: string;
linkType: TaskButtonLinkType;
/** ``url``=完整地址;``business``=目标业务码(如 ``3qfx``)。 */
/** ``url``=完整地址;``business``=目标业务码(如 ``3qfx``);``host``=宿主系统路由(如 ``/task/...``)。 */
target: string;
/** ``url`` 类型是否追加 id 查询参数。 */
appendTaskId: boolean;
@@ -316,11 +327,46 @@ export interface RunTaskButtonCtx {
* 执行一个跳转按钮:
* - ``url``:拼地址(可选追加 ``?id=``,取任务id 或行动id 视 ``idKind``)新标签打开;地址为空提示未配置。
* - ``business``:复用深链解析(会按目标业务重绑任务上下文)后 ``navigate``(始终用任务id)。
* - ``host``:向 ``window.parent`` 发 ``HOST_NAVIGATE``,由宿主系统跳转(仅 iframe 嵌入时生效)。
*/
export async function runTaskButton(btn: TaskButton, ctx: RunTaskButtonCtx): Promise<void> {
const fail = (m: string) => ctx.onError?.(m);
// purpose(目的树弹框)由组件层处理(需打开 React 弹框),此处不动作。
if (btn.linkType === "purpose") return;
if (btn.linkType === "host") {
const path = btn.target.trim();
if (!isHostNavigatePath(path)) {
fail(`未配置「${btn.label}」的宿主路由`);
return;
}
if (!isEmbeddedInParent()) {
fail("当前不在嵌入页,无法跳转宿主系统");
return;
}
const origin = resolveHostNavigateOrigin({
configured: readRuntimeEnv("VITE_HOST_NAVIGATE_ORIGIN"),
ancestorOrigin: readAncestorOrigin(),
referrer: typeof document !== "undefined" ? document.referrer : null,
selfOrigin: typeof window !== "undefined" ? window.location.origin : null,
});
if (!origin) {
fail("未配置宿主地址(VITE_HOST_NAVIGATE_ORIGIN),无法安全跳转");
return;
}
const queryParams: Record<string, number> = {};
if (btn.appendTaskId) {
const id = parseHostTaskId(ctx.taskId);
if (id === null) {
fail("任务id不是数字,无法跳转宿主系统");
return;
}
queryParams[(btn.idParam || "id").trim() || "id"] = id;
}
const message = buildHostNavigateMessage(path, queryParams);
console.info(`[task-button] 「${btn.label}」宿主路由 →`, message, origin);
window.parent.postMessage(message, origin);
return;
}
if (btn.linkType === "url") {
const base = btn.target.trim();
if (!base) {
+41 -16
View File
@@ -95,25 +95,41 @@ function mockCopTaskDetail(taskId: string): CopTaskDetail {
};
}
/** 拉取任务详情;``VITE_MOCK_COP_TASK_DETAIL=true`` 走假数据;缺少 consumer 地址 / 返回不合法时返回 null。 */
/** 详情接口不可用时仍能继续深链跳转:至少带上任务 id,标题/简介可空。 */
export function stubCopTaskDetail(taskId: string): CopTaskDetail {
return {
id: taskId,
overview: "",
taskDirection: "",
taskName: "",
taskContent: "",
};
}
/** 拉取任务详情;``VITE_MOCK_COP_TASK_DETAIL=true`` 走假数据;缺少 consumer 地址 / 返回不合法 / 网络失败时返回 null。 */
export async function fetchCopTaskDetail(taskId: string): Promise<CopTaskDetail | null> {
if (copTaskDetailMockEnabled()) {
return mockCopTaskDetail(taskId);
}
const base = consumerBaseUrl();
if (!base || !taskId) return null;
// service 拦截器已统一注入 `Authorization: Bearer admin` 并返回 response.data(整包 body)。
const body = await requestCrudService.get(`${base}/taskAnalyseSearch/cop-task-detail`, { taskId });
const data = body && typeof body === "object" ? (body as { data?: unknown }).data : null;
if (!data || typeof data !== "object") return null;
const d = data as Record<string, unknown>;
return {
id: (d.id as string | number | null) ?? null,
overview: typeof d.overview === "string" ? d.overview : "",
taskDirection: typeof d.taskDirection === "string" ? d.taskDirection : "",
taskName: typeof d.taskName === "string" ? d.taskName : "",
taskContent: typeof d.taskContent === "string" ? d.taskContent : "",
};
try {
// service 拦截器已统一注入 `Authorization: Bearer admin` 并返回 response.data(整包 body)。
const body = await requestCrudService.get(`${base}/taskAnalyseSearch/cop-task-detail`, { taskId });
const data = body && typeof body === "object" ? (body as { data?: unknown }).data : null;
if (!data || typeof data !== "object") return null;
const d = data as Record<string, unknown>;
return {
id: (d.id as string | number | null) ?? null,
overview: typeof d.overview === "string" ? d.overview : "",
taskDirection: typeof d.taskDirection === "string" ? d.taskDirection : "",
taskName: typeof d.taskName === "string" ? d.taskName : "",
taskContent: typeof d.taskContent === "string" ? d.taskContent : "",
};
} catch (error) {
console.warn("[task-deeplink] cop-task-detail failed", { taskId, error });
return null;
}
}
/** 把任务详情整包暂存到 sessionStorage(隐私模式/quota 异常时静默失败)。 */
@@ -223,7 +239,12 @@ export async function fetchTaskIdFromAction(actionId: string): Promise<string> {
export async function resolveDeeplinkTaskId(goPath: string, rawId: string): Promise<string> {
const parsed = parseGoPath(goPath);
if (parsed?.businessCode === XDFX_BUSINESS_CODE) {
return fetchTaskIdFromAction(rawId);
try {
return (await fetchTaskIdFromAction(rawId)) || "";
} catch (error) {
console.warn("[task-deeplink] action→taskId resolve failed", { rawId, error });
return "";
}
}
return rawId;
}
@@ -332,10 +353,14 @@ export async function resolveTaskDeeplinkTarget(
try {
const mappings = await listBusinessMappings();
agentId = mappings.find((m) => m.businessCode === parsed.businessCode)?.agentId ?? null;
} catch {
} catch (error) {
console.warn("[task-deeplink] business mapping failed", error);
agentId = null;
}
if (!agentId) return null;
if (!agentId) {
console.warn("[task-deeplink] no agent mapped for", parsed.businessCode);
return null;
}
// 单智能体问答统一走「任务工作区」(任务↔对话 1:N,按 taskId + agentId 隔离不同业务的对话列表):
// 暂存任务上下文(taskId + agentId + 开场信息),跳到 chats 索引,由工作区按 taskId 决定
+20
View File
@@ -22,6 +22,8 @@ interface AssistantSetupAgentApprovalGroup extends GenericMessageGroup<"assistan
interface AssistantSetupWritingApprovalGroup extends GenericMessageGroup<"assistant:setup-writing-approval"> {}
interface AssistantSetupReportStructureApprovalGroup extends GenericMessageGroup<"assistant:setup-report-structure-approval"> {}
interface AssistantResearchApprovalGroup extends GenericMessageGroup<"assistant:research-approval"> {}
interface AssistantSubagentGroup extends GenericMessageGroup<"assistant:subagent"> {}
@@ -34,6 +36,7 @@ type MessageGroup =
| AssistantClarificationGroup
| AssistantSetupAgentApprovalGroup
| AssistantSetupWritingApprovalGroup
| AssistantSetupReportStructureApprovalGroup
| AssistantResearchApprovalGroup
| AssistantSubagentGroup;
@@ -58,6 +61,7 @@ export function groupMessages<T>(
last.type !== "assistant:clarification" &&
last.type !== "assistant:setup-agent-approval" &&
last.type !== "assistant:setup-writing-approval" &&
last.type !== "assistant:setup-report-structure-approval" &&
last.type !== "assistant:research-approval"
) {
return last;
@@ -103,6 +107,13 @@ export function groupMessages<T>(
type: "assistant:setup-writing-approval",
messages: [message],
});
} else if (isSetupReportStructureApprovalToolMessage(message)) {
lastOpenGroup()?.messages.push(message);
groups.push({
id: message.id,
type: "assistant:setup-report-structure-approval",
messages: [message],
});
} else {
const open = lastOpenGroup();
if (open) {
@@ -419,6 +430,15 @@ export function isSetupWritingApprovalToolMessage(message: Message) {
);
}
export function isSetupReportStructureApprovalToolMessage(message: Message) {
return (
message.type === "tool" &&
message.name === "setup_report_structure" &&
typeof message.additional_kwargs?.setup_report_structure_approval === "object" &&
message.additional_kwargs.setup_report_structure_approval !== null
);
}
export function hasResearchApproval(message: Message) {
return (
message.type === "ai" &&
+8 -2
View File
@@ -99,8 +99,14 @@ export function useNotebookChat(
},
});
const send = async (text: string, selectedSourceIds: number[]) => {
if (thread.isLoading || !token) return;
const send = async (
text: string,
selectedSourceIds: number[],
options?: { fromClarification?: boolean },
) => {
if (!token) return;
// 协助卡提交前会先停轮;此时 isLoading 可能仍为 true,不能直接丢弃。
if (thread.isLoading && !options?.fromClarification) return;
// API 需要 document.id,而不是 source.id
const toDocIds = (ids: number[]) =>
ids
+44 -13
View File
@@ -28,6 +28,7 @@ import {
isAbortLikeError,
isTransientStreamTransportError,
} from "./thread-access-error";
import { isHiddenFromRecentChats, shouldHideSystemThreadsFromList } from "./utils";
export type ToolEndEvent = {
name: string;
@@ -210,6 +211,39 @@ export async function stopThreadRun(
}
}
/**
* 协助卡提交前:若上一轮还在流式(弱模型出卡后偶发未停干净),先取消服务端 run
* 并断开本地 SSE,避免 409「已有活跃 run」或用户点了选项却发不出去。
*/
export async function stopActiveThreadBeforeClarificationReply(
threadId: string | null | undefined,
thread: { isLoading?: boolean; stop: () => void | Promise<void> },
): Promise<void> {
let storedRunId: string | null = null;
if (threadId && typeof window !== "undefined") {
try {
storedRunId = normalizeStoredRunId(
window.sessionStorage.getItem(`lg:stream:${threadId}`),
);
} catch {
storedRunId = null;
}
}
if (!thread.isLoading && !storedRunId) return;
try {
await stopThreadRun(threadId);
} catch {
/* best-effort */
}
try {
await Promise.resolve(thread.stop());
} catch {
/* best-effort */
}
// cancel 落地需要一拍,立刻 submit 仍可能 409。
await new Promise((resolve) => setTimeout(resolve, 120));
}
function getStreamErrorMessage(error: unknown): string {
if (typeof error === "string" && error.trim()) {
return error;
@@ -1451,18 +1485,10 @@ export function useThreads(
}
// Safety net on top of the backend's system-thread exclusion: drop
// machine-created threads (scheduled tasks, roundtable workers,
// notebooks) in case an older backend still returns them.
return threads.filter((t) => {
const metadata = t.metadata as Record<string, unknown> | null;
if (metadata?.system) return false;
const threadType = metadata?.thread_type;
return (
threadType !== "notebook" &&
threadType !== "scheduler" &&
threadType !== "roundtable"
);
});
// machine-created / wizard threads (scheduled tasks, report-structure
// setup, writing setup, notebooks, …) in case an older backend still
// returns them.
return threads.filter((t) => !isHiddenFromRecentChats(t));
},
refetchOnWindowFocus: false,
});
@@ -1545,8 +1571,13 @@ export function useThreadsPage(options: {
throw new Error("Failed to load conversations");
}
const rows = (await response.json()) as AgentThread[];
// 与后端 exclude_system 对齐:普通列表再兜一层。调用方显式按
// thread_type / system 取数(笔记本等)时不滤,避免把目标会话藏掉。
const visible = shouldHideSystemThreadsFromList(metadata)
? rows.filter((thread) => !isHiddenFromRecentChats(thread))
: rows;
return {
threads: rows.slice(0, pageSize),
threads: visible.slice(0, pageSize),
hasMore: rows.length > pageSize,
};
},
+38
View File
@@ -91,6 +91,44 @@ export function isBootstrapThread(thread: {
return thread.metadata?.thread_type === "bootstrap";
}
/**
* 不应出现在普通对话列表里的 thread_type。
* 与后端 ``EXCLUDED_SYSTEM_THREAD_TYPES`` 保持同步;bootstrap 不在此列(侧栏仍展示)。
*/
export const HIDDEN_FROM_RECENT_CHAT_TYPES = new Set([
"scheduler",
"roundtable",
"notebook",
"deep_research",
"writing_setup",
"report_structure_setup",
]);
export function isSetupWizardThread(thread: {
metadata?: Record<string, unknown> | null;
}): boolean {
const threadType = thread.metadata?.thread_type;
return threadType === "writing_setup" || threadType === "report_structure_setup";
}
/** 侧栏 / 全部对话应隐藏的系统 / 向导会话(bootstrap 除外,仍会展示)。 */
export function isHiddenFromRecentChats(thread: {
metadata?: Record<string, unknown> | null;
}): boolean {
const metadata = thread.metadata;
if (metadata?.system === true) return true;
const threadType = metadata?.thread_type;
return typeof threadType === "string" && HIDDEN_FROM_RECENT_CHAT_TYPES.has(threadType);
}
/** 调用方显式按 thread_type / system 过滤时,不要再套一层「最近对话」隐藏。 */
export function shouldHideSystemThreadsFromList(
metadata?: Record<string, unknown> | null,
): boolean {
if (!metadata) return true;
return !("thread_type" in metadata || "system" in metadata);
}
/**
* bootstrap 会话点击后回到「创建智能体」页并恢复该会话。该页只挂在
* /page/workspace 下(见 WorkspaceRoutes 的 agents/new/:thread_id),故路径固定,
@@ -121,6 +121,8 @@ export interface DocumentRewriteRequest {
modelName?: string;
/** Optional Markdown chapter structure selected from the report-structure library. */
reportOutline?: string;
/** ``fixed`` reproduces the outline; ``adaptive`` keeps the material-driven layout. */
structureMode?: "fixed" | "adaptive";
/** Generate locally saved illustration assets after the prose rewrite. */
generateImages?: boolean;
/** Bounded server-side to 1–4. Ignored unless generateImages is true. */
@@ -26,7 +26,7 @@ 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 { useThreadStream } from '@/core/threads/hooks';
import { stopActiveThreadBeforeClarificationReply, useThreadStream } from '@/core/threads/hooks';
import { uuid } from '@/core/utils/uuid';
import { cn } from '@/lib/utils';
@@ -146,7 +146,10 @@ export function WritingSetupChat({
const ensureThreadExists = useCallback(async () => {
if (threadPrepared) return;
await getAPIClient().threads.create({ threadId, metadata: { thread_type: 'writing_setup' } });
await getAPIClient().threads.create({
threadId,
metadata: { thread_type: 'writing_setup', system: true },
});
setThreadPrepared(true);
}, [threadId, threadPrepared]);
@@ -165,6 +168,17 @@ export function WritingSetupChat({
[thread, ensureThreadExists, sendMessage, threadId, formCtx],
);
const handleClarificationSubmit = useCallback(
async (text: string) => {
const trimmed = text.trim();
if (!trimmed) return;
await stopActiveThreadBeforeClarificationReply(threadId, thread);
await ensureThreadExists();
await sendMessage(threadId, { text: trimmed, files: [] }, buildWritingSampleContext(formCtx));
},
[thread, ensureThreadExists, sendMessage, threadId, formCtx],
);
const publishSampleMessage = useCallback(
(filename: string, text: string) => {
const normalized = text.trim();
@@ -362,7 +376,7 @@ export function WritingSetupChat({
className="size-full"
threadId={threadId}
thread={displayThread}
onClarificationSubmit={handleChatSubmit}
onClarificationSubmit={handleClarificationSubmit}
onSetupWritingApprove={handleApprove}
onSetupWritingReject={handleReject}
onSetupWritingStart={canStartWriting ? handleStart : undefined}
@@ -36,6 +36,7 @@ import { Textarea } from "@/components/ui/textarea";
import { MessageList } from "@/components/workspace/messages";
import { useModels } from "@/core/models/hooks";
import type { AgentThreadState } from "@/core/threads";
import { stopActiveThreadBeforeClarificationReply } from "@/core/threads/hooks";
import { cn } from "@/lib/utils";
import { useCanvasGraphContext } from "../../contexts/CanvasGraphContext";
@@ -177,9 +178,10 @@ export function CanvasChatPanel() {
const handleClarificationSubmit = useCallback(
async (answer: string) => {
await stopActiveThreadBeforeClarificationReply(threadId, thread);
await sendMessage(answer, { mode });
},
[sendMessage, mode],
[sendMessage, mode, thread, threadId],
);
const handleModelSelect = useCallback(
+5 -4
View File
@@ -31,7 +31,7 @@ import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-dep
import { useModels } from "@/core/models/hooks";
import { useNotification } from "@/core/notification/hooks";
import { saveLastAgentId, useThreadSettings } from "@/core/settings";
import { stopThreadRun, useThreadStream } from "@/core/threads/hooks";
import { stopActiveThreadBeforeClarificationReply, stopThreadRun, useThreadStream } from "@/core/threads/hooks";
import { textOfMessage } from "@/core/threads/utils";
import { OptType, writeYLog } from "@/core/log/y-log";
import {
@@ -542,11 +542,12 @@ export default function AgentChatPage({
}, [taskAutoStart, taskStartText, isNewThread, handleSubmit]);
const handleClarificationSubmit = useCallback(
(answer: string) => {
async (answer: string) => {
setHasStartedConversation(true);
void sendMessage(threadId, { text: answer, files: [] }, { agent_id });
await stopActiveThreadBeforeClarificationReply(threadId, thread);
await sendMessage(threadId, { text: answer, files: [] }, { agent_id });
},
[sendMessage, threadId, agent_id],
[sendMessage, thread, threadId, agent_id],
);
const handleStop = useCallback(async () => {
+5 -4
View File
@@ -58,7 +58,7 @@ import {
useKnowledgeRagEnabled,
useThreadSettings,
} from "@/core/settings";
import { useThreadStream } from "@/core/threads/hooks";
import { useThreadStream, stopActiveThreadBeforeClarificationReply } from "@/core/threads/hooks";
import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-deposit";
import { useSingleRoundRagMessages } from "@/core/threads/use-single-round-rag";
import { useMultiRoundRagMessages } from "@/core/threads/use-multi-round-rag";
@@ -525,11 +525,12 @@ export default function ChatPage() {
);
const handleClarificationSubmit = useCallback(
(answer: string) => {
async (answer: string) => {
setHasStartedConversation(true);
void sendMessage(threadId, { text: answer, files: [] });
await stopActiveThreadBeforeClarificationReply(threadId, thread);
await sendMessage(threadId, { text: answer, files: [] });
},
[sendMessage, threadId],
[sendMessage, thread, threadId],
);
useEffect(() => {
+5 -4
View File
@@ -9,7 +9,7 @@ import { setStoredAuth } from "@/core/auth";
import { resolveEmbedChatParams } from "@/core/embed/params";
import { registerEmbedSession } from "@/core/embed/status-api";
import { useThreadSettings } from "@/core/settings";
import { useThreadStream } from "@/core/threads/hooks";
import { stopActiveThreadBeforeClarificationReply, useThreadStream } from "@/core/threads/hooks";
import { uuid } from "@/core/utils/uuid";
import { ChatBox } from "@/components/workspace/chats";
import {
@@ -110,10 +110,11 @@ export default function EmbedChatPage() {
}, [authReady, threadId, registerSessionForThread]);
const handleClarificationSubmit = useCallback(
(answer: string) => {
void sendMessage(threadId, { text: answer, files: [] });
async (answer: string) => {
await stopActiveThreadBeforeClarificationReply(threadId, thread);
await sendMessage(threadId, { text: answer, files: [] });
},
[sendMessage, threadId],
[sendMessage, thread, threadId],
);
useEffect(() => {
+5 -4
View File
@@ -40,7 +40,7 @@ import {
useThreadSettings,
} from "@/core/settings";
import { usePluginManagementSettings } from "@/core/system-settings/hooks";
import { useThreadStream } from "@/core/threads/hooks";
import { stopActiveThreadBeforeClarificationReply, useThreadStream } from "@/core/threads/hooks";
import { textOfMessage } from "@/core/threads/utils";
import { env } from "@/env";
import { cn } from "@/lib/utils";
@@ -336,11 +336,12 @@ export default function IframeChatPage() {
);
const handleClarificationSubmit = useCallback(
(answer: string) => {
async (answer: string) => {
setHasStartedConversation(true);
void sendMessage(threadId, { text: answer, files: [] });
await stopActiveThreadBeforeClarificationReply(threadId, thread);
await sendMessage(threadId, { text: answer, files: [] });
},
[sendMessage, threadId],
[sendMessage, thread, threadId],
);
const handleAgentChange = useCallback((agentId: string | null) => {
+42 -17
View File
@@ -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('深链跳转失败,已进入首页');
}
}
+15 -3
View File
@@ -53,7 +53,7 @@ import { useI18n } from "@/core/i18n/hooks";
import { useModels } from "@/core/models/hooks";
import { isAgentSkillCandidate } from "@/core/skills";
import { useSkills } from "@/core/skills/hooks";
import { useThreadStream } from "@/core/threads/hooks";
import { stopActiveThreadBeforeClarificationReply, useThreadStream } from "@/core/threads/hooks";
import { uuid } from "@/core/utils/uuid";
import { cn } from "@/lib/utils";
@@ -335,13 +335,25 @@ export default function NewAgentPage() {
// Chat is purely a discussion channel. The agent record is only
// created when the user explicitly clicks Save (top-right). If the
// model proposes a config via setup_agent, our approval handler
// prefills the form locally 鈥?it does NOT persist anything.
// prefills the form locally — it does NOT persist anything.
await ensureThreadExists();
await sendMessage(threadId, { text: trimmed, files: [] });
},
[ensureThreadExists, sendMessage, thread.isLoading, threadId],
);
const handleClarificationSubmit = useCallback(
async (text: string) => {
const trimmed = text.trim();
if (!trimmed) return;
// 协助卡提交不能因 isLoading 直接丢弃——弱模型出卡后可能仍在流式。
await stopActiveThreadBeforeClarificationReply(threadId, thread);
await ensureThreadExists();
await sendMessage(threadId, { text: trimmed, files: [] });
},
[ensureThreadExists, sendMessage, thread, threadId],
);
const handleApproveSetupAgent = useCallback(
async (payload: SetupAgentApprovalPayload) => {
// Approving the model's setup_agent proposal **does not** persist
@@ -425,7 +437,7 @@ export default function NewAgentPage() {
className="size-full"
threadId={threadId}
thread={thread}
onClarificationSubmit={handleChatSubmit}
onClarificationSubmit={handleClarificationSubmit}
onSetupAgentApprove={handleApproveSetupAgent}
onSetupAgentReject={handleRejectSetupAgent}
/>
@@ -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 给沙箱预览) */}
@@ -25,6 +25,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { useNotebookChat, useNotebookSources, useNotebookThreads, useStudioNotebooks } from "@/core/studio/hooks";
import { stopActiveThreadBeforeClarificationReply } from "@/core/threads/hooks";
import { fetchDocumentByChunk } from "@/core/studio/api";
import type { DocumentWithChunks } from "@/core/studio/api";
import type { SourceItem } from "@/core/studio/types";
@@ -381,12 +382,13 @@ export default function StudioNotebookDetailPage({
// Clarification 卡片的「发送」按钮回调。MessageList 内部把用户选中的选项 +
// 自由文本合并成一段 answer 传过来,这里走和普通发送同一条 send() 链路,
// 把当前选中的来源也一并带上,保持上下文一致。
// 弱模型出卡后可能仍在流式:先 stop 再发,不能因 isStreaming 直接丢弃。
const handleClarificationSubmit = useCallback(
async (answer: string) => {
if (isStreaming) return;
await send(answer, selectedSourceIds);
await stopActiveThreadBeforeClarificationReply(chatThreadId, thread);
await send(answer, selectedSourceIds, { fromClarification: true });
},
[send, selectedSourceIds, isStreaming],
[send, selectedSourceIds, chatThreadId, thread],
);
const handleBackToList = () => {
+36 -2
View File
@@ -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={
@@ -87,7 +87,11 @@ import { useLocalSettings } from "@/core/settings/hooks";
import { useModels } from "@/core/models/hooks";
import type { DocumentRewriteStreamEvent, DocumentRewriteSummary } from "@/open-canvas/api/rewrite";
import { useDocumentRewrite } from "@/open-canvas/hooks/useDocumentRewrite";
import { stopThreadRun, useThreadStream } from "@/core/threads/hooks";
import {
stopActiveThreadBeforeClarificationReply,
stopThreadRun,
useThreadStream,
} from "@/core/threads/hooks";
import {
extractContentFromMessage,
extractReasoningContentFromMessage,
@@ -120,7 +124,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 +452,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 +547,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 +878,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,24 +916,46 @@ 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(
async (text: string) => {
const threadId = session?.runtimeThreadId;
await stopActiveThreadBeforeClarificationReply(threadId, collectorThread);
await sendCollectorMessage(text);
},
[sendCollectorMessage],
[collectorThread, sendCollectorMessage, session?.runtimeThreadId],
);
// The very first research topic: auto-sent into the freshly pre-built
@@ -1499,6 +1545,7 @@ function DeepResearchWorkbenchInner() {
style: "formal_analysis" | "professional" | "concise" | "readable";
modelName: string | null;
reportOutline: string;
structureMode?: ReportStructureMode;
createNewReport?: boolean;
}) => {
if (!session) {
@@ -1523,6 +1570,7 @@ function DeepResearchWorkbenchInner() {
const parentRunSnapshot = run;
targetSession = await createResearchReportVariant(session.id, {
reportOutline: input.reportOutline || undefined,
structureMode: input.structureMode,
});
reportDocumentRewrite.reset();
const toneByStyle = {
@@ -1536,6 +1584,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 +1652,7 @@ function DeepResearchWorkbenchInner() {
style: input.style,
modelName: input.modelName ?? undefined,
reportOutline: input.reportOutline || undefined,
structureMode: input.structureMode,
},
{
stream: (signal) =>
@@ -1614,6 +1664,7 @@ function DeepResearchWorkbenchInner() {
style: input.style,
modelName: input.modelName ?? undefined,
reportOutline: input.reportOutline || undefined,
structureMode: input.structureMode,
},
signal,
(jobId) => {
@@ -1818,16 +1869,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 +2044,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 +2309,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 +3283,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 +3297,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,238 @@
"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 { stopActiveThreadBeforeClarificationReply, 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", system: true },
});
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) => {
const trimmed = text.trim();
if (!trimmed) return;
await stopActiveThreadBeforeClarificationReply(threadId, thread);
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;
File diff suppressed because it is too large. Load diff
@@ -9,7 +9,7 @@ import { MessageList } from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context";
import { useAgent } from "@/core/agents";
import { useLocalSettings } from "@/core/settings";
import { useThreadStream } from "@/core/threads/hooks";
import { stopActiveThreadBeforeClarificationReply, useThreadStream } from "@/core/threads/hooks";
import type { AgentThreadState } from "@/core/threads/types";
import { textOfMessage } from "@/core/threads/utils";
import { uuid } from "@/core/utils/uuid";
@@ -460,7 +460,10 @@ export function PositionBuiltinAgentChat({
);
const handleSubmit = useCallback(
async (message: PromptInputMessage) => {
async (
message: PromptInputMessage,
options?: { fromClarification?: boolean },
) => {
if (!session) {
toast.warning("请先启动业务链");
return;
@@ -469,7 +472,9 @@ export function PositionBuiltinAgentChat({
toast.info(readOnlyHint || "当前阶段暂不可对话");
return;
}
if (pendingSubmissionRef.current || thread.isLoading) {
if (options?.fromClarification) {
pendingSubmissionRef.current = false;
} else if (pendingSubmissionRef.current || thread.isLoading) {
return;
}
pendingSubmissionRef.current = true;
@@ -540,6 +545,8 @@ export function PositionBuiltinAgentChat({
{
agent_id: submission.agentId,
...(selectedModel ? { model: selectedModel } : {}),
// Summary: one Markdown only. Action-plan needs report + JSON — no cap.
...(kind === "summary" ? { position_artifact_cap: 1 } : {}),
},
{
hiddenContext: {
@@ -683,9 +690,18 @@ export function PositionBuiltinAgentChat({
// Use the built-in agent's regular submit path. This keeps a
// clarification answer on the same persisted thread and retains
// the summary/action-plan preparation and completion lifecycle.
onClarificationSubmit={(answer) =>
handleSubmit({ text: answer, files: [] })
}
onClarificationSubmit={(answer) => {
void (async () => {
await stopActiveThreadBeforeClarificationReply(
boundThreadId ?? displayThreadId,
thread,
);
await handleSubmit(
{ text: answer, files: [] },
{ fromClarification: true },
);
})();
}}
onEditResubmit={(text) => void handleSubmit({ text, files: [] })}
onOpenArtifact={handleOpenMessageArtifact}
/>
@@ -16,6 +16,7 @@ import { MessageList } from "@/components/workspace/messages";
import { useAgent } from "@/core/agents";
import { useLocalSettings } from "@/core/settings";
import {
stopActiveThreadBeforeClarificationReply,
useEditMessage,
useThreadStream,
type ToolStartEvent,
@@ -852,6 +853,9 @@ export function PositionNodeChat({
await sendMessage(boundThreadIdRef.current ?? draftThreadIdRef.current, message, {
agent_id: node.agentId,
task_external_context: taskExternalContext,
// Hard-cap outputs write_file to one deliverable per turn (weak models
// otherwise rewrite the same Markdown and loop). Action-plan is exempt.
position_artifact_cap: 1,
});
} catch (error) {
autoPreviewRunArmedRef.current = false;
@@ -1006,9 +1010,15 @@ export function PositionNodeChat({
// nodes own the actual stream. Route their answer through the
// same submit path as the normal input so handoff context,
// permissions and node lifecycle updates are all preserved.
onClarificationSubmit={(answer) =>
handleSubmit({ text: answer, files: [] })
}
onClarificationSubmit={(answer) => {
void (async () => {
await stopActiveThreadBeforeClarificationReply(
boundThreadId ?? displayThreadId,
thread,
);
await handleSubmit({ text: answer, files: [] });
})();
}}
onEditResubmit={(text) => void handleSubmit({ text, files: [] })}
onEditSave={handleSaveMessageEdit}
messageContentOverrides={messageContentOverrides}
@@ -646,7 +646,7 @@ export async function listReportVariants(parentSessionId: string): Promise<DeepR
*/
export async function createResearchReportVariant(
sessionId: string,
input: { reportOutline?: string },
input: { reportOutline?: string; structureMode?: "fixed" | "adaptive" },
): Promise<DeepResearchSession> {
const response = await apiFetch(
`${BASE()}/sessions/${encodeURIComponent(sessionId)}/report-variants`,
@@ -860,6 +860,7 @@ export async function* streamFullResearchReportRewrite(
style: DocumentRewriteStyle;
modelName?: string;
reportOutline?: string;
structureMode?: "fixed" | "adaptive";
},
signal?: AbortSignal,
onJobStarted?: (jobId: string) => void,
@@ -16,6 +16,128 @@ import {
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
export type ReportStructureMode = "fixed" | "adaptive";
export const DEFAULT_REPORT_STRUCTURE_MODE: ReportStructureMode = "adaptive";
export const REPORT_STRUCTURE_MODE_OPTIONS: {
value: ReportStructureMode;
label: string;
description: string;
}[] = [
{
value: "fixed",
label: "固定大纲",
description: "按结构内容的章节标题与层级逐一成文,不得改写、合并或省略。",
},
{
value: "adaptive",
label: "弹性大纲",
description: "保留现有成文策略,可按材料对无依据小节做合并或省略。",
},
];
export function normalizeReportStructureMode(value: unknown): ReportStructureMode {
return value === "fixed" ? "fixed" : "adaptive";
}
export function reportStructureModeLabel(mode: ReportStructureMode): string {
return mode === "fixed" ? "固定大纲" : "弹性大纲";
}
export const MAX_RETRIEVAL_DIRECTIONS = 12;
export const MAX_RETRIEVAL_DIRECTION_CHARS = 200;
export const MAX_RETRIEVAL_SKILLS = 8;
export const MAX_RETRIEVAL_SKILL_CHARS = 64;
const EMPTY_LIST_TOKENS = new Set(["", "[]", "{}", "null", "none", "undefined"]);
function coerceListish(value: unknown): unknown[] {
if (value == null || typeof value === "boolean") return [];
if (Array.isArray(value)) return value;
if (typeof value === "string") {
const text = value.trim();
if (!text || EMPTY_LIST_TOKENS.has(text.toLowerCase())) return [];
if (text.startsWith("[") || text.startsWith("{")) {
try {
const parsed = JSON.parse(text) as unknown;
if (parsed == null) return [];
if (Array.isArray(parsed)) return parsed;
return [];
} catch {
return [value];
}
}
return [value];
}
return [];
}
export function normalizeRetrievalDirections(value: unknown): string[] {
const parts = coerceListish(value);
const seen = new Set<string>();
const out: string[] = [];
for (const item of parts) {
const text = String(item ?? "")
.replace(/\s+/g, " ")
.trim()
.slice(0, MAX_RETRIEVAL_DIRECTION_CHARS);
if (!text || EMPTY_LIST_TOKENS.has(text.toLowerCase())) continue;
const key = text.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
out.push(text);
if (out.length >= MAX_RETRIEVAL_DIRECTIONS) break;
}
return out;
}
export function normalizeRetrievalSkills(value: unknown): string[] {
const parts = coerceListish(value);
const seen = new Set<string>();
const out: string[] = [];
for (const item of parts) {
const text = String(item ?? "")
.replace(/[\x00\r\n]/g, "")
.trim()
.slice(0, MAX_RETRIEVAL_SKILL_CHARS);
if (!text || EMPTY_LIST_TOKENS.has(text.toLowerCase()) || /\s/.test(text)) continue;
const key = text.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
out.push(text);
if (out.length >= MAX_RETRIEVAL_SKILLS) break;
}
return out;
}
const COLLECTOR_DIRECTION_INTRO =
"请按以下检索方向拆分检索词(这是拆词约束,不是检索词本身):每个方向拆成若干具体、可检索的检索词(含课题中的专名、时间、地点、同义/中英对照等),不要直接把方向名称拿去搜索——方向标签往往搜不到结果。覆盖列出的每一个方向;未列出的方面不必主动扩展。某个检索词没有结果时,换更具体或更短的词再试,仍不要改用未列出的方向。";
const COLLECTOR_SKILL_INTRO =
"请严格按照以下技能收集素材:先用 read_file 读取每个技能的 SKILL.md,再按其说明调用工具检索。不要改用未列出的技能,也不要自行改成纯 web_search(除非技能说明要求联网)。每个技能至少使用一轮。";
/** First collector turn: append direction / skill blocks only when they exist. */
export function composeCollectorQueryWithDirections(
query: string,
directions: unknown,
skills?: unknown,
): string {
let topic = query.trim();
if (!topic) return topic;
const cleanedDirections = normalizeRetrievalDirections(directions);
if (cleanedDirections.length > 0 && !topic.includes("<retrieval-directions>")) {
const lines = cleanedDirections.map((item, index) => `${index + 1}. ${item}`).join("\n");
topic = `${topic}\n\n<retrieval-directions>\n${COLLECTOR_DIRECTION_INTRO}\n${lines}\n</retrieval-directions>`;
}
const cleanedSkills = normalizeRetrievalSkills(skills);
if (cleanedSkills.length > 0 && !topic.includes("<retrieval-skills>")) {
const lines = cleanedSkills.map((item, index) => `${index + 1}. ${item}`).join("\n");
topic = `${topic}\n\n<retrieval-skills>\n${COLLECTOR_SKILL_INTRO}\n${lines}\n</retrieval-skills>`;
}
return topic;
}
export interface ReportStructure {
id: string;
/** 标题:卡片主标签,也是列表「标题筛选」的目标。 */
@@ -24,6 +146,12 @@ export interface ReportStructure {
name: string;
/** 接口内容:选中卡片后应用到报告结构(custom_outline)的 Markdown 文本。 */
content: string;
/** 结构约束:固定大纲逐章对照;弹性大纲沿用现有材料驱动写法。 */
structureMode: ReportStructureMode;
/** 检索方向:选中后收集员按这些方向拆分具体检索词;空则沿用收集员自行拆词。 */
retrievalDirections: string[];
/** 检索来源:选中后收集素材调用这些技能;空则沿用收集员默认工具。 */
retrievalSkills: string[];
enabled: boolean;
/** 创建用户名(本部署为邮箱;系统内置种子为“系统内置”)。 */
createdBy: string | null;
@@ -36,6 +164,9 @@ export type ReportStructureInput = {
title: string;
name: string;
content: string;
structureMode: ReportStructureMode;
retrievalDirections: string[];
retrievalSkills: string[];
};
export type ReportStructurePatch = Partial<ReportStructureInput>;
@@ -49,6 +180,9 @@ interface ServerStructure {
title: string;
name: string;
content: string;
structure_mode?: string | null;
retrieval_directions?: string[] | null;
retrieval_skills?: string[] | null;
enabled: boolean;
created_by: string | null;
created_at: string | null;
@@ -61,6 +195,9 @@ function toStructure(s: ServerStructure): ReportStructure {
title: s.title,
name: s.name ?? "",
content: s.content,
structureMode: normalizeReportStructureMode(s.structure_mode),
retrievalDirections: normalizeRetrievalDirections(s.retrieval_directions),
retrievalSkills: normalizeRetrievalSkills(s.retrieval_skills),
enabled: s.enabled ?? true,
createdBy: s.created_by ?? null,
createdAt: s.created_at ?? null,
@@ -99,7 +236,14 @@ export async function createReportStructure(input: ReportStructureInput): Promis
const res = await apiFetch(BASE(), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: input.title, name: input.name, content: input.content }),
body: JSON.stringify({
title: input.title,
name: input.name,
content: input.content,
structure_mode: normalizeReportStructureMode(input.structureMode),
retrieval_directions: normalizeRetrievalDirections(input.retrievalDirections),
retrieval_skills: normalizeRetrievalSkills(input.retrievalSkills),
}),
});
return toStructure(await readJson<ServerStructure>(res, "create report structure"));
}
@@ -112,6 +256,13 @@ export async function updateReportStructure(
if (patch.title !== undefined) body.title = patch.title;
if (patch.name !== undefined) body.name = patch.name;
if (patch.content !== undefined) body.content = patch.content;
if (patch.structureMode !== undefined) body.structure_mode = normalizeReportStructureMode(patch.structureMode);
if (patch.retrievalDirections !== undefined) {
body.retrieval_directions = normalizeRetrievalDirections(patch.retrievalDirections);
}
if (patch.retrievalSkills !== undefined) {
body.retrieval_skills = normalizeRetrievalSkills(patch.retrievalSkills);
}
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
@@ -10,6 +10,7 @@
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
AppWindow,
ArrowLeft,
ArrowRight,
ExternalLink,
@@ -257,6 +258,7 @@ function TaskSideJumpMenu({
function renderTaskJumpIcon(btn: TaskButton, className: string) {
if (btn.linkType === "url") return <ExternalLink className={className} />;
if (btn.linkType === "host") return <AppWindow className={className} />;
if (btn.linkType === "purpose") return <Maximize2 className={className} />;
return <ArrowRight className={className} />;
}
@@ -264,6 +266,7 @@ function renderTaskJumpIcon(btn: TaskButton, className: string) {
function renderRoundtableMenuIcon(btn: TaskButton) {
const className = "h-4 w-4";
if (btn.linkType === "purpose") return <Maximize2 className={className} />;
if (btn.linkType === "host") return <AppWindow className={className} />;
if (btn.linkType === "url") return <FileSearch className={className} />;
if (btn.target === "3qfx") return <Network className={className} />;
if (btn.target === "xdfx") return <Workflow className={className} />;
@@ -272,6 +275,12 @@ function renderRoundtableMenuIcon(btn: TaskButton) {
}
function getRoundtableMenuTone(btn: TaskButton) {
if (btn.linkType === "host") {
return {
item: "hover:text-indigo-700 dark:hover:text-indigo-100",
icon: "bg-indigo-50 text-indigo-600 dark:bg-indigo-400/10 dark:text-indigo-200",
};
}
if (btn.linkType === "url") {
return {
item: "hover:text-rose-700 dark:hover:text-rose-100",
+20
View File
@@ -76,6 +76,26 @@
@custom-variant dark (&:is(.dark *));
@layer utilities {
.report-structure-detail-scrollbar::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.report-structure-detail-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.report-structure-detail-scrollbar::-webkit-scrollbar-thumb {
background: #e2e8f0;
border-radius: 9999px;
}
.report-structure-detail-scrollbar::-webkit-scrollbar-thumb:hover {
background: #cbd5e1;
}
}
@theme {
--font-sans:
"Noto Sans S Chinese", "Noto Sans SC", "Source Han Sans SC", "思源黑体",