diff --git a/frontend-web/src/components/wiki/RelatedNotesDialog.tsx b/frontend-web/src/components/wiki/RelatedNotesDialog.tsx new file mode 100644 index 00000000..1cc5e048 --- /dev/null +++ b/frontend-web/src/components/wiki/RelatedNotesDialog.tsx @@ -0,0 +1,110 @@ +import { FileText, Hash, Loader2, Sparkles } from "lucide-react"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { useKnowledgeNotes } from "@/core/knowledge/hooks"; +import type { KnowledgeNote } from "@/core/knowledge/types"; + +import { SOURCE_TYPE_LABELS, formatDateTime } from "./wikiUtils"; + +/** What was clicked: a tag (exact filter) or an entity (keyword match in body). */ +export type RelatedNotesFilter = + | { kind: "tag"; value: string } + | { kind: "entity"; value: string }; + +/** + * Click a tag / entity chip → list every other note that shares it. + * Tags use the backend's exact `tag` filter; entities fall back to a keyword + * match (entity names appear verbatim in titles/summaries/bodies). + */ +export function RelatedNotesDialog({ + filter, + currentNoteId, + onOpenChange, + onSelect, +}: { + filter: RelatedNotesFilter | null; + currentNoteId?: string; + onOpenChange: (open: boolean) => void; + onSelect: (noteId: string) => void; +}) { + const params = + filter?.kind === "tag" + ? { tag: filter.value, limit: 50 } + : filter?.kind === "entity" + ? { keyword: filter.value, limit: 50 } + : { limit: 0 }; + + const { data, isLoading, isFetching } = useKnowledgeNotes(params); + const items = (data?.items ?? []).filter( + (n: KnowledgeNote) => n.id !== currentNoteId, + ); + + return ( + + + + + {filter?.kind === "entity" ? ( + + ) : ( + + )} + {filter?.value} + + + {filter?.kind === "entity" + ? "提到该实体的关联知识" + : "使用该标签的关联知识"} + + + + + {isLoading || (isFetching && items.length === 0) ? ( +
+ 加载中... +
+ ) : items.length === 0 ? ( +
+ 暂无其他关联知识。 +
+ ) : ( +
    + {items.map((note) => ( +
  • + +
  • + ))} +
+ )} +
+
+
+ ); +} diff --git a/frontend-web/src/components/wiki/WikiContent.tsx b/frontend-web/src/components/wiki/WikiContent.tsx index 38d4705f..354f190b 100644 --- a/frontend-web/src/components/wiki/WikiContent.tsx +++ b/frontend-web/src/components/wiki/WikiContent.tsx @@ -25,6 +25,10 @@ import { DocumentToolbar } from "./DocumentToolbar"; import { FloatingToc } from "./FloatingToc"; import { MarkdownViewer } from "./MarkdownViewer"; import { RelatedDocs } from "./RelatedDocs"; +import { + RelatedNotesDialog, + type RelatedNotesFilter, +} from "./RelatedNotesDialog"; import { SOURCE_TYPE_LABELS, STATUS_LABELS, @@ -91,6 +95,10 @@ export function WikiContent({ const [draftContent, setDraftContent] = useState(note.content_md); const [draftStatus, setDraftStatus] = useState(note.status); const [activeHeadingId, setActiveHeadingId] = useState(null); + // Tag / entity chip clicked → show notes that share it. + const [relatedFilter, setRelatedFilter] = useState( + null, + ); // Pull the backend-injected `## Related` wikilinks out of the body so they // render as a structured module; editing still works on the full source. @@ -270,12 +278,15 @@ export function WikiContent({ 创建:{formatDateTime(note.created_at).slice(0, 10)} ) : null} {note.tags.map((tag) => ( - setRelatedFilter({ kind: "tag", value: tag })} + className="inline-flex h-6 cursor-pointer items-center rounded-md bg-rose-50 px-2 text-xs font-medium text-rose-500 transition-colors hover:bg-rose-100 hover:text-rose-600 dark:bg-rose-500/10 dark:text-rose-300 dark:hover:bg-rose-500/20" > #{tag} - + ))} @@ -287,11 +298,15 @@ export function WikiContent({ {note.entities.map((ent) => { const meta = entityMeta(ent.entity_type); return ( - + setRelatedFilter({ kind: "entity", value: ent.name }) + } className={cn( - "inline-flex items-center gap-1.5 rounded-full border py-1 pl-2 pr-1 text-xs font-medium", + "inline-flex cursor-pointer items-center gap-1.5 rounded-full border py-1 pl-2 pr-1 text-xs font-medium transition-opacity hover:opacity-80", meta.className, )} > @@ -300,7 +315,7 @@ export function WikiContent({ {meta.label} - + ); })} @@ -373,6 +388,18 @@ export function WikiContent({ + + { + if (!open) setRelatedFilter(null); + }} + onSelect={(id) => { + setRelatedFilter(null); + navigate(`/page/workspace/knowledge/${id}`); + }} + /> ); } diff --git a/frontend-web/src/core/threads/hooks.ts b/frontend-web/src/core/threads/hooks.ts index caea58c4..79a1273b 100644 --- a/frontend-web/src/core/threads/hooks.ts +++ b/frontend-web/src/core/threads/hooks.ts @@ -152,7 +152,10 @@ function getStreamErrorMessage(error: unknown): string { */ function isThreadNotAccessibleError(message: string): boolean { const lowered = message.toLowerCase(); - if (/thread\s+[\w-]{8,}\s+not\s+found/i.test(message)) { + // Backend now returns "not found" (no row + no checkpoint) and + // "not accessible" (row owned by another user / stale login session) as + // distinct details — both mean the current user can't use this thread. + if (/thread\s+[\w-]{8,}\s+not\s+(found|accessible)/i.test(message)) { return true; } // Fallback: a bare 404/403 from the threads endpoint when the SDK has @@ -163,6 +166,21 @@ function isThreadNotAccessibleError(message: string): boolean { ); } +/** + * Detect a "Run not found" 404 from the LangGraph SDK. + * + * The SDK persists the in-flight run id under ``lg:stream:{threadId}`` in + * sessionStorage and, on mount, auto-``joinStream``s it to resume a dropped + * SSE. After a backend restart the in-memory run registry is gone, so the join + * 404s — and the SDK only clears that key on reconnect *success*, never on + * failure. So every switch back into the conversation re-fires the same 404. + * We detect it here to clear the stale key ourselves and stay silent (the + * conversation renders from history; there is nothing live to reconnect to). + */ +function isRunNotFoundError(message: string): boolean { + return /run\s+[\w-]{8,}\s+not\s+found/i.test(message); +} + function buildCurrentUserLabel(): string { const auth = getStoredAuth(); if (!auth) { @@ -335,6 +353,19 @@ export function useThreadStream({ onError(error) { setOptimisticMessages([]); const message = getStreamErrorMessage(error); + // A stale auto-reconnect ("Run not found") after a backend restart: + // drop the dirty ``lg:stream:{threadId}`` key so switching back into this + // conversation stops re-firing the same 404, and render from history + // silently — there is no live run to rejoin. + if (isRunNotFoundError(message)) { + const staleThreadId = threadIdRef.current ?? onStreamThreadId ?? null; + if (staleThreadId && runMetadataStorageRef.current) { + runMetadataStorageRef.current.removeItem( + `lg:stream:${staleThreadId}`, + ); + } + return; + } // A "thread not found" 404 almost always means the URL points at a // thread from a previous login session — either this tab outlived a // logout/re-login or the thread genuinely belongs to a different user. diff --git a/frontend-web/src/pages/KnowledgeBasePage.tsx b/frontend-web/src/pages/KnowledgeBasePage.tsx index 7ce56d67..b12cfb5f 100644 --- a/frontend-web/src/pages/KnowledgeBasePage.tsx +++ b/frontend-web/src/pages/KnowledgeBasePage.tsx @@ -468,6 +468,8 @@ export default function KnowledgeBasePage() { const [status, setStatus] = useState("all"); const [expandedIds, setExpandedIds] = useState>(new Set()); const treeInitializedRef = useRef(false); + // Auto-open a default note once on entry so the reading pane is never blank. + const defaultNoteShownRef = useRef(false); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useState(readSidebarCollapsed); @@ -495,6 +497,15 @@ export default function KnowledgeBasePage() { const { data: note, isLoading: noteLoading } = useKnowledgeNote(noteId); const { data: wikiLinks } = useNoteWikilinks(noteId); + // On first entry without an explicit note, open the most recent one so the + // reading pane shows real content instead of the empty placeholder. + useEffect(() => { + if (defaultNoteShownRef.current || noteId || isLoading) return; + if (notes.length === 0) return; + defaultNoteShownRef.current = true; + navigate(`/page/workspace/knowledge/${notes[0].id}`, { replace: true }); + }, [noteId, isLoading, notes, navigate]); + // First load: expand the root-level folders so the tree reads as a directory. useEffect(() => { if (treeInitializedRef.current || tree.length === 0) return; diff --git a/frontend-web/src/roundtable-planning/api/drafts.ts b/frontend-web/src/roundtable-planning/api/drafts.ts index ac9ba0ff..1906a07b 100644 --- a/frontend-web/src/roundtable-planning/api/drafts.ts +++ b/frontend-web/src/roundtable-planning/api/drafts.ts @@ -103,14 +103,44 @@ export interface DraftStep2RunSnapshot { * thread. Optional — drafts saved before this feature (or never drawn) won't carry it. */ export interface DraftStep3Snapshot { - /** The full self-contained HTML written to outputs/方案总览.html. */ - html: string; - /** ISO timestamp when the report was generated. */ - generatedAt: string; - /** The inference model used to draw it. */ - model: string; - /** The agent's closing one-liner (rendered as the report message bubble). */ + /** + * The full self-contained HTML written to outputs/方案总览.html by the + * `roundtable-report` (HTML 看板) agent. Optional — a draft may only carry the + * Markdown 总结报告 (`summaryReport`) and never draw the HTML 看板. + */ + html?: string; + /** ISO timestamp when the HTML report was generated. */ + generatedAt?: string; + /** The inference model used to draw the HTML report. */ + model?: string; + /** The HTML agent's closing one-liner (rendered as the report message bubble). */ summary?: string; + /** + * Step 3「总结报告」snapshot — output of the `roundtable-summary` agent + * (Markdown report). Stored alongside (not instead of) the HTML report so the + * two Step-3 surfaces persist independently. Nested in the free-form step3 JSON + * blob, so the backend needs no schema change. Optional. + */ + summaryReport?: DraftStep3SummarySnapshot | null; +} + +/** + * Step 3「总结报告」snapshot — the Markdown report written by `roundtable-summary`, + * stored inline so a reloaded draft can show it without depending on the ephemeral + * summary thread. `threadId` keeps the real summary thread id so a reloaded draft + * can continue Q&A / refinement on the same thread (the agent keeps its context). + */ +export interface DraftStep3SummarySnapshot { + /** The full Markdown written to outputs/方案总结报告.md. */ + md: string; + /** The real summary thread id — reused for follow-up Q&A / refine after reload. */ + threadId?: string; + /** ISO timestamp when the summary was generated. */ + generatedAt: string; + /** The inference model used to write it. */ + model: string; + /** The agent's closing chat text (the report body it echoed into the conversation). */ + closing?: string; } /** Lightweight metadata row returned by the list endpoint. */ diff --git a/frontend-web/src/roundtable-planning/api/multi-agent.ts b/frontend-web/src/roundtable-planning/api/multi-agent.ts index 961fab42..bdb833f2 100644 --- a/frontend-web/src/roundtable-planning/api/multi-agent.ts +++ b/frontend-web/src/roundtable-planning/api/multi-agent.ts @@ -818,3 +818,28 @@ export async function initReportThread( const json = (await res.json()) as { agent_name: string; thread_id: string }; return { agentName: json.agent_name, threadId: json.thread_id }; } + +/** + * Create a thread for the Step 3「总结报告」summary agent (`roundtable-summary`, + * a built-in singleton — distinct from `roundtable-report`; this only spins up a + * cheap thread, it never re-creates the agent). Run it afterwards with + * {@link streamMultiAgent} (`agentType: "special"`, `agentName: "roundtable-summary"`, + * `dAgentThreadId: { [agentName]: threadId }`). Follow-up Q&A / refinements reuse + * the SAME thread (do not re-init) so the agent keeps the report context. + */ +export async function initSummaryThread( + options: { signal?: AbortSignal } = {}, +): Promise { + const res = await apiFetch(`${getBackendBaseURL()}/api/multi-agent/summary/init`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + signal: options.signal, + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`init summary thread failed (${res.status}): ${text}`); + } + const json = (await res.json()) as { agent_name: string; thread_id: string }; + return { agentName: json.agent_name, threadId: json.thread_id }; +} diff --git a/frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx b/frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx index 7ffe0069..45226bb4 100644 --- a/frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx +++ b/frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx @@ -27,7 +27,11 @@ import { Step3ReportMode, type ModelOption, } from "@/roundtable-planning/components/Step3ReportMode"; -import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts"; +import { Step3SummaryMode } from "@/roundtable-planning/components/Step3SummaryMode"; +import type { + DraftStep3Snapshot, + DraftStep3SummarySnapshot, +} from "@/roundtable-planning/api/drafts"; import type { ReportIntent } from "@/roundtable-planning/hooks/useStep3Report"; interface SeatDelivery { @@ -62,6 +66,12 @@ interface HighFidelityReportProps { savedReport?: DraftStep3Snapshot | null; /** 一次绘制完成时回调,把结果交给上层存进草稿 step3。 */ onReportSaved?: (snapshot: DraftStep3Snapshot) => void; + /** 业务链写给总控的编排提示 —— 注入「总结报告」智能体作为最高优先级口径。 */ + coordinatorPrompt?: string | null; + /** 草稿里已存的上次总结结果 —— 透传给「总结报告」恢复展示。 */ + savedSummary?: DraftStep3SummarySnapshot | null; + /** 一次总结完成时回调,把结果交给上层存进草稿 step3.summaryReport。 */ + onSummarySaved?: (snapshot: DraftStep3SummarySnapshot) => void; /** Resets state and navigates back to Step 1. */ onReset: () => void; openArtifactPreview: (threadId: string, path: string) => void; @@ -103,6 +113,9 @@ export const HighFidelityReport: React.FC = ({ setSelectedModel, savedReport, onReportSaved, + coordinatorPrompt, + savedSummary, + onSummarySaved, onReset, openArtifactPreview, seatStubMessages, @@ -115,6 +128,9 @@ export const HighFidelityReport: React.FC = ({ return `圆桌总控结论-${short}`; }, [intentReady?.objective]); + // 第 3 步**主页** = 总结报告(进第三步默认自动跑「方案总结报告智能体」)。 + // 用户点「最终方案展示」切到静态页(summaryMode=false),再从静态页可进「结果绘制」HTML。 + const [summaryMode, setSummaryMode] = useState(true); const [drawMode, setDrawMode] = useState(false); const [fileSandboxMode, setFileSandboxMode] = useState(false); const [fileSandboxTarget, setFileSandboxTarget] = useState<{ @@ -126,8 +142,11 @@ export const HighFidelityReport: React.FC = ({ // 不会自动关)。点「结果绘制」后 Step3ReportMode 会按需开自己的沙箱,互不串扰。 const { deselect: deselectArtifact } = useArtifacts(); useEffect(() => { + // 总结报告主页(summaryMode)由 Step3SummaryMode 自管沙箱——这里不 deselect, + // 否则会盖掉它在恢复草稿时同步打开的 md 预览。切到静态页时才清空第二步遗留沙箱。 + if (summaryMode) return; deselectArtifact(); - }, [deselectArtifact]); + }, [deselectArtifact, summaryMode]); // Build the per-seat list (excludes the coordinator). useMemo so the file // fetch effect doesn't re-fire on every parent render. const seatThreadEntries = useMemo(() => { @@ -248,6 +267,28 @@ export const HighFidelityReport: React.FC = ({ [], ); + if (summaryMode) { + return ( + setSummaryMode(false)} + /> + ); + } + if (fileSandboxMode && fileSandboxTarget) { return ( = ({ ))} + + + + + + {/* 主体:左源卡 + 右沙箱 */} +
+ + + {/* 右栏:智能体进度 + 沙箱(复用第 2 步) */} +
+ + + +
+
+ + {/* 弹窗 */} + {modal === "consensus" && ( + setModal(null)} + headerAction={ + + } + > + {consensusText ? ( + + {consensusText} + + ) : ( +

暂无总控结论。

+ )} +
+ )} + + {modal === "seats" && ( + setModal(null)}> + + + )} + + {modal === "files" && ( + setModal(null)} + /> + )} + + ); +} + +// ── 左栏源卡 ───────────────────────────────────────────────────────────── + +function SourceCard({ + icon, + title, + hint, + badge, + disabled, + onClick, +}: { + icon: React.ReactNode; + title: string; + hint: string; + badge?: string; + disabled?: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +function CollapsedSourceCard({ + icon, + title, + disabled, + onClick, +}: { + icon: React.ReactNode; + title: string; + disabled?: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +// ── 右栏:智能体进度面板(沙箱的 60% 区域)+ 底部对话输入框 ────────────────── + +function AgentProgressPanel({ + phase, + dialogues, + error, + busy, + onRetry, + onAsk, + onPause, + onArtifactStepClick, + onArtifactPreview, + selectedModel, + availableModels, + modelsLoading, + setSelectedModel, +}: { + phase: ReturnType["phase"]; + dialogues: ReturnType["dialogues"]; + error: string | null; + busy: boolean; + onRetry: () => void; + onAsk: (message: string, files?: File[]) => void; + onPause: () => void; + onArtifactStepClick: ReturnType["openArtifactFromStep"]; + onArtifactPreview: ReturnType["openArtifactPreview"]; + selectedModel: string; + availableModels: ReadonlyArray; + modelsLoading: boolean; + setSelectedModel: (model: string) => void; +}) { + const scrollRef = useRef(null); + const { atBottom, scrollToBottom } = useAutoScroll(scrollRef, [dialogues]); + const [askText, setAskText] = useState(""); + // 输入框携带的本地附件(点发送时连同指令一起上传到总结线程)。 + const [askFiles, setAskFiles] = useState([]); + const fileInputRef = useRef(null); + + const pickFiles = (e: React.ChangeEvent) => { + const picked = Array.from(e.target.files ?? []); + if (picked.length > 0) { + setAskFiles((prev) => { + const seen = new Set(prev.map((f) => `${f.name}:${f.size}`)); + return [...prev, ...picked.filter((f) => !seen.has(`${f.name}:${f.size}`))]; + }); + } + e.target.value = ""; + }; + const removeFile = (idx: number) => setAskFiles((prev) => prev.filter((_, i) => i !== idx)); + + const handleAskSubmit = (e: FormEvent) => { + e.preventDefault(); + const text = askText.trim(); + const hasFiles = askFiles.length > 0; + if (!text && !hasFiles) return; // 允许「随时」发送(含生成中);空内容不发 + setAskText(""); + setAskFiles([]); + onAsk(text, hasFiles ? askFiles : undefined); + }; + + return ( +
+
+
+
+ 📝 +
+
+
方案总结报告智能体
+
据各席位成果撰写 Markdown 总结报告
+
+
+ +
+ + {/* 消息列表 —— 与第 2 步同款气泡(流式正文 + 工具步骤卡 + 文件卡)。 */} +
+
+ {phase === "creating" && dialogues.length === 0 && ( +
+ + 正在初始化总结线程… +
+ )} + + {dialogues.map((msg) => ( + onArtifactStepClick(msg, step)} + onFileCardClick={(path) => { + if (msg.agentThreadId) onArtifactPreview(msg.agentThreadId, path); + }} + /> + ))} + + {busy && ( +
+ + 报告正文实时输出,右侧沙箱同步预览 Markdown +
+ )} + + {phase === "error" && ( +
+
总结失败
+
{error}
+ +
+ )} + + {phase === "done" && ( +
+ + 已生成方案总结报告,右侧可预览 / 下载 .md;可在下方继续提修改意见或提问。 +
+ )} +
+ {!atBottom && } +
+ {/* 已选附件预览(发送时随指令上传到总结线程,供智能体读取) */} + {askFiles.length > 0 && ( +
+ {askFiles.map((file, idx) => ( + + + + {file.name} + + {formatBytes(file.size)} + + + ))} +
+ )} +
+