Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-code into master-zl
This commit is contained in:
commit
b3aa9f4bac
91 files changed
+6033
-986
No files matched your search
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 决定
|
||||
|
||||
@@ -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" &&
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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('深链跳转失败,已进入首页');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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", "思源黑体",
|
||||
|
||||
Reference in new issue
Block a user