diff --git a/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx b/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx index 3a11ded3..9af34f0b 100644 --- a/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx +++ b/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx @@ -19,8 +19,9 @@ import { useQueryClient } from "@tanstack/react-query"; import { useParams } from "next/navigation"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react"; -import { toast } from "sonner"; -import { Streamdown } from "streamdown"; +import { toast } from "sonner"; +import { defaultUrlTransform } from "react-markdown"; +import { Streamdown } from "streamdown"; import { Artifact, @@ -42,6 +43,7 @@ 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 } from "@/strategy-components/api/deep-research"; import { listDurableDocumentRewrites, streamDurableDocumentRewriteJob, @@ -149,9 +151,11 @@ export function ArtifactFileDetail({ onClose, allowVirtualMarkdownEdit = false, disableDesensitize = false, - hideFileName = false, - onDownloadClick, -}: { + hideFileName = false, + onDownloadClick, + toolbarActions, + researchSessionId, +}: { className?: string; filepath: string; threadId: string; @@ -177,8 +181,15 @@ export function ArtifactFileDetail({ * (深度研究沙箱用它弹「Markdown / Word」格式选择弹窗),并隐藏工具栏中重复的 * 「另存为 Word」。默认不传 —— 其它调用方行为不变。 */ - onDownloadClick?: () => void; -}) { + onDownloadClick?: () => void; + /** Deep-research reports use protected `deep-research://` image references. */ + researchSessionId?: string; + /** + * 由宿主注入到右上角 ArtifactActions 图标组的动作。深度研究报告使用它 + * 放置全文改写入口,避免再在沙箱内容区叠一个文字按钮。 + */ + toolbarActions?: ReactNode; +}) { const { t } = useI18n(); const { artifacts, setOpen, select } = useArtifacts(); const { isMock, thread, publicShareCode } = useThread(); @@ -291,13 +302,20 @@ export function ArtifactFileDetail({ const updateArtifact = useUpdateArtifactContent(); const queryClient = useQueryClient(); const documentRewrite = useDocumentRewrite(); + const imageCapabilities = useResearchCapabilities(); const rewritePresentation = useDocumentRewritePresentation(); const hydratedRewriteScopeRef = useRef(null); const mirroredRequirementsPlanRef = useRef<{ presentationId: string | null; plan: string }>({ presentationId: null, plan: "", }); + const mirroredThinkingTextRef = useRef<{ presentationId: string | null; thinking: string }>({ + presentationId: null, + thinking: "", + }); const documentRewriteActive = documentRewrite.isActive; + const imageGenerationAvailable = imageCapabilities.data?.imageGeneration?.available === true; + const maxGeneratedImages = imageCapabilities.data?.imageGeneration?.maxImages ?? 4; const canUseFullDocumentRewrite = canEditArtifact && !hasLocalEdits; const displayContent = documentRewriteActive ? documentRewrite.run.draft @@ -405,6 +423,29 @@ export function ArtifactFileDetail({ writtenCharCount: event.charCount, }); break; + case "illustration_started": + rewritePresentation.update(presentationId, { + stage: "illustrating", + illustrationTarget: event.section, + maxGeneratedImages: event.total || 1, + }); + break; + case "illustration_generated": + rewritePresentation.update(presentationId, { + stage: "illustrating", + illustrationTarget: event.section, + generatedImageCount: event.generatedCount ?? event.position, + maxGeneratedImages: event.total || 1, + }); + break; + case "illustration_completed": + rewritePresentation.update(presentationId, { + stage: "illustrating", + generatedImageCount: event.generatedCount, + maxGeneratedImages: event.requestedCount || 1, + warnings: event.warnings, + }); + break; case "validation_started": rewritePresentation.update(presentationId, { stage: "validation" }); break; @@ -463,6 +504,8 @@ export function ArtifactFileDetail({ instruction: historical.request.instruction, style: historical.request.style, modelName: historical.request.modelName, + generateImages: historical.request.generateImages, + maxGeneratedImages: historical.request.maxGeneratedImages, originalContent: historical.request.originalContent, rewrittenContent: typeof historicalResult.content === "string" ? historicalResult.content : null, requirementsPlan: typeof historicalResult.requirementsPlan === "string" @@ -497,6 +540,8 @@ export function ArtifactFileDetail({ instruction: latest.request.instruction, style: latest.request.style, modelName: latest.request.modelName, + generateImages: latest.request.generateImages, + maxGeneratedImages: latest.request.maxGeneratedImages, originalContent, rewrittenContent, requirementsPlan: typeof result.requirementsPlan === "string" ? result.requirementsPlan : "", @@ -537,6 +582,8 @@ export function ArtifactFileDetail({ instruction: latest.request.instruction, style: latest.request.style, modelName: latest.request.modelName ?? undefined, + generateImages: latest.request.generateImages, + maxGeneratedImages: latest.request.maxGeneratedImages, }, { jobId: latest.id, @@ -605,6 +652,19 @@ export function ArtifactFileDetail({ rewritePresentation, ]); + // Thoughts use the same rAF coalescing as the draft, then are mirrored once + // per browser frame into the regular message-list card. They never touch + // the artifact Markdown or the durable rewrite checkpoint. + useEffect(() => { + const presentationId = documentRewritePresentationId; + const thinking = documentRewrite.run.thinkingText; + if (!presentationId || !rewritePresentation || !thinking) return; + const previous = mirroredThinkingTextRef.current; + if (previous.presentationId === presentationId && previous.thinking === thinking) return; + mirroredThinkingTextRef.current = { presentationId, thinking }; + rewritePresentation.update(presentationId, { thinkingText: thinking }); + }, [documentRewrite.run.thinkingText, documentRewritePresentationId, rewritePresentation]); + useEffect(() => { const handleRewriteUndo = (event: Event) => { const detail = (event as CustomEvent<{ @@ -851,6 +911,8 @@ export function ArtifactFileDetail({ instruction: string; style: "formal_analysis" | "professional" | "concise" | "readable"; modelName: string | null; + generateImages: boolean; + maxGeneratedImages: number; }) => { if (!canUseFullDocumentRewrite) { toast.error( @@ -872,6 +934,8 @@ export function ArtifactFileDetail({ instruction: input.instruction, style: input.style, modelName: input.modelName, + generateImages: input.generateImages, + maxGeneratedImages: input.maxGeneratedImages, originalContent: draftContent, }); setDocumentRewritePresentationId(presentationId ?? null); @@ -882,6 +946,8 @@ export function ArtifactFileDetail({ instruction: input.instruction, style: input.style, modelName: input.modelName ?? undefined, + generateImages: input.generateImages, + maxGeneratedImages: input.maxGeneratedImages, expectedHash, }, { @@ -921,6 +987,29 @@ export function ArtifactFileDetail({ writtenCharCount: event.charCount, }); break; + case "illustration_started": + rewritePresentation.update(presentationId, { + stage: "illustrating", + illustrationTarget: event.section, + maxGeneratedImages: event.total || 1, + }); + break; + case "illustration_generated": + rewritePresentation.update(presentationId, { + stage: "illustrating", + illustrationTarget: event.section, + generatedImageCount: event.generatedCount ?? event.position, + maxGeneratedImages: event.total || 1, + }); + break; + case "illustration_completed": + rewritePresentation.update(presentationId, { + stage: "illustrating", + generatedImageCount: event.generatedCount, + maxGeneratedImages: event.requestedCount || 1, + warnings: event.warnings, + }); + break; case "validation_started": rewritePresentation.update(presentationId, { stage: "validation" }); break; @@ -1217,6 +1306,7 @@ export function ArtifactFileDetail({ onClick={handleSaveArtifact} /> )} + {toolbarActions} {canEditArtifact && ( )} @@ -1454,25 +1545,40 @@ export function ArtifactFileDetail({ onOpenChange={setDocumentRewriteDialogOpen} fileName={getFileName(filepath)} charCount={(displayContent ?? "").replace(/\s/g, "").length} + imageGenerationAvailable={imageGenerationAvailable} + maxGeneratedImages={maxGeneratedImages} onStart={handleStartDocumentRewrite} /> ); } -function SandboxPreviewImage({ - src, - alt, - threadId, - publicShareCode, - ...props -}: React.ImgHTMLAttributes & { - threadId: string; - publicShareCode?: string; -}) { - if (!src || typeof src !== "string") { - return null; - } +function SandboxPreviewImage({ + src, + alt, + threadId, + publicShareCode, + researchSessionId, + ...props +}: React.ImgHTMLAttributes & { + threadId: string; + publicShareCode?: string; + researchSessionId?: string; +}) { + if (!src || typeof src !== "string") { + return null; + } + + if (src.startsWith("deep-research://")) { + return researchSessionId ? ( + + ) : null; + } let url = src; if (src.startsWith("/mnt/")) { @@ -1496,21 +1602,108 @@ function SandboxPreviewImage({ src={url} alt={alt} /> - ); -} + ); +} + +/** Fetches a private deep-research image with the normal auth client. */ +function ProtectedResearchReportImage({ + sessionId, + src, + alt, + ...props +}: React.ImgHTMLAttributes & { + sessionId: string; +}) { + const [objectUrl, setObjectUrl] = useState(null); + const [loadFailed, setLoadFailed] = useState(false); + const [retryKey, setRetryKey] = useState(0); + + useEffect(() => { + setObjectUrl(null); + setLoadFailed(false); + if (!src?.startsWith("deep-research://")) { + return; + } + const name = src.slice("deep-research://".length); + if (!name) { + setLoadFailed(true); + return; + } + const controller = new AbortController(); + let nextUrl: string | null = null; + let disposed = false; + void (async () => { + try { + const response = await apiFetch( + `${getBackendBaseURL()}/api/deep-research/sessions/${encodeURIComponent(sessionId)}/artifacts/${encodeURIComponent(name)}`, + { method: "GET", signal: controller.signal }, + ); + if (!response.ok) { + throw new Error(`Unable to load report image (${response.status})`); + } + const blob = await response.blob(); + if (!blob.size) { + throw new Error("Report image is empty"); + } + nextUrl = URL.createObjectURL(blob); + if (disposed) return; + setObjectUrl(nextUrl); + } catch (error) { + if (!disposed && !(error instanceof DOMException && error.name === "AbortError")) { + // Never let an optional illustration break Markdown rendering, but don't + // leave the user with a misleading infinite loading label either. + setLoadFailed(true); + } + } + })(); + return () => { + disposed = true; + controller.abort(); + if (nextUrl) URL.revokeObjectURL(nextUrl); + }; + }, [retryKey, sessionId, src]); + + if (!objectUrl) { + if (loadFailed) { + return ( + + 报告配图加载失败 + + + ); + } + return 正在加载报告配图…; + } + return ( + {alt + ); +} export function ArtifactFilePreview({ content, language, - threadId, - publicShareCode, - animated = false, + threadId, + publicShareCode, + researchSessionId, + animated = false, activeCursor = false, }: { content: string; language: string; - threadId: string; - publicShareCode?: string; + threadId: string; + publicShareCode?: string; + researchSessionId?: string; animated?: boolean; activeCursor?: boolean; }) { @@ -1574,7 +1767,7 @@ export function ArtifactFilePreview({ ); }, }; - }, [content]); + }, [content, publicShareCode, researchSessionId, threadId]); useEffect(() => { if (language !== "html") { @@ -1639,16 +1832,26 @@ export function ArtifactFilePreview({ animated && "animate-[sandbox-preview-enter_320ms_ease-out]", )} > - + url.startsWith("deep-research://") + ? url + : defaultUrlTransform(url) + } + components={{ ...markdownComponents, img: (props) => ( ), }} diff --git a/frontend-web/src/components/workspace/artifacts/document-rewrite-dialog.tsx b/frontend-web/src/components/workspace/artifacts/document-rewrite-dialog.tsx index 765ceca1..ee696af3 100644 --- a/frontend-web/src/components/workspace/artifacts/document-rewrite-dialog.tsx +++ b/frontend-web/src/components/workspace/artifacts/document-rewrite-dialog.tsx @@ -1,6 +1,6 @@ "use client"; -import { SparklesIcon } from "lucide-react"; +import { ImagePlusIcon, SparklesIcon } from "lucide-react"; import { useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; @@ -20,6 +20,7 @@ import { SelectValue, } from "@/components/ui/select"; 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"; @@ -51,16 +52,22 @@ export function DocumentRewriteDialog({ onOpenChange, fileName, charCount, + imageGenerationAvailable = false, + maxGeneratedImages = 4, onStart, }: { open: boolean; onOpenChange: (open: boolean) => void; fileName: string; charCount: number; + imageGenerationAvailable?: boolean; + maxGeneratedImages?: number; onStart: (input: { instruction: string; style: DocumentRewriteStyle; modelName: string | null; + generateImages: boolean; + maxGeneratedImages: number; }) => void; }) { const [instruction, setInstruction] = useState( @@ -68,11 +75,15 @@ export function DocumentRewriteDialog({ ); const [style, setStyle] = useState("formal_analysis"); const [modelName, setModelName] = useState(null); + const [generateImages, setGenerateImages] = useState(false); + const [imageCount, setImageCount] = useState("2"); useEffect(() => { if (!open) return; setStyle("formal_analysis"); setModelName(null); + setGenerateImages(false); + setImageCount("2"); }, [open]); const selectedStyle = STYLE_OPTIONS.find((item) => item.value === style); @@ -136,6 +147,48 @@ export function DocumentRewriteDialog({ + {imageGenerationAvailable && ( +
+
+ + +
+ {generateImages && ( +
+ 每次会额外消耗图片模型额度 + +
+ )} +
+ )} + + + +

+ {active ? "模型思考内容正在更新" : "模型思考内容已完成"} +

+
+          {text}
+        
+
+ + ); +} + export function DocumentRewriteTimeline({ item, }: { @@ -64,6 +110,9 @@ export function DocumentRewriteTimeline({ const current = stepPosition(item.stage); const failed = ["conflict", "error", "cancelled"].includes(item.stage); const completed = item.stage === "completed"; + const thinkingStep = ["snapshot", "requirements"].includes(item.stage) + ? "requirements" + : "writing"; const presentation = useDocumentRewritePresentation(); const [undoing, setUndoing] = useState(false); const [diffOpen, setDiffOpen] = useState(false); @@ -148,7 +197,13 @@ export function DocumentRewriteTimeline({
{STEPS.map((step, index) => { const active = !isTerminal(item.stage) && index === current; - const done = completed || index < current; + const illustrationSkipped = step.key === "illustrations" && !item.generateImages; + const illustrationFailed = + step.key === "illustrations" && + item.generateImages && + completed && + item.generatedImageCount === 0; + const done = !illustrationSkipped && !illustrationFailed && (completed || index < current); return (
)} + {step.key === "illustrations" && ( + illustrationSkipped ? ( +

+ 本次未启用配图,保留原文已有图片与链接。 +

+ ) : active ? ( +

+ {item.illustrationTarget + ? `正在为「${item.illustrationTarget}」生成配图,已完成 ${item.generatedImageCount}/${item.maxGeneratedImages} 张。` + : `正在准备 ${item.maxGeneratedImages} 张文档配图…`} +

+ ) : item.generatedImageCount > 0 ? ( +

+ 已生成并保存 {item.generatedImageCount} 张本地配图。 +

+ ) : ( +

+ 未成功生成配图,文字改写结果仍已保留。 +

+ ) + )} + {step.key === thinkingStep && ( + + )} {step.key === "validation" && item.warnings.length > 0 && (

{item.warnings.join(";")} @@ -228,6 +307,11 @@ export function DocumentRewriteTimeline({ 本次优化:{item.summary.requestedOptimizations.join("、")}。

) : null} + {item.summary.requestedImageCount ? ( +

+ 文档配图:请求 {item.summary.requestedImageCount} 张,已保存 {item.summary.generatedImageCount ?? 0} 张本地图片。 +

+ ) : null} {item.summary.headingChanges && ( item.summary.headingChanges.reordered || item.summary.headingChanges.added.length > 0 || diff --git a/frontend-web/src/components/workspace/messages/message-group.tsx b/frontend-web/src/components/workspace/messages/message-group.tsx index bae97491..634deb28 100644 --- a/frontend-web/src/components/workspace/messages/message-group.tsx +++ b/frontend-web/src/components/workspace/messages/message-group.tsx @@ -523,7 +523,13 @@ function DeepResearchProgressStep({ ? args.label : "正在撰写研究报告"; const detail = typeof args.detail === "string" ? args.detail.trim() : ""; + const thinking = typeof args.thinking === "string" ? args.thinking.trim() : ""; const active = args.status !== "complete"; + const [thinkingOpen, setThinkingOpen] = useState(false); + + useEffect(() => { + if (active && thinking) setThinkingOpen(true); + }, [active, thinking]); return ( )} + {thinking && ( + + + + + +

+ {active ? "模型思考内容正在更新" : "模型思考内容已完成"} +

+
+              {thinking}
+            
+
+
+ )}
); } diff --git a/frontend-web/src/core/document-rewrite/context.tsx b/frontend-web/src/core/document-rewrite/context.tsx index 638a3df5..1e7dbcbb 100644 --- a/frontend-web/src/core/document-rewrite/context.tsx +++ b/frontend-web/src/core/document-rewrite/context.tsx @@ -20,11 +20,17 @@ export type DocumentRewritePresentation = { instruction: string; style: string; modelName: string | null; + generateImages: boolean; + maxGeneratedImages: number; + generatedImageCount: number; + illustrationTarget: string; stage: DocumentRewriteStage; originalCharCount: number; writtenCharCount: number; /** Short model-generated plan, streamed while it understands the request. */ requirementsPlan: string; + /** Ephemeral thought trace. Never included in the Markdown draft or persisted job state. */ + thinkingText: string; warnings: string[]; summary: DocumentRewriteSummary | null; /** Kept in-memory for the line-level compare dialog; backend job hydration refills it. */ @@ -43,9 +49,14 @@ type CreatePresentationInput = Omit< DocumentRewritePresentation, | "id" | "stage" + | "generateImages" + | "maxGeneratedImages" + | "generatedImageCount" + | "illustrationTarget" | "originalCharCount" | "writtenCharCount" | "requirementsPlan" + | "thinkingText" | "warnings" | "summary" | "originalContent" @@ -60,6 +71,8 @@ type CreatePresentationInput = Omit< originalContent?: string; rewrittenContent?: string | null; requirementsPlan?: string; + generateImages?: boolean; + maxGeneratedImages?: number; }; type DocumentRewritePresentationContextValue = { @@ -94,9 +107,14 @@ export function DocumentRewritePresentationProvider({ ...input, id, stage: "snapshot", + generateImages: input.generateImages === true, + maxGeneratedImages: Math.min(Math.max(input.maxGeneratedImages ?? 2, 1), 4), + generatedImageCount: 0, + illustrationTarget: "", originalCharCount: 0, writtenCharCount: 0, requirementsPlan: input.requirementsPlan ?? "", + thinkingText: "", warnings: [], summary: null, originalContent: input.originalContent ?? "", diff --git a/frontend-web/src/core/streamdown/inline-think.ts b/frontend-web/src/core/streamdown/inline-think.ts new file mode 100644 index 00000000..b8bdc27e --- /dev/null +++ b/frontend-web/src/core/streamdown/inline-think.ts @@ -0,0 +1,90 @@ +/** + * Some OpenAI-compatible reasoning models write ``...`` into + * the ordinary text stream rather than a dedicated reasoning field. Markdown + * sandboxes must never render those fragments as document content. Callers + * can separately render the returned thinking part in a labelled UI trace. + * + * The filter is stateful because either tag can be split across arbitrary SSE + * deltas. Hold only a possible tag suffix, so confirmed Markdown continues + * to appear with the normal token-streaming cadence. + */ +export class InlineThinkTagFilter { + private static readonly OPEN_TAG = ""; + private static readonly CLOSE_TAG = ""; + + private insideThink = false; + private pending = ""; + + private possibleTagPrefixSize(content: string, tag: string) { + const lowered = content.toLowerCase(); + for (let size = Math.min(content.length, tag.length - 1); size > 0; size -= 1) { + if (lowered.slice(-size) === tag.slice(0, size)) return size; + } + return 0; + } + + pushParts(chunk: string): { text: string; thinking: string } { + if (!chunk) return { text: "", thinking: "" }; + + let remaining = this.pending + chunk; + this.pending = ""; + let visible = ""; + let thinking = ""; + + while (remaining) { + const tag = this.insideThink + ? InlineThinkTagFilter.CLOSE_TAG + : InlineThinkTagFilter.OPEN_TAG; + const tagIndex = remaining.toLowerCase().indexOf(tag); + if (tagIndex >= 0) { + if (this.insideThink) thinking += remaining.slice(0, tagIndex); + else visible += remaining.slice(0, tagIndex); + remaining = remaining.slice(tagIndex + tag.length); + this.insideThink = !this.insideThink; + continue; + } + + const suffixSize = this.possibleTagPrefixSize(remaining, tag); + const confirmed = suffixSize ? remaining.slice(0, -suffixSize) : remaining; + if (this.insideThink) thinking += confirmed; + else visible += confirmed; + this.pending = suffixSize ? remaining.slice(-suffixSize) : ""; + break; + } + + return { text: visible, thinking }; + } + + push(chunk: string) { + return this.pushParts(chunk).text; + } + + /** Flush a completed stream into its visible/thinking channels. */ + finishParts(): { text: string; thinking: string } { + if (this.insideThink) { + const thinking = this.pending; + this.insideThink = false; + this.pending = ""; + return { text: "", thinking }; + } + const tail = this.pending; + this.pending = ""; + return { text: tail, thinking: "" }; + } + + finish() { + return this.finishParts().text; + } + + /** Replace a rendered checkpoint while retaining the correct tag state. */ + replace(content: string) { + this.insideThink = false; + this.pending = ""; + return this.push(content); + } +} + +export function stripInlineThinkTags(content: string) { + const filter = new InlineThinkTagFilter(); + return filter.push(content) + filter.finish(); +} diff --git a/frontend-web/src/eightbf-roundtable/api/task-context.ts b/frontend-web/src/eightbf-roundtable/api/task-context.ts index 2d7021a6..280414a0 100644 --- a/frontend-web/src/eightbf-roundtable/api/task-context.ts +++ b/frontend-web/src/eightbf-roundtable/api/task-context.ts @@ -51,6 +51,27 @@ export interface EightBfTaskContextPayload { }; } +/** + * 外部任务系统在离线部署或本地联调时可能不可用。此回退仅用于页面展示与 + * 意图上下文,保持与岗位会商示例一致,避免左上任务卡退化成接口错误文本。 + */ +export function createEightBfFallbackTask(taskId: string): EightBfTask { + return { + id: taskId, + externalTaskId: taskId, + title: "暑期活动", + description: "围绕暑期活动的游泳夏令营开展宣传,结合线上线下渠道吸引目标人群参与,持续提升活动影响力。", + direction: "暑期活动", + overview: "面向家长群体传播暑期游泳夏令营活动信息,提升品牌曝光与触达声量。", + priority: "urgent", + taskPriority: "urgent", + targetDirection: "summer_activity", + taskStatus: "system_created", + source: "本系统创建", + raw: null, + }; +} + const TASK_DETAIL_PATH = "/task/{taskId}"; const DICTIONARY_TYPES = { priority: "task_priority", diff --git a/frontend-web/src/eightbf-roundtable/components/EightBfStep1Workspace.tsx b/frontend-web/src/eightbf-roundtable/components/EightBfStep1Workspace.tsx new file mode 100644 index 00000000..ec06e6f9 --- /dev/null +++ b/frontend-web/src/eightbf-roundtable/components/EightBfStep1Workspace.tsx @@ -0,0 +1,451 @@ +import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { + Bot, + ChevronDown, + ChevronLeft, + Loader2, + Search, + Settings2, + UserRoundCog, +} from "lucide-react"; +import { toast } from "sonner"; + +import { AgentDetailPanel } from "@/components/workspace/agents/agent-detail-panel"; +import { useAgent, useAgents, type Agent } from "@/core/agents"; +import agentConfigLogo from "@/assets/position-roundtable/agent-config-logo.svg"; +import collapsePanelIcon from "@/assets/position-roundtable/collapse-panel.svg"; +import doubleArrowDecoration from "@/assets/position-roundtable/double-arrow-decoration.svg"; +import { PositionIntentInput } from "@/position-roundtable/components/PositionIntentInput"; +import "@/position-roundtable/styles/intent-understanding.css"; +import type { Step1PanelProps } from "@/roundtable-planning/components/Step1Panel"; +import { Step1Panel } from "@/roundtable-planning/components/Step1Panel"; +import type { BusinessChain, ChainSeat } from "@/roundtable-planning/lib/business-chain"; +import { cn } from "@/lib/utils"; + +type IntentRecord = Record; + +export interface EightBfStep1PanelProps extends Step1PanelProps { + chain: BusinessChain | null; + canConfigureChain: boolean; + onConfigureChain: () => void; + selectedModel: string; + modelOptions: Array<{ label: string; value: string }>; + onSelectedModelChange: (model: string) => void; +} + +export function EightBfStep1Panel({ + chain, + canConfigureChain, + onConfigureChain, + selectedModel, + modelOptions, + onSelectedModelChange, + intentReady, + ...step1Props +}: EightBfStep1PanelProps) { + const [rightPanelOpen, setRightPanelOpen] = useState(true); + const [rightTab, setRightTab] = useState<"agents" | "results">("agents"); + + const openPanel = (tab: "agents" | "results") => { + if (rightPanelOpen && rightTab === tab) { + setRightPanelOpen(false); + return; + } + setRightTab(tab); + setRightPanelOpen(true); + }; + + return ( +
+ +
+ } + headerRightSlot={ +
+ + +
+ } + rightSidebar={ + + } + hideQuestionCount + inputArea={(inputProps) => ( + + )} + intentReady={intentReady} + /> +
+ ); +} + +export function EightBfStep1Sidebar({ + taskCard, + businessChain, + intentReady, +}: { + taskCard?: ReactNode; + businessChain?: ReactNode; + intentReady: object | null; +}) { + return ( +
+ {taskCard} + +
+ ); +} + +/** 第一、二步共用的意图结果面板,保持完全相同的展示结构和样式。 */ +export function EightBfIntentDelivery({ + intentReady, + businessChain, +}: { + intentReady: object | null; + businessChain?: ReactNode; +}) { + const sections = intentSections(intentReady); + return ( +
+ {sections.length > 0 && ( +
+
+
+
+ {sections.map((section) => ( +
+

{section.label}

+
    + {section.items.map((item) =>
  • {item}
  • )} +
+
+ ))} +
+
+ )} + {businessChain} +
+ ); +} + +/** 第二步不需要第一步的整列占位,只复用其任务卡和意图面板。 */ +export function EightBfStep2SidebarHeader({ + taskCard, + intentReady, +}: { + taskCard?: ReactNode; + intentReady: object | null; +}) { + return ( +
+ {taskCard} + +
+ ); +} + +export function EightBfHeaderBrand({ chainTitle }: { chainTitle?: string }) { + return ( +
+ +
+

8BF 多智能体会商

+

按业务链承接任务、对话与分析产物

+
+ + + {chainTitle ? "业务链已启动" : "等待业务链配置"} + +
+ ); +} + +export function EightBfHeaderChain({ chainTitle }: { chainTitle?: string }) { + return ( + + 当前业务链 + {chainTitle || "暂未配置"} + + ); +} + +function EightBfAgentWorkspace({ + tab, + chain, + intentReady, + canConfigureChain, + onConfigureChain, + onCollapse, +}: { + tab: "agents" | "results"; + chain: BusinessChain | null; + intentReady: object | null; + canConfigureChain: boolean; + onConfigureChain: () => void; + onCollapse: () => void; +}) { + const { agents, isLoading } = useAgents(); + const seats = chain?.seats ?? []; + const [selectedAgentId, setSelectedAgentId] = useState(null); + const { agent: selectedAgent, isLoading: selectedAgentLoading } = useAgent(selectedAgentId); + + useEffect(() => { + if (selectedAgentId && !seats.some((seat) => seat.agent_id === selectedAgentId)) { + setSelectedAgentId(null); + } + }, [seats, selectedAgentId]); + + const seatAgents = useMemo(() => { + const agentById = new Map(agents.map((agent) => [agent.id, agent])); + return seats.map((seat) => ({ seat, agent: agentById.get(seat.agent_id) ?? null })); + }, [agents, seats]); + const selectedSeat = seats.find((seat) => seat.agent_id === selectedAgentId) ?? null; + + if (tab === "results") { + return ( +
+
+ 分析结果 + +
+
+ +
+
+ ); + } + + if (selectedAgentId) { + return ( +
+
+ + +
+
+ {selectedAgentLoading ? ( +
+ 正在读取智能体配置… +
+ ) : ( + toast.info("请在中间对话区向任务理解智能体发起问题")} + showRecentChats={false} + avatarSrc={agentConfigLogo} + positionName={chain?.title || "8BF 业务链"} + skillsIcon={} + skillsTitle="技能列表" + showCommonQuestions + panelTitle="智能体配置" + embedded + hideStatusBadges + /> + )} +
+
+ ); + } + + return ( +
+
+
+

智能体配置

+

{chain?.title ? `业务链「${chain.title}」已配置的智能体` : "请先配置 8BF 业务链"}

+
+
+ {canConfigureChain && ( + + )} + +
+
+
+ + 业务链智能体 + {seatAgents.length} 个 +
+
+ {isLoading && seats.length > 0 ? ( +
正在加载智能体…
+ ) : seats.length === 0 ? ( +
当前业务链未配置智能体
+ ) : ( + seatAgents.map(({ seat, agent }, index) => ( + setSelectedAgentId(seat.agent_id)} + /> + )) + )} +
+
+ ); +} + +function EightBfAgentRow({ + seat, + agent, + index, + onSelect, +}: { + seat: ChainSeat; + agent: Agent | null; + index: number; + onSelect: () => void; +}) { + const name = agent?.name || seat.name || "未命名智能体"; + const description = agent?.description || seat.description || "查看该智能体的模型、技能与常用问题配置"; + return ( + + ); +} + +function EightBfIntentResults({ intentReady }: { intentReady: object | null }) { + const sections = intentSections(intentReady); + if (sections.length === 0) { + return

完成意图识别后,将在这里展示任务意图与关键约束。

; + } + return ( +
+ {sections.map((section) => ( +
+

{section.label}

+
    {section.items.map((item) =>
  • {item}
  • )}
+
+ ))} +
+ ); +} + +function intentSections(intentReady: object | null): Array<{ label: string; items: string[] }> { + const source = intentReady && typeof intentReady === "object" ? intentReady as IntentRecord : null; + if (!source) return []; + const entries = [ + ["核心目标", arrayValue(source.coreGoals, source.objective)], + ["风险提示", arrayValue(source.riskWarnings, source.constraints)], + ["关键要点", arrayValue(source.keyPoints, source.assumptions)], + ["战略意义", arrayValue(source.strategicSignificance)], + ] as const; + return entries + .filter(([, items]) => items.length > 0) + .map(([label, items]) => ({ label, items })); +} + +function intentSectionTone(label: string) { + switch (label) { + case "核心目标": + return "position-roundtable-intent-delivery-section--goals"; + case "风险提示": + return "position-roundtable-intent-delivery-section--constraints"; + case "关键要点": + return "position-roundtable-intent-delivery-section--purpose"; + case "战略意义": + return "position-roundtable-intent-delivery-section--significance"; + default: + return "position-roundtable-intent-delivery-section--significance"; + } +} + +function arrayValue(...values: unknown[]): string[] { + for (const value of values) { + if (Array.isArray(value)) { + const items = value.map((item) => String(item).trim()).filter(Boolean); + if (items.length > 0) return items; + } + if (typeof value === "string" && value.trim()) return [value.trim()]; + } + return []; +} diff --git a/frontend-web/src/eightbf-roundtable/components/EightBfTaskCard.tsx b/frontend-web/src/eightbf-roundtable/components/EightBfTaskCard.tsx index 48e70eb5..f3d0a09f 100644 --- a/frontend-web/src/eightbf-roundtable/components/EightBfTaskCard.tsx +++ b/frontend-web/src/eightbf-roundtable/components/EightBfTaskCard.tsx @@ -1,6 +1,5 @@ -import { Clipboard, Loader2 } from "lucide-react"; - import { cn } from "@/lib/utils"; +import collapsePanelIcon from "@/assets/position-roundtable/collapse-panel.svg"; import { dictionaryText, type EightBfDictionaryOption, @@ -16,6 +15,8 @@ interface EightBfTaskCardProps { }; loading?: boolean; error?: string | null; + /** 第二步左栏使用,与“智能体配置”同款图标收起整列。 */ + onCollapse?: () => void; } export function EightBfTaskCard({ @@ -23,36 +24,74 @@ export function EightBfTaskCard({ dictionaries, loading = false, error, + onCollapse, }: EightBfTaskCardProps) { const priority = dictionaryText(dictionaries.priority, task?.taskPriority); const direction = dictionaryText(dictionaries.direction, task?.targetDirection, "alias"); const status = dictionaryText(dictionaries.status, task?.taskStatus); - const tags = [priority, direction, status].filter(Boolean); + // 外部任务接口不可用时,卡片使用与岗位会商相同的示例任务;标签也必须 + // 保持可读,而不能暴露内部状态码。 + const tags = task?.raw === null + ? ["紧急", "本系统创建", "暑期活动"] + : [priority, status, direction].filter(Boolean); return ( -
-
- -

当前任务

- {loading && } -
+
+
+ {onCollapse && ( + + )} +

+ {task?.title || (task?.id ? `任务 ${task.id}` : "等待任务信息")} +

-
-

- {task?.title || (task?.id ? `任务 ${task.id}` : "等待任务信息")} -

{tags.length > 0 && ( -
- {tags.map((tag) => {tag})} +
+ {tags.map((tag, index) => ( + 1 && "border-[#a2e1f9] bg-[rgba(162,225,249,0.3)] leading-[18px] text-[#a2e1f9]", + ].filter(Boolean).join(" ")} + > + {tag} + + ))}
)} -

+

{task?.description || task?.overview || (loading ? "正在读取任务详情…" : "暂无任务描述")}

{error &&

{error}

} -
+
); } diff --git a/frontend-web/src/eightbf-roundtable/pages/EightBfRoundtablePage.tsx b/frontend-web/src/eightbf-roundtable/pages/EightBfRoundtablePage.tsx index 1696ea51..e66a0331 100644 --- a/frontend-web/src/eightbf-roundtable/pages/EightBfRoundtablePage.tsx +++ b/frontend-web/src/eightbf-roundtable/pages/EightBfRoundtablePage.tsx @@ -1,11 +1,17 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useSearchParams } from "react-router-dom"; -import { Loader2, Save, Settings2, X } from "lucide-react"; +import { Loader2, Save, X } from "lucide-react"; import { getAccountDisplayName, getStoredAuth } from "@/core/auth"; import { EightBfTaskCard } from "@/eightbf-roundtable/components/EightBfTaskCard"; +import { + EightBfStep1Panel, + EightBfStep1Sidebar, + EightBfStep2SidebarHeader, +} from "@/eightbf-roundtable/components/EightBfStep1Workspace"; import { buildEightBfTaskContext, + createEightBfFallbackTask, fetchEightBfTask, fetchEightBfTaskDictionaries, normalizeEightBfTask, @@ -100,7 +106,9 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa }) .catch((error) => { if (cancelled) return; - setTaskError(error instanceof Error ? error.message : "任务详情加载失败"); + console.warn("[8bf] 任务详情请求失败,使用页面示例任务", error); + setTask(createEightBfFallbackTask(taskId)); + setTaskError(null); }) .finally(markRequestFinished); void fetchEightBfTaskDictionaries() @@ -169,18 +177,12 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa } }, [canConfigureChain, chains, selectedChainId]); + const openChainSettings = useCallback(() => { + if (canConfigureChain) setIsSettingsOpen(true); + }, [canConfigureChain]); + const toolbarSlot = ( <> - {canConfigureChain && ( - - )} {canConfigureChain && isSettingsOpen && (
) : undefined} toolbarSlot={toolbarSlot} + hideHeaderSaveDraft + hideHeaderSettings + newTaskLabel="新建会话" + hideDefaultStep1RightSidebar + renderStep1={(props) => ( + + )} + renderStep1LeftSidebar={({ intentReady }) => ( + + ) : undefined} + businessChain={ + + } + /> + )} + renderStep2LeftSidebarHeader={({ intentReady, onCollapse }) => ( + + ) : undefined} + /> + )} entryRailBusinessChainSlot={ svg:last-child { - margin-left: auto; - color: #0f766e; -} - -.eightbf-roundtable .eightbf-roundtable__task-card-icon { - display: inline-flex; - align-items: center; - justify-content: center; - width: 22px; - height: 22px; - border-radius: 7px; - background: #0f766e; - color: #fff; -} - -.eightbf-roundtable .eightbf-roundtable__task-card-body { - min-width: 0; - margin-top: 11px; -} - -.eightbf-roundtable .eightbf-roundtable__task-card-title { - margin: 0; - color: #0f172a; - font-size: 14px; - font-weight: 700; - line-height: 1.55; - overflow-wrap: anywhere; } .eightbf-roundtable .eightbf-roundtable__task-card-tags { - display: flex; - flex-wrap: wrap; - gap: 5px; - margin-top: 8px; + margin-top: 12px; } .eightbf-roundtable .eightbf-roundtable__task-card-tags span { - border: 1px solid #c7e6df; - border-radius: 999px; - background: #ecfdf5; - color: #0f766e; - padding: 2px 7px; - font-size: 10px; - line-height: 1.4; + border-radius: 2px; } .eightbf-roundtable .eightbf-roundtable__task-card-description { - display: -webkit-box; - margin: 9px 0 0; - color: #475569; - font-size: 11px; - line-height: 1.65; - overflow: hidden; - -webkit-box-orient: vertical; - -webkit-line-clamp: 5; - overflow-wrap: anywhere; - white-space: pre-wrap; + margin: 12px 0 0; } .eightbf-roundtable .eightbf-roundtable__task-card-description.is-empty { color: #94a3b8; } -.eightbf-roundtable .eightbf-roundtable__task-card-error { margin: 8px 0 0; color: #b91c1c; font-size: 10px; line-height: 1.5; } +.eightbf-roundtable .eightbf-roundtable__task-card-error { margin: 8px 0 0; color: #fca5a5; font-size: 12px; line-height: 1.5; } -.dark .eightbf-roundtable .eightbf-roundtable__task-card { - border-color: rgb(92 156 219 / 45%); - background: linear-gradient(145deg, #0b315d 0%, #08264b 100%); - box-shadow: 0 8px 24px rgb(0 0 0 / 18%); +.dark .eightbf-roundtable .eightbf-roundtable__task-card-description.is-empty { color: #8fa8c5; } + +/* + * 8BF 第一阶段直接采用「岗位多智能体会商」意图工作台的三栏尺寸和深蓝表面。 + * 专属类只改变 8BF;岗位会商原有页面继续由 intent-understanding.css 管理。 + */ +.eightbf-roundtable .eightbf-roundtable__header-brand { + display: flex; + min-width: 0; + flex: 1; + align-items: center; + gap: 12px; } -.dark .eightbf-roundtable .eightbf-roundtable__task-card-heading, -.dark .eightbf-roundtable .eightbf-roundtable__task-card-title { color: #f1f7ff; } -.dark .eightbf-roundtable .eightbf-roundtable__task-card-heading > svg:last-child { color: #65d6c6; } -.dark .eightbf-roundtable .eightbf-roundtable__task-card-tags span { border-color: rgb(101 214 198 / 40%); background: rgb(15 118 110 / 24%); color: #9ef0df; } -.dark .eightbf-roundtable .eightbf-roundtable__task-card-description { color: #c3d4e8; } -.dark .eightbf-roundtable .eightbf-roundtable__task-card-description.is-empty { color: #8fa8c5; } -.dark .eightbf-roundtable .eightbf-roundtable__task-card-error { color: #fca5a5; } +.eightbf-roundtable .eightbf-roundtable__header-brand-icon { + display: inline-flex; + width: 42px; + height: 42px; + flex: 0 0 auto; + align-items: center; + justify-content: center; + border-radius: 14px; + background: rgb(43 143 254 / 16%); + color: #2b8ffe; +} -/* 可在此继续覆盖第 1 步的任务理解面板与业务链流程图样式。 */ +.eightbf-roundtable .eightbf-roundtable__header-brand-icon svg { width: 23px; height: 23px; } +.eightbf-roundtable .eightbf-roundtable__header-brand h1 { margin: 0; color: #1e293b; font-size: 16px; font-weight: 700; } +.eightbf-roundtable .eightbf-roundtable__header-brand p { margin: 2px 0 0; color: #64748b; font-size: 12px; } +.eightbf-roundtable .eightbf-roundtable__header-status { + display: inline-flex; + align-items: center; + gap: 5px; + margin-left: 8px; + border: 1px solid rgb(16 185 129 / 30%); + border-radius: 999px; + background: rgb(16 185 129 / 8%); + color: #047857; + padding: 5px 10px; + font-size: 12px; + font-weight: 700; +} + +.eightbf-roundtable .eightbf-roundtable__header-chain { + display: inline-flex; + min-width: 230px; + max-width: 360px; + align-items: center; + gap: 8px; + border: 1px solid #cbd5e1; + border-radius: 6px; + background: #fff; + color: #64748b; + padding: 8px 10px; + font-size: 12px; +} +.eightbf-roundtable .eightbf-roundtable__header-chain strong { min-width: 0; flex: 1; overflow: hidden; color: #334155; font-size: 13px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; } +.eightbf-roundtable .eightbf-roundtable__header-chain svg { width: 16px; height: 16px; flex: 0 0 auto; } + +/* 参考工作台的直角圆角层级:内容面、消息区与输入框均为 4px。 */ +.eightbf-roundtable #middle-viewport { border-radius: 4px; } +.eightbf-roundtable .position-roundtable-intent-panel > div:first-child, +.eightbf-roundtable .position-roundtable-intent-composer-frame, +.eightbf-roundtable .position-roundtable-intent-config-pane { border-radius: 4px; } +.eightbf-roundtable .position-roundtable-intent-panel > div:first-child { border-radius: 4px 4px 0 0; } + +.eightbf-roundtable .eightbf-roundtable__step1-sidebar { + display: flex; + min-height: 0; + flex: 1; + flex-direction: column; + gap: 12px; + overflow: hidden; + padding-right: 0; +} + +/* 8BF 首步左栏不允许横向滚动;流程图的横向超出由画布自身裁切。 */ +.eightbf-roundtable[data-roundtable-step="1"] #left-sidebar, +.eightbf-roundtable .eightbf-roundtable__step1-sidebar, +.eightbf-roundtable .eightbf-roundtable__intent-delivery-sidebar { + overflow-x: hidden !important; +} + +.eightbf-roundtable[data-roundtable-step="1"] #left-sidebar::-webkit-scrollbar:horizontal, +.eightbf-roundtable .eightbf-roundtable__step1-sidebar::-webkit-scrollbar:horizontal, +.eightbf-roundtable .eightbf-roundtable__intent-delivery-sidebar::-webkit-scrollbar:horizontal { + display: none; + height: 0; +} + +.eightbf-roundtable .eightbf-roundtable__intent-delivery-sidebar { + min-height: 0; + overflow-y: auto; +} + +.eightbf-roundtable .eightbf-roundtable__intent-sidebar-empty { + margin: 0; + padding: 12px 16px 18px; + color: #aaceff; + font-size: 14px; + line-height: 22px; +} + +/* The flow is embedded into the intent surface: only its own header line and + dotted canvas are visible. The surrounding intent panel owns the border. */ +.eightbf-roundtable .position-roundtable-intent-delivery-flow { + /* Reference flow has horizontal gutters. Reduce the width by those gutters + so the right-side magnifier is never clipped by the left-rail crop. */ + width: calc(100% - 24px) !important; + height: 580px !important; + min-height: 580px !important; + flex-basis: 580px !important; +} +.eightbf-roundtable .position-roundtable-intent-delivery-flow > div:first-child { + margin: 0 0 12px !important; +} + +.eightbf-roundtable .eightbf-roundtable__agent-list-workspace { + display: flex; + height: 100%; + min-height: 0; + flex-direction: column; + background: #032650; +} +.eightbf-roundtable .eightbf-roundtable__agent-list-workspace > header { + display: flex; + min-height: 64px; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 10px 12px; + border-bottom: 1px solid rgb(43 143 254 / 35%); +} +.eightbf-roundtable .eightbf-roundtable__agent-list-workspace h3 { margin: 0; color: #fff; font-size: 17px; font-weight: 500; } +.eightbf-roundtable .eightbf-roundtable__agent-list-workspace p { margin: 4px 0 0; color: #aaceff; font-size: 11px; line-height: 1.45; } +.eightbf-roundtable .eightbf-roundtable__configure-chain-button { + display: inline-flex; + height: 28px; + align-items: center; + gap: 4px; + border: 1px solid rgb(115 200 255 / 52%); + border-radius: 4px; + background: rgb(0 98 217 / 22%); + color: #dceaff; + padding: 0 8px; + font-size: 11px; +} +.eightbf-roundtable .eightbf-roundtable__configure-chain-button:hover { background: rgb(0 98 217 / 40%); } +.eightbf-roundtable .eightbf-roundtable__agent-list-search { + display: flex; + align-items: center; + gap: 7px; + margin: 12px; + border: 1px solid rgb(43 143 254 / 36%); + border-radius: 4px; + background: rgb(0 12 28 / 20%); + color: #aaceff; + padding: 8px 9px; + font-size: 12px; +} +.eightbf-roundtable .eightbf-roundtable__agent-list-search span:last-child { margin-left: auto; border-radius: 999px; background: #0b386d; padding: 1px 6px; color: #83c1ff; font-size: 10px; } +.eightbf-roundtable .eightbf-roundtable__agent-list-scroll { min-height: 0; flex: 1; overflow-y: auto; padding: 0 12px 12px; } +.eightbf-roundtable .eightbf-roundtable__agent-list-loading, +.eightbf-roundtable .eightbf-roundtable__agent-list-empty { display: flex; min-height: 100px; align-items: center; justify-content: center; gap: 7px; color: #8aa0c4; font-size: 12px; text-align: center; } +.eightbf-roundtable .eightbf-roundtable__agent-list-row { + display: flex; + width: 100%; + align-items: center; + gap: 10px; + border-bottom: 1px solid rgb(43 143 254 / 21%); + padding: 11px 2px; + color: inherit; + text-align: left; + transition: background-color .16s ease, transform .16s ease; +} +.eightbf-roundtable .eightbf-roundtable__agent-list-row:hover { background: rgb(0 98 217 / 13%); transform: translateX(2px); } +.eightbf-roundtable .eightbf-roundtable__agent-list-avatar { display: inline-flex; width: 34px; height: 34px; flex: 0 0 auto; align-items: center; justify-content: center; border: 1px solid #007e82; border-radius: 7px; background: #00306a; color: #73c8ff; } +.eightbf-roundtable .eightbf-roundtable__agent-list-avatar svg { width: 18px; height: 18px; } +.eightbf-roundtable .eightbf-roundtable__agent-list-row strong, +.eightbf-roundtable .eightbf-roundtable__agent-list-row small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.eightbf-roundtable .eightbf-roundtable__agent-list-row strong { color: #fff; font-size: 13px; font-weight: 600; } +.eightbf-roundtable .eightbf-roundtable__agent-list-row small { margin-top: 3px; color: #aaceff; font-size: 11px; } +.eightbf-roundtable .eightbf-roundtable__agent-list-index { color: #75b7f5; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; } +.eightbf-roundtable .eightbf-roundtable__agent-detail-topbar { display: flex; height: 48px; align-items: center; justify-content: space-between; border-bottom: 1px solid rgb(43 143 254 / 35%); padding: 0 12px; } +.eightbf-roundtable .eightbf-roundtable__agent-detail-topbar > button:first-child { display: inline-flex; align-items: center; gap: 2px; color: #aaceff; font-size: 12px; } +.eightbf-roundtable .eightbf-roundtable__agent-detail-topbar > button:first-child:hover { color: #fff; } +.eightbf-roundtable .eightbf-roundtable__intent-results-scroll { padding: 12px; } +.eightbf-roundtable .eightbf-roundtable__intent-results-empty { margin: 0; color: #aaceff; font-size: 13px; line-height: 1.8; } +.eightbf-roundtable .eightbf-roundtable__intent-results-list { display: grid; gap: 10px; } +.eightbf-roundtable .eightbf-roundtable__intent-results-list section { border: 1px solid rgb(43 143 254 / 32%); border-radius: 4px; background: rgb(0 12 28 / 20%); padding: 12px; } +.eightbf-roundtable .eightbf-roundtable__intent-results-list h3 { margin: 0 0 8px; color: #a2e1f9; font-size: 14px; font-weight: 500; } +.eightbf-roundtable .eightbf-roundtable__intent-results-list ul { margin: 0; padding-left: 17px; color: #dceaff; font-size: 12px; line-height: 1.8; } + +/* Screenshot-equivalent dark workbench shell. */ +.dark .eightbf-roundtable { background: #061b3b; color: #e7f0ff; } +.dark .eightbf-roundtable > header { min-height: 64px; border-color: rgb(43 143 254 / 45%); background: #032650; box-shadow: none; } +.dark .eightbf-roundtable .eightbf-roundtable__header-brand h1 { color: #e7f0ff; } +.dark .eightbf-roundtable .eightbf-roundtable__header-brand p { color: #8aa0c4; } +.dark .eightbf-roundtable .eightbf-roundtable__header-status { border-color: rgb(101 214 198 / 45%); background: rgb(15 118 110 / 24%); color: #9ef0df; } +.dark .eightbf-roundtable .eightbf-roundtable__header-chain { border-color: rgb(143 196 255 / 44%); background: #082b57; color: #aaceff; } +.dark .eightbf-roundtable .eightbf-roundtable__header-chain strong { color: #e7f0ff; } +.dark .eightbf-roundtable #new-task-btn { border: 1px solid rgb(115 200 255 / 45%); border-radius: 4px; background: transparent; color: #e7f0ff; box-shadow: none; } +.dark .eightbf-roundtable #new-task-btn:hover { background: rgb(0 98 217 / 24%); } +.dark .eightbf-roundtable[data-roundtable-step="1"] > main { gap: 0; padding: 12px 0; } +.dark .eightbf-roundtable[data-roundtable-step="1"] #middle-viewport { border-color: rgb(43 143 254 / 50%); border-radius: 4px; background: #032650; box-shadow: none; } +.dark .eightbf-roundtable[data-roundtable-step="1"] #left-sidebar { + width: 374px; + overflow: hidden; + border: 0; + border-radius: 0; + background: transparent; + padding: 0 12px; +} +.dark .eightbf-roundtable .eightbf-roundtable__intent-delivery-sidebar { + border-color: rgb(0 110 254 / 50%); + background: #032650; +} + +@media (max-width: 1280px) { + .eightbf-roundtable .eightbf-roundtable__header-chain { min-width: 180px; max-width: 220px; } + .dark .eightbf-roundtable[data-roundtable-step="1"] #left-sidebar { width: 326px; } + .eightbf-roundtable .position-roundtable-intent-config-pane { --prt-intent-config-width: 320px; } +} + +@media (max-width: 1024px) { + .dark .eightbf-roundtable[data-roundtable-step="1"] #left-sidebar { width: 294px; } + .eightbf-roundtable .eightbf-roundtable__header-status, + .eightbf-roundtable .eightbf-roundtable__header-brand p { display: none; } + .eightbf-roundtable .eightbf-roundtable__header-chain { display: none; } + .eightbf-roundtable .position-roundtable-intent-config-pane { --prt-intent-config-width: min(360px, 42vw); } +} diff --git a/frontend-web/src/eightbf-roundtable/styles/step2.css b/frontend-web/src/eightbf-roundtable/styles/step2.css index 0abc75de..a9481942 100644 --- a/frontend-web/src/eightbf-roundtable/styles/step2.css +++ b/frontend-web/src/eightbf-roundtable/styles/step2.css @@ -14,4 +14,67 @@ .eightbf-roundtable .eightbf-roundtable__chain-settings-button:hover { background: #115e59; } -/* 可在此继续覆盖会商消息、席位卡片、流程节点及展开按钮的样式。 */ +/* 展开时工作区紧贴左栏;收起时恢复通用的 16px 间距,避免窄栏贴着内容区。 */ +.dark .eightbf-roundtable[data-roundtable-step="2"] > main[data-step2-sidebar-expanded="true"] { + gap: 0; +} + +.dark .eightbf-roundtable[data-roundtable-step="2"] > main[data-step2-sidebar-expanded="false"] { + gap: 10px; +} + +/* 第二步左栏顶部直接复用第一步任务卡和意图理解区,但不复用第一步整列的 + flex: 1 行为;内容高度随意图实际内容收拢,剩余内容交给左栏统一滚动。 */ +.eightbf-roundtable[data-roundtable-step="2"] .eightbf-roundtable__step2-sidebar-header { + display: flex; + flex: 0 0 auto; + flex-direction: column; + gap: 12px; + min-height: 0; + overflow: visible; +} + +.eightbf-roundtable[data-roundtable-step="2"] .eightbf-roundtable__step2-sidebar-header + .eightbf-roundtable__intent-delivery-sidebar { + flex: 0 0 auto; + overflow: visible; +} + +/* 角色面板与列表均依内容撑开,再交由左栏的单一滚动容器承载;否则取消 + 内层滚动后,角色项会从固定高度的父面板溢出并覆盖下方内容。 */ +.eightbf-roundtable[data-roundtable-step="2"] #left-sidebar > .eightbf-roundtable__step2-sidebar-header + div { + flex: 0 0 auto !important; + min-height: 0 !important; +} + +.eightbf-roundtable[data-roundtable-step="2"] #left-sidebar .custom-scrollbar { + flex: 0 0 auto; + overflow: visible; + padding-right: 0; +} + +/* 8BF 第二步沿用第一步的 4px 面板语言,避免通用会商的 rounded-2xl 混入。 */ +.eightbf-roundtable[data-roundtable-step="2"] #left-sidebar > .rounded-2xl { + border-radius: 4px !important; +} + +/* 展开态与第一步左栏保持同一宽度和内边距。注意不能无条件锁定宽度,否则会 + 覆盖收起态的 w-[56px],使顶部图标按钮看起来无效。 */ +.dark .eightbf-roundtable[data-roundtable-step="2"] #left-sidebar[data-sidebar-expanded="true"] { + width: 374px; + padding: 0 12px; +} + +/* 第二步输入区直接复用第一步 PositionIntentInput;共识动作也使用其 + #enter-roundtable-btn 规则,保证和「确认意图并启动业务链」完全同款。 */ +.dark .eightbf-roundtable[data-roundtable-step="2"] .eightbf-roundtable-step2-input { + flex: 0 0 auto; +} + +@media (max-width: 1280px) { + .dark .eightbf-roundtable[data-roundtable-step="2"] #left-sidebar[data-sidebar-expanded="true"] { width: 326px; } +} + +@media (max-width: 1024px) { + .dark .eightbf-roundtable[data-roundtable-step="2"] #left-sidebar[data-sidebar-expanded="true"] { width: 294px; } +} diff --git a/frontend-web/src/open-canvas/api/rewrite.ts b/frontend-web/src/open-canvas/api/rewrite.ts index 18ab5900..4d471fed 100644 --- a/frontend-web/src/open-canvas/api/rewrite.ts +++ b/frontend-web/src/open-canvas/api/rewrite.ts @@ -119,6 +119,10 @@ export interface DocumentRewriteRequest { instruction: string; style?: DocumentRewriteStyle; modelName?: string; + /** Generate locally saved illustration assets after the prose rewrite. */ + generateImages?: boolean; + /** Bounded server-side to 1–4. Ignored unless generateImages is true. */ + maxGeneratedImages?: number; expectedHash?: string; } @@ -140,6 +144,8 @@ export interface DocumentRewriteSummary { }; requestedOptimizations?: string[]; validationWarnings?: string[]; + generatedImageCount?: number; + requestedImageCount?: number; } export type DocumentRewriteStreamEvent = @@ -156,6 +162,15 @@ export type DocumentRewriteStreamEvent = | { type: "rewrite_delta"; delta: string; charCount: number } /** Durable full-draft recovery point used after an SSE reconnect. */ | { type: "rewrite_checkpoint"; content: string; charCount: number } + | { type: "illustration_started"; section: string; position: number; total: number } + | { + type: "illustration_generated"; + section: string; + position: number; + total: number; + generatedCount?: number; + } + | { type: "illustration_completed"; generatedCount: number; requestedCount: number; warnings: string[] } | { type: "validation_started" } | { type: "validation_completed"; passed: boolean; warnings: string[] } | { type: "comparison_ready"; summary: DocumentRewriteSummary } @@ -177,6 +192,8 @@ export interface DurableDocumentRewriteJob { instruction: string; style: DocumentRewriteStyle; modelName: string | null; + generateImages: boolean; + maxGeneratedImages: number; originalContent: string; }; } @@ -229,6 +246,30 @@ async function readDocumentRewriteEvent( content: String(parsed.content ?? ""), charCount: Number(parsed.charCount ?? 0), }; + case "illustration_started": + return { + type: "illustration_started", + section: String(parsed.section ?? ""), + position: Number(parsed.position ?? 0), + total: Number(parsed.total ?? 0), + }; + case "illustration_generated": + return { + type: "illustration_generated", + section: String(parsed.section ?? ""), + position: Number(parsed.position ?? 0), + total: Number(parsed.total ?? 0), + generatedCount: Number(parsed.generatedCount ?? parsed.position ?? 0), + }; + case "illustration_completed": + return { + type: "illustration_completed", + generatedCount: Number(parsed.generatedCount ?? 0), + requestedCount: Number(parsed.requestedCount ?? 0), + warnings: Array.isArray(parsed.warnings) + ? parsed.warnings.filter((item): item is string => typeof item === "string") + : [], + }; case "validation_started": return { type: "validation_started" }; case "validation_completed": @@ -362,6 +403,8 @@ export async function listDurableDocumentRewrites( instruction: String(request.instruction ?? ""), style: (request.style ?? "professional") as DocumentRewriteStyle, modelName: typeof request.modelName === "string" ? request.modelName : null, + generateImages: request.generateImages === true, + maxGeneratedImages: Number(request.maxGeneratedImages ?? 2), originalContent: String(request.originalContent ?? ""), }, }]; diff --git a/frontend-web/src/open-canvas/hooks/useDocumentRewrite.ts b/frontend-web/src/open-canvas/hooks/useDocumentRewrite.ts index 2612d535..3bde9ac4 100644 --- a/frontend-web/src/open-canvas/hooks/useDocumentRewrite.ts +++ b/frontend-web/src/open-canvas/hooks/useDocumentRewrite.ts @@ -9,12 +9,14 @@ import { cancelDurableDocumentRewrite, streamDocumentRewrite, } from "../api/rewrite"; +import { InlineThinkTagFilter, stripInlineThinkTags } from "@/core/streamdown/inline-think"; export type DocumentRewriteStage = | "idle" | "snapshot" | "requirements" | "writing" + | "illustrating" | "validation" | "committing" | "completed" @@ -27,6 +29,8 @@ export interface DocumentRewriteRun { draft: string; /** Live, compact execution plan produced before Markdown generation starts. */ requirementsPlan: string; + /** Ephemeral model reasoning, deliberately kept out of the Markdown draft. */ + thinkingText: string; originalCharCount: number; writtenCharCount: number; fileName: string; @@ -41,6 +45,7 @@ const INITIAL_RUN: DocumentRewriteRun = { stage: "idle", draft: "", requirementsPlan: "", + thinkingText: "", originalCharCount: 0, writtenCharCount: 0, fileName: "", @@ -67,6 +72,10 @@ export function useDocumentRewrite() { const frameRef = useRef(null); const pendingRequirementsDeltaRef = useRef(""); const requirementsFrameRef = useRef(null); + const pendingThinkingRef = useRef(""); + const thinkingFrameRef = useRef(null); + const draftThinkFilterRef = useRef(new InlineThinkTagFilter()); + const requirementsThinkFilterRef = useRef(new InlineThinkTagFilter()); const flushDelta = useCallback(() => { if (frameRef.current !== null) { @@ -86,27 +95,6 @@ export function useDocumentRewrite() { })); }, []); - const enqueueDelta = useCallback((delta: string, writtenCharCount: number) => { - if (!delta) return; - pendingDeltaRef.current += delta; - pendingWrittenCharCountRef.current = writtenCharCount; - if (frameRef.current !== null) return; - frameRef.current = requestAnimationFrame(() => { - frameRef.current = null; - const pending = pendingDeltaRef.current; - const pendingWrittenCharCount = pendingWrittenCharCountRef.current; - pendingDeltaRef.current = ""; - pendingWrittenCharCountRef.current = null; - if (!pending) return; - setRun((previous) => ({ - ...previous, - stage: "writing", - draft: previous.draft + pending, - ...(pendingWrittenCharCount !== null ? { writtenCharCount: pendingWrittenCharCount } : {}), - })); - }); - }, []); - const flushRequirements = useCallback(() => { if (requirementsFrameRef.current !== null) { cancelAnimationFrame(requirementsFrameRef.current); @@ -124,9 +112,35 @@ export function useDocumentRewrite() { })); }, []); - const enqueueRequirementsDelta = useCallback((delta: string) => { + const flushThinking = useCallback(() => { + if (thinkingFrameRef.current !== null) { + cancelAnimationFrame(thinkingFrameRef.current); + thinkingFrameRef.current = null; + } + const delta = pendingThinkingRef.current; + pendingThinkingRef.current = ""; if (!delta) return; - pendingRequirementsDeltaRef.current += delta; + setRun((previous) => ({ ...previous, thinkingText: previous.thinkingText + delta })); + }, []); + + const enqueueThinking = useCallback((delta: string) => { + if (!delta) return; + pendingThinkingRef.current += delta; + if (thinkingFrameRef.current !== null) return; + thinkingFrameRef.current = requestAnimationFrame(() => { + thinkingFrameRef.current = null; + const pending = pendingThinkingRef.current; + pendingThinkingRef.current = ""; + if (!pending) return; + setRun((previous) => ({ ...previous, thinkingText: previous.thinkingText + pending })); + }); + }, []); + + const enqueueRequirementsDelta = useCallback((delta: string) => { + const { text: visibleDelta, thinking } = requirementsThinkFilterRef.current.pushParts(delta); + enqueueThinking(thinking); + if (!visibleDelta) return; + pendingRequirementsDeltaRef.current += visibleDelta; if (requirementsFrameRef.current !== null) return; requirementsFrameRef.current = requestAnimationFrame(() => { requirementsFrameRef.current = null; @@ -141,7 +155,30 @@ export function useDocumentRewrite() { requirementsPlan: previous.requirementsPlan + pending, })); }); - }, []); + }, [enqueueThinking]); + + const enqueueDelta = useCallback((delta: string, writtenCharCount: number) => { + const { text: visibleDelta, thinking } = draftThinkFilterRef.current.pushParts(delta); + enqueueThinking(thinking); + if (!visibleDelta) return; + pendingDeltaRef.current += visibleDelta; + pendingWrittenCharCountRef.current = writtenCharCount; + if (frameRef.current !== null) return; + frameRef.current = requestAnimationFrame(() => { + frameRef.current = null; + const pending = pendingDeltaRef.current; + const pendingWrittenCharCount = pendingWrittenCharCountRef.current; + pendingDeltaRef.current = ""; + pendingWrittenCharCountRef.current = null; + if (!pending) return; + setRun((previous) => ({ + ...previous, + stage: "writing", + draft: previous.draft + pending, + ...(pendingWrittenCharCount !== null ? { writtenCharCount: pendingWrittenCharCount } : {}), + })); + }); + }, [enqueueThinking]); const cancel = useCallback(() => { controllerRef.current?.abort(); @@ -155,12 +192,13 @@ export function useDocumentRewrite() { } flushDelta(); flushRequirements(); + flushThinking(); setRun((previous) => previous.stage === "completed" ? previous : { ...previous, stage: "cancelled", error: "已停止全文改写,原文件未被修改。" }, ); - }, [flushDelta, flushRequirements]); + }, [flushDelta, flushRequirements, flushThinking]); const reset = useCallback(() => { controllerRef.current?.abort(); @@ -169,6 +207,9 @@ export function useDocumentRewrite() { pendingDeltaRef.current = ""; pendingWrittenCharCountRef.current = null; pendingRequirementsDeltaRef.current = ""; + pendingThinkingRef.current = ""; + draftThinkFilterRef.current = new InlineThinkTagFilter(); + requirementsThinkFilterRef.current = new InlineThinkTagFilter(); if (frameRef.current !== null) { cancelAnimationFrame(frameRef.current); frameRef.current = null; @@ -177,6 +218,10 @@ export function useDocumentRewrite() { cancelAnimationFrame(requirementsFrameRef.current); requirementsFrameRef.current = null; } + if (thinkingFrameRef.current !== null) { + cancelAnimationFrame(thinkingFrameRef.current); + thinkingFrameRef.current = null; + } setRun(INITIAL_RUN); }, []); @@ -202,6 +247,9 @@ export function useDocumentRewrite() { pendingDeltaRef.current = ""; pendingWrittenCharCountRef.current = null; pendingRequirementsDeltaRef.current = ""; + pendingThinkingRef.current = ""; + draftThinkFilterRef.current = new InlineThinkTagFilter(); + requirementsThinkFilterRef.current = new InlineThinkTagFilter(); setRun({ ...INITIAL_RUN, stage: "snapshot", @@ -236,28 +284,36 @@ export function useDocumentRewrite() { case "requirements_delta": enqueueRequirementsDelta(event.delta); break; + case "thinking": + enqueueThinking(event.chunk); + break; case "requirements_checkpoint": flushRequirements(); setRun((previous) => ({ ...previous, stage: "requirements", - requirementsPlan: event.content, + requirementsPlan: requirementsThinkFilterRef.current.replace(event.content), })); break; case "requirements_completed": flushRequirements(); - setRun((previous) => ({ - ...previous, - stage: "requirements", - requirementsPlan: event.content, - })); + { + const requirementsPlan = + requirementsThinkFilterRef.current.replace(event.content) + + requirementsThinkFilterRef.current.finish(); + setRun((previous) => ({ + ...previous, + stage: "requirements", + requirementsPlan, + })); + } break; case "requirements_resolved": flushRequirements(); setRun((previous) => ({ ...previous, stage: "writing", - ...(event.plan ? { requirementsPlan: event.plan } : {}), + ...(event.plan ? { requirementsPlan: stripInlineThinkTags(event.plan) } : {}), })); break; case "rewrite_delta": @@ -276,13 +332,20 @@ export function useDocumentRewrite() { setRun((previous) => ({ ...previous, stage: "writing", - draft: event.content, - writtenCharCount: event.charCount, + draft: draftThinkFilterRef.current.replace(event.content), + writtenCharCount: stripInlineThinkTags(event.content).replace(/\s/g, "").length, })); break; + case "illustration_started": + case "illustration_generated": + case "illustration_completed": + flushDelta(); + setRun((previous) => ({ ...previous, stage: "illustrating" })); + break; case "validation_started": flushDelta(); flushRequirements(); + flushThinking(); setRun((previous) => ({ ...previous, stage: "validation" })); break; case "validation_completed": @@ -300,17 +363,23 @@ export function useDocumentRewrite() { case "committed": flushDelta(); flushRequirements(); - setRun((previous) => ({ - ...previous, - draft: event.content, - writtenCharCount: event.content.replace(/\s/g, "").length, - versionId: event.versionId ?? previous.versionId, - })); - options?.onCommitted?.(event.content); + flushThinking(); + { + const committedContent = + draftThinkFilterRef.current.replace(event.content) + draftThinkFilterRef.current.finish(); + setRun((previous) => ({ + ...previous, + draft: committedContent, + writtenCharCount: committedContent.replace(/\s/g, "").length, + versionId: event.versionId ?? previous.versionId, + })); + options?.onCommitted?.(committedContent); + } break; case "done": flushDelta(); flushRequirements(); + flushThinking(); receivedTerminalEvent = true; setRun((previous) => ({ ...previous, @@ -321,6 +390,7 @@ export function useDocumentRewrite() { case "conflict": flushDelta(); flushRequirements(); + flushThinking(); receivedTerminalEvent = true; setRun((previous) => ({ ...previous, @@ -331,12 +401,14 @@ export function useDocumentRewrite() { case "cancelled": flushDelta(); flushRequirements(); + flushThinking(); receivedTerminalEvent = true; setRun((previous) => ({ ...previous, stage: "cancelled", error: event.message })); return; case "error": flushDelta(); flushRequirements(); + flushThinking(); receivedTerminalEvent = true; setRun((previous) => ({ ...previous, @@ -351,6 +423,7 @@ export function useDocumentRewrite() { if (!controller.signal.aborted && !receivedTerminalEvent) { flushDelta(); flushRequirements(); + flushThinking(); setRun((previous) => ({ ...previous, stage: "error", @@ -361,6 +434,7 @@ export function useDocumentRewrite() { if (controller.signal.aborted) return; flushDelta(); flushRequirements(); + flushThinking(); setRun((previous) => ({ ...previous, stage: "error", @@ -372,7 +446,7 @@ export function useDocumentRewrite() { } } }, - [enqueueDelta, enqueueRequirementsDelta, flushDelta, flushRequirements], + [enqueueDelta, enqueueRequirementsDelta, enqueueThinking, flushDelta, flushRequirements, flushThinking], ); useEffect( @@ -380,13 +454,14 @@ export function useDocumentRewrite() { controllerRef.current?.abort(); if (frameRef.current !== null) cancelAnimationFrame(frameRef.current); if (requirementsFrameRef.current !== null) cancelAnimationFrame(requirementsFrameRef.current); + if (thinkingFrameRef.current !== null) cancelAnimationFrame(thinkingFrameRef.current); }, [], ); return { run, - isActive: ["snapshot", "requirements", "writing", "validation", "committing"].includes(run.stage), + isActive: ["snapshot", "requirements", "writing", "illustrating", "validation", "committing"].includes(run.stage), start, cancel, reset, diff --git a/frontend-web/src/pages/deep-research/DeepResearchWorkbench.tsx b/frontend-web/src/pages/deep-research/DeepResearchWorkbench.tsx index 5723d416..bf85a03f 100644 --- a/frontend-web/src/pages/deep-research/DeepResearchWorkbench.tsx +++ b/frontend-web/src/pages/deep-research/DeepResearchWorkbench.tsx @@ -123,6 +123,7 @@ import { advancedOptionsFromConfig, buildReportCardConfig, ReportConfigCard, + ReportOutlineEditor, } from "./ReportConfigCard"; import { ResearchSandboxPanel } from "./ResearchSandboxPanel"; import { REPORT_OUTPUT_PATH, useReportSandbox } from "./useReportSandbox"; @@ -183,6 +184,8 @@ type WorkbenchRun = { phase: string; planText: string; planReasoning: string; + /** Live-only model thinking while report Markdown is being streamed. */ + reportThinking: string; report: string; reportTargets: Record; reportVersion: number; @@ -201,6 +204,7 @@ const EMPTY_RUN: WorkbenchRun = { phase: "initializing", planText: "", planReasoning: "", + reportThinking: "", report: "", reportTargets: {}, reportVersion: 1, @@ -418,6 +422,10 @@ function DeepResearchWorkbenchInner() { presentationId: null, plan: "", }); + const mirroredReportRewriteThinkingRef = useRef<{ presentationId: string | null; thinking: string }>({ + presentationId: null, + thinking: "", + }); const sessions = useResearchSessions(); const sessionQuery = useResearchSession(selectedId); @@ -428,6 +436,10 @@ function DeepResearchWorkbenchInner() { const [localSettings] = useLocalSettings(); const { models, isLoading: modelsLoading } = useModels(); const seenEventKeys = useRef(new Set()); + // Live provider deltas can legitimately repeat the same text (including a + // single space). They have no durable sequence, so never deduplicate them + // by payload or a thought token could disappear from the visible trace. + const liveEventSerial = useRef(0); const replayedTraceJobs = useRef(new Set()); const pendingReportEvents = useRef([]); const reportAnimationFrame = useRef(null); @@ -679,6 +691,16 @@ function DeepResearchWorkbenchInner() { }); }, [reportDocumentRewrite.run.requirementsPlan, rewritePresentation]); + useEffect(() => { + const presentationId = reportRewritePresentationId.current; + const thinking = reportDocumentRewrite.run.thinkingText; + if (!presentationId || !rewritePresentation || !thinking) return; + const previous = mirroredReportRewriteThinkingRef.current; + if (previous.presentationId === presentationId && previous.thinking === thinking) return; + mirroredReportRewriteThinkingRef.current = { presentationId, thinking }; + rewritePresentation.update(presentationId, { thinkingText: thinking }); + }, [reportDocumentRewrite.run.thinkingText, rewritePresentation]); + useEffect(() => { const handleRewriteUndo = (event: Event) => { const detail = (event as CustomEvent<{ @@ -854,6 +876,9 @@ function DeepResearchWorkbenchInner() { phase: session?.status === "completed" ? "done" : run.phase, materialCount, error: run.error, + thinking: run.reportThinking, + events: run.events, + requestedImageCount: requestedReportImageCount(session?.config), }), } as Injected); } @@ -870,6 +895,7 @@ function DeepResearchWorkbenchInner() { ? buildCompletedReportMessage({ sourceCount: materialCount, summaryText: run.summaryText, + imageCount: countEmbeddedReportImages(session?.reportMarkdown), }) : buildStreamingSummaryMessage(run.summaryText), // MessageList 会将该标记转为既有的 assistant 流式渲染状态,避免摘要只在结束后才显示。 @@ -950,7 +976,9 @@ function DeepResearchWorkbenchInner() { pendingFollowUp, followUpStream, run.error, + run.events, run.phase, + run.reportThinking, run.summaryText, session, streamJobId, @@ -980,6 +1008,7 @@ function DeepResearchWorkbenchInner() { if (replayingTerminalTrace && replayedTraceJobs.current.has(streamJobId)) return; if (replayingTerminalTrace) replayedTraceJobs.current.add(streamJobId); seenEventKeys.current = new Set(); + liveEventSerial.current = 0; pendingReportEvents.current = []; setRun({ ...EMPTY_RUN, jobId: streamJobId }); // 进行中的写作仍自动打开沙箱;历史完成记录只回放步骤与摘要,等待用户点击 @@ -997,6 +1026,7 @@ function DeepResearchWorkbenchInner() { let reportTargets = current.reportTargets; let planText = current.planText; let planReasoning = current.planReasoning; + let reportThinking = current.reportThinking; let summaryText = current.summaryText; let phase = current.phase; for (const event of queued) { @@ -1009,6 +1039,10 @@ function DeepResearchWorkbenchInner() { planReasoning += typeof event.payload.delta === "string" ? event.payload.delta : ""; continue; } + if (event.type === "report_thinking") { + reportThinking += typeof event.payload.delta === "string" ? event.payload.delta : ""; + continue; + } if (event.type === "summary_delta") { summaryText += typeof event.payload.delta === "string" ? event.payload.delta : ""; continue; @@ -1020,6 +1054,7 @@ function DeepResearchWorkbenchInner() { if (event.type === "report_reset") { report = ""; reportTargets = {}; + reportThinking = ""; reportVersion = typeof event.payload.reportVersion === "number" ? event.payload.reportVersion : reportVersion + 1; continue; } @@ -1035,7 +1070,7 @@ function DeepResearchWorkbenchInner() { reportVersion = Math.max(reportVersion, version); } } - return { ...current, report, reportTargets, reportVersion, phase, planText, planReasoning, summaryText }; + return { ...current, report, reportTargets, reportVersion, phase, planText, planReasoning, reportThinking, summaryText }; }); }; @@ -1043,12 +1078,15 @@ function DeepResearchWorkbenchInner() { streamJobId, { onEvent: (event) => { - const key = eventKey(event); + const key = event.seq === 0 && !event.liveId + ? `live-arrival:${++liveEventSerial.current}` + : eventKey(event); if (seenEventKeys.current.has(key)) return; seenEventKeys.current.add(key); if ( event.type === "plan_delta" || event.type === "plan_reasoning" || + event.type === "report_thinking" || event.type === "report_delta" || event.type === "report_chunk" || event.type === "report_reset" || @@ -1521,6 +1559,10 @@ function DeepResearchWorkbenchInner() { case "config": return ( 0 || imageSnapshot.imageTotal > 0 || imageSnapshot.images.length > 0; + const imageTotal = imageSnapshot.imageTotal || requestedImageCount; + const generatedImages = imageSnapshot.images.filter((image) => image.status === "completed").length; + const failedImages = imageSnapshot.images.filter((image) => image.status === "failed").length; const stages = [ { id: "harvest", @@ -1879,6 +1933,18 @@ function buildWritingProgressToolCalls({ boundary: chatPhaseRank("reviewing"), detail: "正在逐字写入右侧沙箱中的 report.md", }, + ...(imageStageVisible ? [{ + id: "illustrate", + label: "生成报告配图", + boundary: chatPhaseRank("exporting"), + detail: generatedImages > 0 + ? `已生成 ${generatedImages} 张报告配图并嵌入 report.md${failedImages ? `;另有 ${failedImages} 张未生成,文字报告已保留` : ""}` + : failedImages > 0 + ? `配图未生成,已保留完整文字报告${imageTotal ? `(已处理 ${failedImages}/${imageTotal} 张)` : ""}` + : imageTotal > 0 + ? `正在生成报告配图,已处理 ${generatedImages + failedImages}/${imageTotal} 张` + : "正在为报告关键章节生成配图", + }] : []), { id: "summary", label: "生成报告摘要", @@ -1901,6 +1967,7 @@ function buildWritingProgressToolCalls({ label: stage.label, detail, status: complete ? "complete" : "active", + ...(stage.id === "write" && thinking.trim() ? { thinking } : {}), }, }; }); @@ -1910,14 +1977,19 @@ function buildWritingProgressToolCalls({ function buildCompletedReportMessage({ sourceCount, summaryText, + imageCount, }: { sourceCount: number; summaryText: string; + imageCount: number; }): string { const parts = [ "## 研究报告已完成", `已基于 ${sourceCount} 条资料完成报告,正文已在右侧沙箱中打开;可通过沙箱工具栏下载 Markdown 或 Word。`, ]; + if (imageCount > 0) { + parts.push(`已生成 ${imageCount} 张报告配图,并嵌入正文对应章节。`); + } if (summaryText.trim()) { parts.push(`### 报告摘要\n\n${summaryText.trim()}`); } @@ -2104,6 +2176,7 @@ function ResearchConversation({ const hasTrace = active || events.length > 0; const streamedSourceCount = events.reduce((count, event) => count + (event.type === "source_added" ? 1 : 0), 0); const liveSourceCount = Math.max(sourceCount, streamedSourceCount); + const requestedImageCount = requestedReportImageCount(session.config); return (
@@ -2120,6 +2193,7 @@ function ResearchConversation({ error={streamError} onOpenSandbox={onOpenSandbox} active={active} + requestedImageCount={requestedImageCount} /> )} @@ -2169,6 +2243,15 @@ type TraceSource = { snippet?: string; }; +type TraceImage = { + position: number; + section: string; + status: "generating" | "completed" | "failed"; + name?: string; + alt?: string; + error?: string; +}; + type TraceSnapshot = { groups: { title: string; queries: string[] }[]; subtopics: string[]; @@ -2179,6 +2262,9 @@ type TraceSnapshot = { curationReason?: string; compressedSourceCount?: number; reportCompleted: boolean; + imageTotal: number; + images: TraceImage[]; + imagePhaseFinished: boolean; warnings: string[]; }; @@ -2198,17 +2284,30 @@ function numberValue(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) ? value : undefined; } +function countEmbeddedReportImages(markdown?: string): number { + return (markdown?.match(/!\[[^\]]*\]\(deep-research:\/\/[^)\s]+\)/g) ?? []).length; +} + +function requestedReportImageCount(config?: Record): number { + if (config?.generate_images !== true) return 0; + const configured = numberValue(config.max_generated_images); + return Math.max(1, Math.min(4, Math.floor(configured ?? 2))); +} + function buildTraceSnapshot(events: ProgressEvent[]): TraceSnapshot { const groupQueries = new Map(); const subtopics: string[] = []; const queryMap = new Map(); const sourceMap = new Map(); + const imageMap = new Map(); const selectedIds = new Set(); const removedIds = new Set(); const warnings: string[] = []; let curationReason: string | undefined; let compressedSourceCount: number | undefined; let reportCompleted = false; + let imageTotal = 0; + let imagePhaseFinished = false; const addQuery = (query: string, group = "研究课题") => { const normalized = query.trim(); @@ -2223,6 +2322,11 @@ function buildTraceSnapshot(events: ProgressEvent[]): TraceSnapshot { for (const event of events) { const payload = event.payload ?? {}; + if (event.type === "phase_changed") { + const from = traceString(payload.from); + const to = traceString(payload.to); + if (from === "illustrating" || to === "exporting") imagePhaseFinished = true; + } if (event.type === "plan_created") { const plan = traceRecord(payload.plan); for (const subtopic of traceStrings(payload.subtopics).concat(traceStrings(plan.subtopics))) { @@ -2292,9 +2396,39 @@ function buildTraceSnapshot(events: ProgressEvent[]): TraceSnapshot { reportCompleted = true; continue; } + if (event.type === "image_generation_started" || event.type === "image_generated") { + const position = Math.max(1, Math.floor(numberValue(payload.position) ?? imageMap.size + 1)); + const total = numberValue(payload.total); + if (total !== undefined) imageTotal = Math.max(imageTotal, Math.floor(total)); + imageTotal = Math.max(imageTotal, position); + const prior = imageMap.get(position); + imageMap.set(position, { + position, + section: traceString(payload.section) ?? prior?.section ?? `第 ${position} 张配图`, + status: event.type === "image_generated" ? "completed" : prior?.status === "completed" ? "completed" : "generating", + name: traceString(payload.name) ?? prior?.name, + alt: traceString(payload.alt) ?? prior?.alt, + }); + continue; + } if (event.type === "warning" || event.type === "error") { const message = traceString(payload.message) ?? traceString(payload.detail); if (message && !warnings.includes(message)) warnings.push(message); + if (traceString(payload.code) === "image_generation_failed") { + const position = Math.max(1, Math.floor(numberValue(payload.position) ?? imageMap.size + 1)); + const total = numberValue(payload.total); + if (total !== undefined) imageTotal = Math.max(imageTotal, Math.floor(total)); + imageTotal = Math.max(imageTotal, position); + const prior = imageMap.get(position); + imageMap.set(position, { + position, + section: traceString(payload.section) ?? prior?.section ?? `第 ${position} 张配图`, + status: "failed", + name: prior?.name, + alt: prior?.alt, + error: message ?? "报告配图生成失败", + }); + } } } @@ -2308,12 +2442,15 @@ function buildTraceSnapshot(events: ProgressEvent[]): TraceSnapshot { curationReason, compressedSourceCount, reportCompleted, + imageTotal, + images: [...imageMap.values()].sort((left, right) => left.position - right.position), + imagePhaseFinished, warnings, }; } function phaseAtOrAfter(phase: string, boundary: string): boolean { - const order = ["initializing", "planning", "collecting", "curating", "compressing", "deepening", "writing", "reviewing", "illustrating", "exporting", "done"]; + const order = ["initializing", "planning", "collecting", "curating", "compressing", "deepening", "writing", "summarizing", "reviewing", "illustrating", "exporting", "done"]; return Math.max(0, order.indexOf(phase)) >= Math.max(0, order.indexOf(boundary)); } @@ -2345,7 +2482,7 @@ function ReasoningStream({ text }: { text: string }) { ); } -const ResearchTrace = memo(function ResearchTrace({ events, phase, planText, planReasoning, sourceCount, reportStarted, error, onOpenSandbox, active }: { events: ProgressEvent[]; phase: string; planText: string; planReasoning: string; sourceCount: number; reportStarted: boolean; error: string | null; onOpenSandbox: () => void; active: boolean }) { +const ResearchTrace = memo(function ResearchTrace({ events, phase, planText, planReasoning, sourceCount, reportStarted, error, onOpenSandbox, active, requestedImageCount }: { events: ProgressEvent[]; phase: string; planText: string; planReasoning: string; sourceCount: number; reportStarted: boolean; error: string | null; onOpenSandbox: () => void; active: boolean; requestedImageCount: number }) { const [expanded, setExpanded] = useState(true); const snapshot = useMemo(() => buildTraceSnapshot(events), [events]); const sourcesById = useMemo(() => new Map(snapshot.sources.map((source) => [source.id, source])), [snapshot.sources]); @@ -2358,6 +2495,14 @@ const ResearchTrace = memo(function ResearchTrace({ events, phase, planText, pla const curatingDone = phaseAtOrAfter(phase, "writing") || snapshot.selectedIds.size > 0; const writingDone = snapshot.reportCompleted || phase === "done"; const streamedQueries = planningDone ? [] : parseStreamedQueries(planText); + const imageStageVisible = requestedImageCount > 0 || snapshot.imageTotal > 0 || snapshot.images.length > 0; + const imageTotal = snapshot.imageTotal || requestedImageCount; + const completedImages = snapshot.images.filter((image) => image.status === "completed").length; + const failedImages = snapshot.images.filter((image) => image.status === "failed").length; + const processedImages = completedImages + failedImages; + const imageDone = imageStageVisible && (snapshot.imagePhaseFinished || phaseAtOrAfter(phase, "exporting") || (!active && phase === "done")); + const imageCurrent = imageStageVisible && !imageDone && (phase === "illustrating" || snapshot.images.some((image) => image.status === "generating")); + const imageProgress = imageTotal ? Math.min(100, Math.round((processedImages / imageTotal) * 100)) : 0; return (
@@ -2390,6 +2535,12 @@ const ResearchTrace = memo(function ResearchTrace({ events, phase, planText, pla + {imageStageVisible && 0 ? `已生成 ${completedImages} 张配图并嵌入 report.md${failedImages ? `;另有 ${failedImages} 张未生成,文字报告已保留` : ""}。` : "配图未生成,已保留完整文字报告。" : imageCurrent ? `正在生成报告配图,已处理 ${processedImages}/${imageTotal || "?"} 张。` : "报告写完后会为关键章节生成并嵌入配图。"} done={imageDone} current={imageCurrent}> + {imageTotal > 0 &&
} + {snapshot.images.length > 0 ?
{snapshot.images.map((image) =>

第 {image.position} 张 · {image.section}

{image.status === "completed" ? `${image.name ?? "已保存并嵌入报告"}` : image.status === "failed" ? image.error ?? "生成失败,已保留文字报告" : "正在调用图片模型…"}

{image.status === "completed" ? "已生成" : image.status === "failed" ? "未生成" : "生成中"}
)}
:
{imageCurrent ? "正在准备第一张报告配图…" : `最多生成 ${imageTotal || requestedImageCount} 张,并在完成后插入对应章节。`}
} + {completedImages > 0 && } + } + {snapshot.warnings.length > 0 &&

过程提示

{snapshot.warnings.map((warning) =>

{warning}

)}
} {error &&

实时连接提示:{error}。研究仍会继续,页面会从已保存的事件记录恢复。

}
} @@ -2398,7 +2549,8 @@ const ResearchTrace = memo(function ResearchTrace({ events, phase, planText, pla }); function CompletionSummary({ session, sourceCount, onOpenSandbox, onExport }: { session: DeepResearchSession; sourceCount: number; onOpenSandbox: () => void; onExport: (format: "word" | "markdown" | "html") => void }) { - return

研究报告已完成

已基于 {sourceCount || session.sourceCount} 条资料完成报告,并保留来源供后续追问。报告正文在右侧沙箱中打开。

; + const imageCount = countEmbeddedReportImages(session.reportMarkdown); + return

研究报告已完成

已基于 {sourceCount || session.sourceCount} 条资料完成报告,并保留来源供后续追问。报告正文在右侧沙箱中打开。

{imageCount > 0 &&

已生成 {imageCount} 张报告配图,已嵌入正文对应章节。

}
; } function PlanReviewCard({ plan, submitting, onSubmit }: { plan: Record | undefined; submitting: boolean; onSubmit: (action: "approve" | "revise", feedback?: string) => void }) { @@ -2409,8 +2561,132 @@ function PlanReviewCard({ plan, submitting, onSubmit }: { plan: Record void; mode: ResearchMode; options: AdvancedResearchOptions; classicMode: boolean; onClassicModeChange: (value: boolean) => void; imageGenerationAvailable: boolean; imageLimit: number; onChange: (key: K, value: AdvancedResearchOptions[K]) => void }) { - return 高级研究配置本次设置会作为下一次研究的默认值;对话式收集的最终选项在报告配置卡片中确认。
onChange("language", value as AdvancedResearchOptions["language"])} options={[{ value: "zh-CN", label: "中文" }, { value: "en-US", label: "English" }]} /> onChange("tone", value as ResearchTone)} options={[{ value: "objective", label: "客观" }, { value: "formal", label: "正式" }, { value: "analytical", label: "分析" }, { value: "persuasive", label: "说服" }]} /> onChange("maxSearchResults", value)} /> onChange("maxIterations", value)} /> onChange("maxConcurrency", value)} /> onChange("maxContextWords", value)} />{(mode === "detailed" || mode === "multi_agent") && onChange("maxSubtopics", value)} />}{mode === "deep" && <> onChange("deepBreadth", value)} /> onChange("deepDepth", value)} />}{mode === "multi_agent" && onChange("maxRevisionLoops", value)} />}