Merge branch 'ji' of http://192.168.0.200/knowledge-base-bj/deerflow-server into ji
This commit is contained in:
commit
11bc442f10
22 files changed
+2563
-41
No files matched your search
@@ -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 (
|
||||
<Dialog open={!!filter} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{filter?.kind === "entity" ? (
|
||||
<Sparkles className="size-4 text-violet-500" />
|
||||
) : (
|
||||
<Hash className="size-4 text-rose-500" />
|
||||
)}
|
||||
<span className="truncate">{filter?.value}</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{filter?.kind === "entity"
|
||||
? "提到该实体的关联知识"
|
||||
: "使用该标签的关联知识"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="max-h-[55vh]">
|
||||
{isLoading || (isFetching && items.length === 0) ? (
|
||||
<div className="text-muted-foreground flex items-center gap-2 p-4 text-sm">
|
||||
<Loader2 className="size-4 animate-spin" /> 加载中...
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="text-muted-foreground rounded-md border border-dashed p-6 text-center text-sm">
|
||||
暂无其他关联知识。
|
||||
</div>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1 pr-2">
|
||||
{items.map((note) => (
|
||||
<li key={note.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(note.id)}
|
||||
className="flex w-full flex-col gap-0.5 rounded-md border border-transparent px-3 py-2 text-left transition-colors hover:border-slate-200 hover:bg-slate-50 dark:hover:border-slate-700 dark:hover:bg-slate-800/60"
|
||||
>
|
||||
<span className="flex items-center gap-2 text-sm font-medium text-slate-800 dark:text-slate-100">
|
||||
<FileText className="size-3.5 shrink-0 text-slate-400" />
|
||||
<span className="truncate">{note.title}</span>
|
||||
</span>
|
||||
{note.summary ? (
|
||||
<span className="line-clamp-2 pl-[1.375rem] text-xs text-slate-500 dark:text-slate-400">
|
||||
{note.summary}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="text-muted-foreground pl-[1.375rem] text-[11px]">
|
||||
{SOURCE_TYPE_LABELS[note.source_type] ?? note.source_type}
|
||||
{note.updated_at
|
||||
? ` · ${formatDateTime(note.updated_at).slice(0, 10)}`
|
||||
: ""}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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<string>(note.status);
|
||||
const [activeHeadingId, setActiveHeadingId] = useState<string | null>(null);
|
||||
// Tag / entity chip clicked → show notes that share it.
|
||||
const [relatedFilter, setRelatedFilter] = useState<RelatedNotesFilter | null>(
|
||||
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({
|
||||
<MetaTag>创建:{formatDateTime(note.created_at).slice(0, 10)}</MetaTag>
|
||||
) : null}
|
||||
{note.tags.map((tag) => (
|
||||
<span
|
||||
<button
|
||||
key={tag}
|
||||
className="inline-flex h-6 items-center rounded-md bg-rose-50 px-2 text-xs font-medium text-rose-500 dark:bg-rose-500/10 dark:text-rose-300"
|
||||
type="button"
|
||||
title={`查看「${tag}」的关联知识`}
|
||||
onClick={() => 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}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -287,11 +298,15 @@ export function WikiContent({
|
||||
{note.entities.map((ent) => {
|
||||
const meta = entityMeta(ent.entity_type);
|
||||
return (
|
||||
<span
|
||||
<button
|
||||
key={`${ent.name}-${ent.entity_type ?? ""}`}
|
||||
title={meta.label}
|
||||
type="button"
|
||||
title={`查看与「${ent.name}」(${meta.label})相关的知识`}
|
||||
onClick={() =>
|
||||
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({
|
||||
<span className="rounded-full bg-current/15 px-1.5 py-0.5 text-[10px] leading-none opacity-90">
|
||||
{meta.label}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -373,6 +388,18 @@ export function WikiContent({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RelatedNotesDialog
|
||||
filter={relatedFilter}
|
||||
currentNoteId={note.id}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setRelatedFilter(null);
|
||||
}}
|
||||
onSelect={(id) => {
|
||||
setRelatedFilter(null);
|
||||
navigate(`/page/workspace/knowledge/${id}`);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 <id> 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 <id> 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.
|
||||
|
||||
@@ -468,6 +468,8 @@ export default function KnowledgeBasePage() {
|
||||
const [status, setStatus] = useState("all");
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(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;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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<ReportThreadResponse> {
|
||||
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 };
|
||||
}
|
||||
@@ -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<HighFidelityReportProps> = ({
|
||||
setSelectedModel,
|
||||
savedReport,
|
||||
onReportSaved,
|
||||
coordinatorPrompt,
|
||||
savedSummary,
|
||||
onSummarySaved,
|
||||
onReset,
|
||||
openArtifactPreview,
|
||||
seatStubMessages,
|
||||
@@ -115,6 +128,9 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
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<HighFidelityReportProps> = ({
|
||||
// 不会自动关)。点「结果绘制」后 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<HighFidelityReportProps> = ({
|
||||
[],
|
||||
);
|
||||
|
||||
if (summaryMode) {
|
||||
return (
|
||||
<Step3SummaryMode
|
||||
threadIds={threadIds}
|
||||
coordinatorName={coordinatorName}
|
||||
consensusText={consensusText}
|
||||
hasConsensus={hasConsensus}
|
||||
seatDeliveries={seatDeliveries}
|
||||
seatNameMap={seatNameMap}
|
||||
intentReady={intentReady}
|
||||
selectedModel={selectedModel}
|
||||
availableModels={availableModels}
|
||||
modelsLoading={modelsLoading}
|
||||
setSelectedModel={setSelectedModel}
|
||||
coordinatorPrompt={coordinatorPrompt}
|
||||
savedSummary={savedSummary}
|
||||
onSummarySaved={onSummarySaved}
|
||||
onViewStatic={() => setSummaryMode(false)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (fileSandboxMode && fileSandboxTarget) {
|
||||
return (
|
||||
<Step3FileBrowseMode
|
||||
@@ -332,10 +373,19 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSummaryMode(true)}
|
||||
title="返回「方案总结报告」(Markdown 总结 + 问答/修改)"
|
||||
className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition cursor-pointer"
|
||||
>
|
||||
<FileText className="w-3.5 h-3.5 text-slate-500" />
|
||||
总结报告
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDrawMode(true)}
|
||||
title="把研讨成果交给方案总结智能体,绘制带图表的可视化看板"
|
||||
title="把研讨成果交给方案可视化智能体,绘制带图表的可视化看板"
|
||||
className="bg-primary text-white hover:bg-[var(--brand-900)] text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition cursor-pointer shadow-sm shadow-primary/10"
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
* 拉取 / 文本类预览 / window.open 下载 / 「重新起草」按钮等。
|
||||
*/
|
||||
import { HighFidelityReport } from "@/roundtable-planning/components/HighFidelityReport";
|
||||
import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts";
|
||||
import type {
|
||||
DraftStep3Snapshot,
|
||||
DraftStep3SummarySnapshot,
|
||||
} from "@/roundtable-planning/api/drafts";
|
||||
import type { ModelOption } from "@/roundtable-planning/components/Step3ReportMode";
|
||||
import type { Step2Dialogue } from "@/roundtable-planning/hooks/useStep2Orchestration";
|
||||
import type { ReportIntent } from "@/roundtable-planning/hooks/useStep3Report";
|
||||
@@ -34,6 +37,11 @@ export interface Step3PanelProps {
|
||||
/** 草稿里已存的上次绘制结果 + 完成回调 —— 透传给「结果绘制」做持久化。 */
|
||||
savedReport?: DraftStep3Snapshot | null;
|
||||
onReportSaved?: (snapshot: DraftStep3Snapshot) => void;
|
||||
/** 业务链写给总控的编排提示 —— 注入「总结报告」智能体。 */
|
||||
coordinatorPrompt?: string | null;
|
||||
/** 草稿里已存的上次总结结果 + 完成回调 —— 透传给「总结报告」做持久化。 */
|
||||
savedSummary?: DraftStep3SummarySnapshot | null;
|
||||
onSummarySaved?: (snapshot: DraftStep3SummarySnapshot) => void;
|
||||
onReset: () => void;
|
||||
/** Step 2 沙箱预览产出文件(与第二步相同)。 */
|
||||
openArtifactPreview: (threadId: string, path: string) => void;
|
||||
@@ -55,6 +63,9 @@ export function Step3Panel(props: Step3PanelProps) {
|
||||
setSelectedModel,
|
||||
savedReport,
|
||||
onReportSaved,
|
||||
coordinatorPrompt,
|
||||
savedSummary,
|
||||
onSummarySaved,
|
||||
onReset,
|
||||
openArtifactPreview,
|
||||
seatStubMessages,
|
||||
@@ -87,6 +98,9 @@ export function Step3Panel(props: Step3PanelProps) {
|
||||
setSelectedModel={setSelectedModel}
|
||||
savedReport={savedReport}
|
||||
onReportSaved={onReportSaved}
|
||||
coordinatorPrompt={coordinatorPrompt}
|
||||
savedSummary={savedSummary}
|
||||
onSummarySaved={onSummarySaved}
|
||||
onReset={onReset}
|
||||
openArtifactPreview={openArtifactPreview}
|
||||
seatStubMessages={seatStubMessages}
|
||||
|
||||
@@ -0,0 +1,914 @@
|
||||
/**
|
||||
* Step 3「总结报告」模式 —— 第 3 步**主页**(进第三步默认自动跑这个)。
|
||||
*
|
||||
* 由内置的「方案总结报告智能体」(`roundtable-summary`) 把各席位交付 + 总控共识
|
||||
* 总结成一份 **Markdown 报告**(写入 outputs/方案总结报告.md),报告正文同时流式
|
||||
* 输出到左侧对话区;右侧沙箱预览 md 文件。底部输入框让用户继续对话——智能体自行
|
||||
* 判断是「增量改报告」还是「问答」。
|
||||
*
|
||||
* 布局对齐 Step3ReportMode(HTML 绘制):左栏研讨原料源卡 + 右栏沙箱。两者并存、
|
||||
* 互不影响(report 画 HTML 看板,summary 写 Markdown 报告 + 问答)。
|
||||
*/
|
||||
import { useEffect, useMemo, useRef, useState, type FormEvent } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import {
|
||||
ArrowRight,
|
||||
CheckCircle,
|
||||
Download,
|
||||
Eye,
|
||||
FileText,
|
||||
Loader2,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
Paperclip,
|
||||
Pause,
|
||||
RefreshCw,
|
||||
SendHorizontal,
|
||||
Sparkles,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
import { useArtifacts } from "@/components/workspace/artifacts";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
buildArtifactDownloadUrl,
|
||||
listSessionArtifacts,
|
||||
type SessionArtifactFile,
|
||||
type ThreadIdMap,
|
||||
} from "@/roundtable-planning/api/multi-agent";
|
||||
import { ArtifactPreviewModal } from "@/roundtable-planning/components/ArtifactPreviewModal";
|
||||
import { ConsensusDocxDownloadButton } from "@/roundtable-planning/components/ConsensusDocxDownloadButton";
|
||||
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
|
||||
import { Step2Message } from "@/roundtable-planning/components/Step2Panel";
|
||||
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
|
||||
import { markdownComponents } from "@/roundtable-planning/components/markdown-components";
|
||||
import type { ModelOption } from "@/roundtable-planning/components/Step3ReportMode";
|
||||
import type { DraftStep3SummarySnapshot } from "@/roundtable-planning/api/drafts";
|
||||
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
|
||||
import { useStep3Summary } from "@/roundtable-planning/hooks/useStep3Summary";
|
||||
import type { ReportIntent } from "@/roundtable-planning/hooks/useStep3Report";
|
||||
|
||||
interface SeatDelivery {
|
||||
sender: string;
|
||||
avatarType: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface Step3SummaryModeProps {
|
||||
threadIds: ThreadIdMap | null;
|
||||
coordinatorName: string | null;
|
||||
consensusText: string;
|
||||
hasConsensus: boolean;
|
||||
seatDeliveries: SeatDelivery[];
|
||||
seatNameMap: Record<string, string>;
|
||||
intentReady: ReportIntent | null;
|
||||
selectedModel: string;
|
||||
availableModels: ReadonlyArray<ModelOption>;
|
||||
modelsLoading: boolean;
|
||||
setSelectedModel: (model: string) => void;
|
||||
/** 业务链写给总控的编排提示 —— 注入总结智能体作为最高优先级口径。 */
|
||||
coordinatorPrompt?: string | null;
|
||||
/** 草稿里已存的上次总结结果 —— 有则进入时恢复展示,不重新生成。 */
|
||||
savedSummary?: DraftStep3SummarySnapshot | null;
|
||||
/** 一次总结完成时回调,把结果交给上层存进草稿 step3.summaryReport。 */
|
||||
onSummarySaved?: (snapshot: DraftStep3SummarySnapshot) => void;
|
||||
/** 切到「最终方案展示」静态页(次要入口)。 */
|
||||
onViewStatic: () => void;
|
||||
}
|
||||
|
||||
type SourceModal = "consensus" | "seats" | "files" | null;
|
||||
|
||||
export function Step3SummaryMode({
|
||||
threadIds,
|
||||
coordinatorName,
|
||||
consensusText,
|
||||
hasConsensus,
|
||||
seatDeliveries,
|
||||
seatNameMap,
|
||||
intentReady,
|
||||
selectedModel,
|
||||
availableModels,
|
||||
modelsLoading,
|
||||
setSelectedModel,
|
||||
coordinatorPrompt,
|
||||
savedSummary,
|
||||
onSummarySaved,
|
||||
onViewStatic,
|
||||
}: Step3SummaryModeProps) {
|
||||
const summarySeatDeliveries = useMemo(
|
||||
() => seatDeliveries.map((d) => ({ sender: d.sender, content: d.content })),
|
||||
[seatDeliveries],
|
||||
);
|
||||
|
||||
const consensusDocTitle = useMemo(() => {
|
||||
const objective = intentReady?.objective?.trim();
|
||||
if (!objective) return "圆桌总控最终结论";
|
||||
const short = objective.length > 40 ? `${objective.slice(0, 40)}…` : objective;
|
||||
return `圆桌总控结论-${short}`;
|
||||
}, [intentReady?.objective]);
|
||||
|
||||
const {
|
||||
phase,
|
||||
error,
|
||||
summaryThreadId,
|
||||
dialogues,
|
||||
seatStubMessages,
|
||||
activeArtifactThreadId,
|
||||
busy,
|
||||
start,
|
||||
hydrateSaved,
|
||||
redraw,
|
||||
ask,
|
||||
pause,
|
||||
abort,
|
||||
openArtifactFromStep,
|
||||
openArtifactPreview,
|
||||
} = useStep3Summary({
|
||||
intentReady,
|
||||
seatDeliveries: summarySeatDeliveries,
|
||||
consensusText,
|
||||
selectedModel,
|
||||
coordinatorPrompt,
|
||||
savedSummary,
|
||||
onSummarySaved,
|
||||
});
|
||||
|
||||
const [modal, setModal] = useState<SourceModal>(null);
|
||||
|
||||
const { open: artifactsOpen } = useArtifacts();
|
||||
const [leftCollapsed, setLeftCollapsed] = useState(false);
|
||||
const prevArtifactsOpenRef = useRef(artifactsOpen);
|
||||
useEffect(() => {
|
||||
if (artifactsOpen !== prevArtifactsOpenRef.current) {
|
||||
prevArtifactsOpenRef.current = artifactsOpen;
|
||||
setLeftCollapsed(artifactsOpen);
|
||||
}
|
||||
}, [artifactsOpen]);
|
||||
|
||||
// 进入即自动开跑;草稿里已存结果则恢复展示。卸载中断在途请求(理由同 Step3ReportMode)。
|
||||
useEffect(() => {
|
||||
if (savedSummary?.md) {
|
||||
hydrateSaved(savedSummary);
|
||||
} else {
|
||||
start();
|
||||
}
|
||||
return () => abort();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const isStreaming =
|
||||
phase === "creating" ||
|
||||
phase === "streaming" ||
|
||||
dialogues.some((d) => d.streaming === true);
|
||||
const activeSeatMessages = activeArtifactThreadId
|
||||
? seatStubMessages[activeArtifactThreadId]
|
||||
: undefined;
|
||||
|
||||
const statusLabel =
|
||||
phase === "creating"
|
||||
? "正在初始化总结线程…"
|
||||
: phase === "streaming"
|
||||
? "方案总结报告智能体撰写中…"
|
||||
: phase === "done"
|
||||
? "总结完成"
|
||||
: phase === "error"
|
||||
? "总结失败"
|
||||
: "待总结";
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col text-slate-800">
|
||||
{/* 顶栏 */}
|
||||
<div className="shrink-0 flex items-center justify-between gap-3 border-b border-slate-100 bg-gradient-to-r from-slate-50 to-white px-6 py-3.5 flex-wrap">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Sparkles className="w-5 h-5 text-primary" />
|
||||
<h3 className="text-sm font-black text-slate-900 tracking-tight">方案总结 · 报告</h3>
|
||||
<span className="inline-flex items-center gap-1.5 text-[10px] font-bold text-primary bg-[var(--brand-200)]/60 border border-red-200/50 px-2 py-0.5 rounded-full">
|
||||
{(phase === "creating" || phase === "streaming") && (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
)}
|
||||
{phase === "done" && <CheckCircle className="w-3 h-3 text-emerald-600" />}
|
||||
{statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => redraw()}
|
||||
disabled={phase === "creating" || phase === "streaming"}
|
||||
className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5 text-slate-500" />
|
||||
重新生成
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onViewStatic}
|
||||
className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition"
|
||||
>
|
||||
最终方案展示
|
||||
<ArrowRight className="w-3.5 h-3.5 text-slate-500" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 主体:左源卡 + 右沙箱 */}
|
||||
<div className="flex-1 min-h-0 flex gap-3 p-3">
|
||||
<aside
|
||||
className={cn(
|
||||
"shrink-0 flex flex-col gap-3 overflow-y-auto pr-0.5 transition-[width] duration-200 ease-out",
|
||||
leftCollapsed ? "w-[52px]" : "w-[280px]",
|
||||
)}
|
||||
>
|
||||
{leftCollapsed ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLeftCollapsed(false)}
|
||||
title="展开研讨原料"
|
||||
aria-expanded={false}
|
||||
className="w-full shrink-0 flex h-10 items-center justify-center rounded-2xl border border-slate-200/50 bg-white text-slate-500 hover:text-primary hover:border-primary/30 transition-colors shadow-[0_2px_8px_rgba(0,0,0,0.01)]"
|
||||
>
|
||||
<PanelLeftOpen className="h-4 w-4" />
|
||||
</button>
|
||||
<CollapsedSourceCard
|
||||
icon={<CheckCircle className="w-4 h-4 text-emerald-500" />}
|
||||
title="总控最终结论"
|
||||
disabled={!consensusText}
|
||||
onClick={() => setModal("consensus")}
|
||||
/>
|
||||
<CollapsedSourceCard
|
||||
icon={<FileText className="w-4 h-4 text-primary" />}
|
||||
title="各席位最终交付"
|
||||
disabled={seatDeliveries.length === 0}
|
||||
onClick={() => setModal("seats")}
|
||||
/>
|
||||
<CollapsedSourceCard
|
||||
icon={<Download className="w-4 h-4 text-primary" />}
|
||||
title="各席位生成文件"
|
||||
onClick={() => setModal("files")}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="shrink-0 flex items-center justify-between gap-2 px-1">
|
||||
<span className="text-[11px] font-bold text-slate-500 uppercase tracking-widest">
|
||||
研讨原料
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLeftCollapsed(true)}
|
||||
title="收起研讨原料"
|
||||
aria-expanded
|
||||
className="shrink-0 flex h-7 w-7 items-center justify-center rounded-lg border border-slate-200/50 bg-white text-slate-500 hover:text-primary hover:border-primary/30 transition-colors"
|
||||
>
|
||||
<PanelLeftClose className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<SourceCard
|
||||
icon={<CheckCircle className="w-4 h-4 text-emerald-500" />}
|
||||
title="总控最终结论"
|
||||
hint={consensusText ? "点击查看结论全文" : "暂无结论"}
|
||||
badge={hasConsensus ? "已共识" : undefined}
|
||||
disabled={!consensusText}
|
||||
onClick={() => setModal("consensus")}
|
||||
/>
|
||||
<SourceCard
|
||||
icon={<FileText className="w-4 h-4 text-primary" />}
|
||||
title="各席位最终交付"
|
||||
hint="点击逐席位查看交付"
|
||||
badge={String(seatDeliveries.length)}
|
||||
disabled={seatDeliveries.length === 0}
|
||||
onClick={() => setModal("seats")}
|
||||
/>
|
||||
<SourceCard
|
||||
icon={<Download className="w-4 h-4 text-primary" />}
|
||||
title="各席位生成文件"
|
||||
hint="点击查看/预览产出文件"
|
||||
onClick={() => setModal("files")}
|
||||
/>
|
||||
|
||||
<div className="mt-1 rounded-2xl border border-dashed border-slate-200 bg-slate-50/60 p-3 text-[11px] leading-relaxed text-slate-400">
|
||||
右侧由「方案总结报告智能体」据这些原料撰写 Markdown 总结报告,可在下方输入框继续提修改意见或提问。
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{/* 右栏:智能体进度 + 沙箱(复用第 2 步) */}
|
||||
<section className="flex-1 min-w-0 rounded-2xl border border-slate-200/60 bg-white overflow-hidden">
|
||||
<Step2SandboxLayout
|
||||
activeThreadId={activeArtifactThreadId ?? summaryThreadId}
|
||||
isStreaming={isStreaming}
|
||||
seatMessages={activeSeatMessages}
|
||||
>
|
||||
<AgentProgressPanel
|
||||
phase={phase}
|
||||
dialogues={dialogues}
|
||||
error={error}
|
||||
busy={busy}
|
||||
onRetry={redraw}
|
||||
onAsk={ask}
|
||||
onPause={pause}
|
||||
onArtifactStepClick={openArtifactFromStep}
|
||||
onArtifactPreview={openArtifactPreview}
|
||||
selectedModel={selectedModel}
|
||||
availableModels={availableModels}
|
||||
modelsLoading={modelsLoading}
|
||||
setSelectedModel={setSelectedModel}
|
||||
/>
|
||||
</Step2SandboxLayout>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* 弹窗 */}
|
||||
{modal === "consensus" && (
|
||||
<ContentModal
|
||||
title="总控最终结论"
|
||||
onClose={() => setModal(null)}
|
||||
headerAction={
|
||||
<ConsensusDocxDownloadButton markdown={consensusText} title={consensusDocTitle} />
|
||||
}
|
||||
>
|
||||
{consensusText ? (
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
|
||||
{consensusText}
|
||||
</ReactMarkdown>
|
||||
) : (
|
||||
<p className="text-xs text-slate-400 italic">暂无总控结论。</p>
|
||||
)}
|
||||
</ContentModal>
|
||||
)}
|
||||
|
||||
{modal === "seats" && (
|
||||
<ContentModal title={`各席位最终交付(${seatDeliveries.length})`} onClose={() => setModal(null)}>
|
||||
<SeatDeliveriesView seatDeliveries={seatDeliveries} />
|
||||
</ContentModal>
|
||||
)}
|
||||
|
||||
{modal === "files" && (
|
||||
<SeatFilesModal
|
||||
threadIds={threadIds}
|
||||
coordinatorName={coordinatorName}
|
||||
seatNameMap={seatNameMap}
|
||||
onClose={() => setModal(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 左栏源卡 ─────────────────────────────────────────────────────────────
|
||||
|
||||
function SourceCard({
|
||||
icon,
|
||||
title,
|
||||
hint,
|
||||
badge,
|
||||
disabled,
|
||||
onClick,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
hint: string;
|
||||
badge?: string;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className="text-left bg-white border border-slate-200/60 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)] transition hover:border-primary/40 hover:shadow-[0_4px_14px_rgba(0,0,0,0.04)] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:border-slate-200/60"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
{icon}
|
||||
<h4 className="text-xs font-bold text-slate-900 tracking-wide flex-1 truncate">{title}</h4>
|
||||
{badge && (
|
||||
<span className="text-[10px] font-mono font-semibold text-primary bg-[var(--brand-200)]/50 px-1.5 py-0.5 rounded">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-400 leading-normal">{hint}</p>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function CollapsedSourceCard({
|
||||
icon,
|
||||
title,
|
||||
disabled,
|
||||
onClick,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={title}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className="w-full shrink-0 flex items-center justify-center rounded-2xl border border-slate-200/50 bg-white p-2.5 text-slate-500 shadow-[0_2px_8px_rgba(0,0,0,0.01)] transition hover:text-primary hover:border-primary/30 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 右栏:智能体进度面板(沙箱的 60% 区域)+ 底部对话输入框 ──────────────────
|
||||
|
||||
function AgentProgressPanel({
|
||||
phase,
|
||||
dialogues,
|
||||
error,
|
||||
busy,
|
||||
onRetry,
|
||||
onAsk,
|
||||
onPause,
|
||||
onArtifactStepClick,
|
||||
onArtifactPreview,
|
||||
selectedModel,
|
||||
availableModels,
|
||||
modelsLoading,
|
||||
setSelectedModel,
|
||||
}: {
|
||||
phase: ReturnType<typeof useStep3Summary>["phase"];
|
||||
dialogues: ReturnType<typeof useStep3Summary>["dialogues"];
|
||||
error: string | null;
|
||||
busy: boolean;
|
||||
onRetry: () => void;
|
||||
onAsk: (message: string, files?: File[]) => void;
|
||||
onPause: () => void;
|
||||
onArtifactStepClick: ReturnType<typeof useStep3Summary>["openArtifactFromStep"];
|
||||
onArtifactPreview: ReturnType<typeof useStep3Summary>["openArtifactPreview"];
|
||||
selectedModel: string;
|
||||
availableModels: ReadonlyArray<ModelOption>;
|
||||
modelsLoading: boolean;
|
||||
setSelectedModel: (model: string) => void;
|
||||
}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const { atBottom, scrollToBottom } = useAutoScroll(scrollRef, [dialogues]);
|
||||
const [askText, setAskText] = useState("");
|
||||
// 输入框携带的本地附件(点发送时连同指令一起上传到总结线程)。
|
||||
const [askFiles, setAskFiles] = useState<File[]>([]);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const pickFiles = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
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<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const text = askText.trim();
|
||||
const hasFiles = askFiles.length > 0;
|
||||
if (!text && !hasFiles) return; // 允许「随时」发送(含生成中);空内容不发
|
||||
setAskText("");
|
||||
setAskFiles([]);
|
||||
onAsk(text, hasFiles ? askFiles : undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col bg-slate-50/20">
|
||||
<div className="shrink-0 flex items-center justify-between gap-2 border-b border-slate-100 bg-white px-4 py-2.5">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="w-6 h-6 shrink-0 rounded-lg bg-gradient-to-tr from-primary to-[var(--brand-200)] flex items-center justify-center text-white text-xs shadow-sm">
|
||||
📝
|
||||
</div>
|
||||
<div className="leading-tight min-w-0">
|
||||
<div className="text-xs font-bold text-slate-800 truncate">方案总结报告智能体</div>
|
||||
<div className="text-[10px] text-slate-400 truncate">据各席位成果撰写 Markdown 总结报告</div>
|
||||
</div>
|
||||
</div>
|
||||
<select
|
||||
id="step3-summary-model-select"
|
||||
value={selectedModel}
|
||||
onChange={(e) => setSelectedModel(e.target.value)}
|
||||
disabled={busy || modelsLoading || availableModels.length === 0}
|
||||
title={
|
||||
modelsLoading
|
||||
? "正在加载模型列表…"
|
||||
: availableModels.length === 0
|
||||
? "未在 config.yaml 中配置任何模型"
|
||||
: busy
|
||||
? "撰写中不可切换;完成后或重新生成时生效"
|
||||
: "切换撰写使用的推理模型(下次生成生效)"
|
||||
}
|
||||
className="shrink-0 max-w-[180px] bg-slate-50/50 border border-slate-200 rounded-xl px-2.5 py-1.5 text-[11px] font-bold text-slate-700 cursor-pointer focus:outline-none focus:border-primary focus:ring-1 focus:ring-[var(--brand-200)]/50 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-secondary dark:text-foreground dark:border-border"
|
||||
>
|
||||
{availableModels.length === 0 && (
|
||||
<option value="">{modelsLoading ? "加载中…" : "未配置模型"}</option>
|
||||
)}
|
||||
{availableModels.map((m) => (
|
||||
<option key={m.name} value={m.name}>
|
||||
{m.display_name && m.display_name !== m.name
|
||||
? `${m.display_name} (${m.name})`
|
||||
: m.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 消息列表 —— 与第 2 步同款气泡(流式正文 + 工具步骤卡 + 文件卡)。 */}
|
||||
<div className="relative flex-1 min-h-0 flex flex-col">
|
||||
<div ref={scrollRef} className="flex-1 min-h-0 overflow-y-auto p-4 space-y-4 bg-white">
|
||||
{phase === "creating" && dialogues.length === 0 && (
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500">
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin text-primary" />
|
||||
正在初始化总结线程…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dialogues.map((msg) => (
|
||||
<Step2Message
|
||||
key={msg.id}
|
||||
msg={msg}
|
||||
onArtifactStepClick={(step) => onArtifactStepClick(msg, step)}
|
||||
onFileCardClick={(path) => {
|
||||
if (msg.agentThreadId) onArtifactPreview(msg.agentThreadId, path);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{busy && (
|
||||
<div className="inline-flex items-center gap-2 rounded-full border bg-white px-2.5 py-1 text-[11px] text-slate-500">
|
||||
<span className="bg-primary size-1.5 animate-pulse rounded-full" />
|
||||
报告正文实时输出,右侧沙箱同步预览 Markdown
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === "error" && (
|
||||
<div className="rounded-xl border border-rose-200 bg-rose-50 p-3 text-xs text-rose-700">
|
||||
<div className="font-bold mb-1">总结失败</div>
|
||||
<div className="leading-relaxed break-all">{error}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
className="mt-2 inline-flex items-center gap-1 text-[11px] font-bold text-rose-700 underline hover:text-rose-800"
|
||||
>
|
||||
<RefreshCw className="w-3 h-3" />
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === "done" && (
|
||||
<div className="flex items-center gap-2 text-xs font-bold text-emerald-600">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
已生成方案总结报告,右侧可预览 / 下载 .md;可在下方继续提修改意见或提问。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!atBottom && <ScrollToBottomButton onClick={scrollToBottom} />}
|
||||
<form onSubmit={handleAskSubmit} className="shrink-0 border-t border-slate-100 bg-white px-3 py-3">
|
||||
{/* 已选附件预览(发送时随指令上传到总结线程,供智能体读取) */}
|
||||
{askFiles.length > 0 && (
|
||||
<div className="mb-2 flex flex-wrap gap-1.5">
|
||||
{askFiles.map((file, idx) => (
|
||||
<span
|
||||
key={`${file.name}-${file.size}-${idx}`}
|
||||
className="inline-flex items-center gap-1.5 bg-white border border-slate-200 rounded-lg pl-2 pr-1 py-1 text-[11px] text-slate-600 max-w-[200px]"
|
||||
>
|
||||
<FileText className="w-3 h-3 shrink-0 text-primary" />
|
||||
<span className="truncate font-medium" title={file.name}>
|
||||
{file.name}
|
||||
</span>
|
||||
<span className="shrink-0 text-slate-400">{formatBytes(file.size)}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeFile(idx)}
|
||||
className="shrink-0 p-0.5 rounded hover:bg-slate-200 text-slate-400 hover:text-slate-600 transition"
|
||||
title="移除"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-end gap-2 rounded-2xl border border-slate-200 bg-slate-50/70 px-3 py-2 focus-within:border-primary/60 focus-within:bg-white focus-within:ring-2 focus-within:ring-[var(--brand-200)]/40">
|
||||
<textarea
|
||||
value={askText}
|
||||
onChange={(e) => setAskText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
e.currentTarget.form?.requestSubmit();
|
||||
}
|
||||
}}
|
||||
rows={1}
|
||||
placeholder={
|
||||
busy
|
||||
? "生成中也可随时插话干预:发送将打断当前轮并据此继续…"
|
||||
: "继续对话:提修改意见(如「执行路线写细些」)/ 提问 / 上传参考文件"
|
||||
}
|
||||
className="min-h-8 max-h-28 flex-1 resize-none bg-transparent py-1.5 text-xs leading-relaxed text-slate-800 placeholder:text-slate-400 focus:outline-none"
|
||||
/>
|
||||
|
||||
{/* 上传文件(参考第一/二步:本地选取,发送时上传到总结线程) */}
|
||||
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={pickFiles} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
title="上传参考文件(智能体将读取文件内容辅助总结/修改)"
|
||||
className="mb-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-white border border-slate-200 text-slate-500 hover:text-primary hover:bg-slate-50 transition active:scale-95"
|
||||
>
|
||||
<Paperclip className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{/* 暂停(仅生成中显示) */}
|
||||
{busy && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPause}
|
||||
title="暂停当前生成"
|
||||
className="mb-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-amber-100 border border-amber-200/60 text-amber-700 hover:bg-amber-200 transition active:scale-95"
|
||||
>
|
||||
<Pause className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 发送(随时可发:生成中发送=打断并续问) */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={askText.trim().length === 0 && askFiles.length === 0}
|
||||
title={busy ? "打断当前轮并发送" : "发送(智能体自行判断改报告 or 回答)"}
|
||||
className="mb-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-primary text-white transition hover:bg-[var(--brand-900)] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<SendHorizontal className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 弹窗外壳 ─────────────────────────────────────────────────────────────
|
||||
|
||||
function ContentModal({
|
||||
title,
|
||||
children,
|
||||
onClose,
|
||||
headerAction,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
onClose: () => void;
|
||||
headerAction?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-[1px] p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-white border border-slate-200 rounded-2xl shadow-xl w-full max-w-2xl max-h-[80vh] flex flex-col overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="shrink-0 flex items-center justify-between gap-2 px-5 py-3.5 border-b border-slate-100 bg-slate-50">
|
||||
<h3 className="text-sm font-bold text-slate-900 min-w-0 truncate">{title}</h3>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{headerAction}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1 text-slate-400 hover:text-slate-600 rounded-md hover:bg-slate-100"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-5">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 各席位交付(弹窗内的折叠列表)────────────────────────────────────────
|
||||
|
||||
function SeatDeliveriesView({ seatDeliveries }: { seatDeliveries: SeatDelivery[] }) {
|
||||
const [openIdx, setOpenIdx] = useState<number | null>(0);
|
||||
if (seatDeliveries.length === 0) {
|
||||
return <p className="text-xs text-slate-400 italic">本次研讨尚未产生子智能体交付。</p>;
|
||||
}
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{seatDeliveries.map((seat, idx) => {
|
||||
const open = openIdx === idx;
|
||||
return (
|
||||
<div key={`${seat.sender}-${idx}`} className="border border-slate-150 rounded-xl overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpenIdx(open ? null : idx)}
|
||||
className="w-full flex items-center justify-between gap-2 px-3 py-2.5 bg-slate-50 hover:bg-slate-100 transition text-left"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<FileText className="w-3.5 h-3.5 text-slate-400 shrink-0" />
|
||||
<span className="text-[11.5px] font-bold text-slate-800 truncate">{seat.sender}</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-400 font-mono shrink-0">{seat.content.length} 字</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="px-4 py-3 text-xs text-slate-700 leading-relaxed border-t border-slate-100 bg-white">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
|
||||
{seat.content || "(空)"}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 各席位生成文件(弹窗,按需拉取 outputs 列表)──────────────────────────
|
||||
|
||||
interface SeatArtifacts {
|
||||
agentName: string;
|
||||
displayName: string;
|
||||
threadId: string;
|
||||
files: SessionArtifactFile[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
function shouldPreviewInModal(file: { name: string; mimeType: string | null }): boolean {
|
||||
const mime = file.mimeType ?? "";
|
||||
if (mime.startsWith("text/")) return true;
|
||||
if (["application/json"].includes(mime)) return true;
|
||||
return /\.(md|markdown|mmd|txt|json|csv|log|yml|yaml|xml|html?)$/i.test(file.name);
|
||||
}
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function SeatFilesModal({
|
||||
threadIds,
|
||||
coordinatorName,
|
||||
seatNameMap,
|
||||
onClose,
|
||||
}: {
|
||||
threadIds: ThreadIdMap | null;
|
||||
coordinatorName: string | null;
|
||||
seatNameMap: Record<string, string>;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const seatEntries = useMemo(() => {
|
||||
if (!threadIds) return [] as Array<{ agentName: string; threadId: string; displayName: string }>;
|
||||
return Object.entries(threadIds)
|
||||
.filter(([name]) => !coordinatorName || name !== coordinatorName)
|
||||
.map(([agentName, threadId]) => ({
|
||||
agentName,
|
||||
threadId,
|
||||
displayName: seatNameMap[agentName.toLowerCase()] ?? agentName,
|
||||
}));
|
||||
}, [threadIds, coordinatorName, seatNameMap]);
|
||||
|
||||
const [bySeat, setBySeat] = useState<SeatArtifacts[]>([]);
|
||||
const [previewing, setPreviewing] = useState<{ threadId: string; file: SessionArtifactFile } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (seatEntries.length === 0) {
|
||||
setBySeat([]);
|
||||
return;
|
||||
}
|
||||
setBySeat(seatEntries.map((s) => ({ ...s, files: [], loading: true, error: null })));
|
||||
const abort = new AbortController();
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const results = await Promise.all(
|
||||
seatEntries.map(async (seat) => {
|
||||
try {
|
||||
const res = await listSessionArtifacts(seat.threadId, { signal: abort.signal });
|
||||
return { seat, files: res.files, error: null as string | null };
|
||||
} catch (err) {
|
||||
if ((err as Error).name === "AbortError") {
|
||||
return { seat, files: [] as SessionArtifactFile[], error: null };
|
||||
}
|
||||
return { seat, files: [] as SessionArtifactFile[], error: (err as Error).message };
|
||||
}
|
||||
}),
|
||||
);
|
||||
if (cancelled) return;
|
||||
setBySeat(results.map(({ seat, files, error }) => ({ ...seat, files, loading: false, error })));
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
abort.abort();
|
||||
};
|
||||
}, [seatEntries]);
|
||||
|
||||
const handlePreview = (threadId: string, file: SessionArtifactFile) => {
|
||||
if (shouldPreviewInModal({ name: file.name, mimeType: file.mimeType })) {
|
||||
setPreviewing({ threadId, file });
|
||||
return;
|
||||
}
|
||||
if (typeof window !== "undefined") {
|
||||
window.open(buildArtifactDownloadUrl(threadId, file.path), "_blank", "noopener,noreferrer");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = (threadId: string, file: SessionArtifactFile) => {
|
||||
if (typeof window !== "undefined") {
|
||||
window.open(
|
||||
buildArtifactDownloadUrl(threadId, file.path, { download: true }),
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContentModal title="各席位生成文件" onClose={onClose}>
|
||||
{bySeat.length === 0 ? (
|
||||
<p className="text-xs text-slate-400 italic">尚未初始化席位线程,无法列出文件。</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{bySeat.map((seat) => (
|
||||
<div key={seat.agentName} className="border border-slate-150 rounded-xl">
|
||||
<div className="flex items-center justify-between gap-2 px-3 py-2 bg-slate-50 border-b border-slate-150 rounded-t-xl">
|
||||
<span className="text-[11.5px] font-bold text-slate-800 truncate">{seat.displayName}</span>
|
||||
<span className="text-[10px] text-slate-400 font-mono shrink-0">
|
||||
{seat.loading ? "加载中…" : `${seat.files.length} 个文件`}
|
||||
</span>
|
||||
</div>
|
||||
{seat.loading ? (
|
||||
<div className="flex items-center gap-2 px-3 py-3 text-[11px] text-slate-500">
|
||||
<Loader2 className="w-3 h-3 animate-spin text-primary" />
|
||||
正在查询 outputs 目录…
|
||||
</div>
|
||||
) : seat.error ? (
|
||||
<div className="px-3 py-3 text-[11px] text-rose-600">读取失败:{seat.error}</div>
|
||||
) : seat.files.length === 0 ? (
|
||||
<div className="px-3 py-3 text-[11px] text-slate-400 italic">该席位未产生输出文件</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-slate-100">
|
||||
{seat.files.map((f) => (
|
||||
<li key={f.path} className="px-3 py-2 flex items-center justify-between gap-2 hover:bg-slate-50/60">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<FileText className="w-3.5 h-3.5 text-slate-400 shrink-0" />
|
||||
<span className="text-[11.5px] font-medium text-slate-800 truncate" title={f.name}>
|
||||
{f.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-[10px] font-mono text-slate-400">{formatBytes(f.sizeBytes)}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handlePreview(seat.threadId, f)}
|
||||
className="text-slate-600 hover:text-primary transition p-1 rounded hover:bg-slate-100"
|
||||
title="预览"
|
||||
>
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDownload(seat.threadId, f)}
|
||||
className="text-slate-600 hover:text-primary transition p-1 rounded hover:bg-slate-100"
|
||||
title="下载"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ContentModal>
|
||||
|
||||
{previewing && (
|
||||
<ArtifactPreviewModal
|
||||
threadId={previewing.threadId}
|
||||
path={previewing.file.path}
|
||||
name={previewing.file.name}
|
||||
mimeType={previewing.file.mimeType}
|
||||
onClose={() => setPreviewing(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1275,6 +1275,21 @@ export default function RoundtablePlanningPage() {
|
||||
);
|
||||
setStep3SavedTick((t) => t + 1);
|
||||
}}
|
||||
// 业务链总控编排提示 → 注入「总结报告」智能体(带 coordinatorPrompt 的链走 dag)。
|
||||
coordinatorPrompt={orchestrationPlan?.coordinatorPrompt ?? null}
|
||||
savedSummary={step3Report?.summaryReport ?? null}
|
||||
onSummarySaved={(snap) => {
|
||||
// 把总结结果合并进 step3(与 HTML 报告并存,不互相覆盖)。
|
||||
setStep3Report((prev) => ({ ...(prev ?? {}), summaryReport: snap }));
|
||||
setStep2Runs((prev) =>
|
||||
prev.map((run) =>
|
||||
run.id === activeStep2RunIdRef.current
|
||||
? { ...run, step3: { ...(run.step3 ?? {}), summaryReport: snap } }
|
||||
: run,
|
||||
),
|
||||
);
|
||||
setStep3SavedTick((t) => t + 1);
|
||||
}}
|
||||
onReset={handleStartNewTask}
|
||||
openArtifactPreview={step2.openArtifactPreview}
|
||||
seatStubMessages={step2.seatStubMessages}
|
||||
|
||||
@@ -34,10 +34,8 @@ const Header = ({ collapsed }: HeaderProps) => {
|
||||
/>
|
||||
<header
|
||||
className={cn(
|
||||
"theme-transition flex h-16 items-center justify-between border-b px-4",
|
||||
isDark
|
||||
? "border-blue-800/30 bg-slate-900/50 backdrop-blur-xl"
|
||||
: "border-[var(--brand-950)] bg-[var(--brand-900)]",
|
||||
"strategy-app-header theme-transition flex h-16 items-center justify-between border-b px-4",
|
||||
isDark ? "border-blue-800/30" : "border-[var(--brand-950)]",
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
|
||||
@@ -870,3 +870,16 @@
|
||||
body {
|
||||
font-size: calc(1rem * var(--ui-font-scale, 1));
|
||||
}
|
||||
|
||||
/* Strategy 顶栏:浅色 / 深色背景图随 html.dark 切换 */
|
||||
.strategy-app-header {
|
||||
background-color: transparent;
|
||||
background-image: url("../assets/img/red-header.png");
|
||||
background-size: 100% 100%;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
html.dark .strategy-app-header {
|
||||
background-image: url("../assets/img/dark-blue-header.svg");
|
||||
}
|
||||
@@ -31,21 +31,43 @@ from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import inspect
|
||||
|
||||
from app.gateway.auth.disabled_mode import get_assumed_user_for_disabled_auth, is_auth_disabled
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from types import SimpleNamespace
|
||||
from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from app.gateway.auth.disabled_mode import get_assumed_user_for_disabled_auth, is_auth_disabled
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.gateway.auth.models import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
P = ParamSpec("P")
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
async def _thread_checkpoint_exists(request: Request, thread_id: str) -> bool:
|
||||
"""Return True if a LangGraph checkpoint exists for ``thread_id``.
|
||||
|
||||
A checkpoint with no matching ``threads_meta`` row is the signature of a
|
||||
DB-switch orphan (sqlite↔mysql/postgres juggling): the conversation state
|
||||
survives in the checkpointer while the business row is gone. Normal thread
|
||||
deletion removes the checkpoint too, so this can never resurrect a
|
||||
deliberately-deleted thread.
|
||||
"""
|
||||
try:
|
||||
from app.gateway.deps import get_checkpointer
|
||||
|
||||
checkpointer = get_checkpointer(request)
|
||||
tup = await checkpointer.aget_tuple({"configurable": {"thread_id": thread_id}})
|
||||
return tup is not None
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# Permission constants
|
||||
class Permissions:
|
||||
"""Permission constants for resource:action format."""
|
||||
@@ -205,6 +227,7 @@ def require_permission(
|
||||
action: str,
|
||||
owner_check: bool = False,
|
||||
require_existing: bool = False,
|
||||
claim_orphan: bool = False,
|
||||
) -> Callable[[Callable[P, T]], Callable[P, T]]:
|
||||
"""Decorator that checks permission for resource:action.
|
||||
|
||||
@@ -221,6 +244,15 @@ def require_permission(
|
||||
**destructive / mutating** routes (DELETE, PATCH,
|
||||
state-update) so a deleted thread can't be re-targeted
|
||||
by another user via the missing-row code path.
|
||||
claim_orphan: Only meaningful with ``owner_check=True`` and
|
||||
``require_existing=True``. When the ``threads_meta``
|
||||
row is missing **and** a LangGraph checkpoint exists
|
||||
for the thread (a DB-switch orphan), adopt it for the
|
||||
current user instead of 404ing. Use on **run-create**
|
||||
routes so a conversation whose business row was lost
|
||||
(sqlite↔mysql swap) stays answerable. Safe: normal
|
||||
deletion removes the checkpoint, so this never revives
|
||||
a deliberately-deleted thread.
|
||||
|
||||
Usage:
|
||||
# Read-style: legacy untracked threads are allowed
|
||||
@@ -289,16 +321,44 @@ def require_permission(
|
||||
from app.gateway.deps import get_thread_store
|
||||
|
||||
thread_store = get_thread_store(request)
|
||||
user_id = str(auth.user.id)
|
||||
allowed = await thread_store.check_access(
|
||||
thread_id,
|
||||
str(auth.user.id),
|
||||
user_id,
|
||||
require_existing=require_existing,
|
||||
)
|
||||
if not allowed:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Thread {thread_id} not found",
|
||||
)
|
||||
# Tell apart three denial causes so the client gets an
|
||||
# accurate message and so DB-switch orphans can be adopted
|
||||
# rather than hard-404'd:
|
||||
# 1. row exists but owned by another user -> not accessible
|
||||
# 2. no row, no checkpoint -> truly missing
|
||||
# 3. no row + checkpoint + claim_orphan -> orphan, claim
|
||||
existing = await thread_store.get(thread_id, user_id=None)
|
||||
if existing is not None:
|
||||
# Distinct detail string lets the frontend show "无权访问"
|
||||
# instead of "不存在" for a cross-user / stale-session hit.
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Thread {thread_id} not accessible",
|
||||
)
|
||||
claimed = False
|
||||
if claim_orphan and await _thread_checkpoint_exists(request, thread_id):
|
||||
try:
|
||||
await thread_store.create(thread_id, user_id=user_id)
|
||||
logger.info(
|
||||
"Claimed orphan thread %s for user %s (checkpoint present, no meta row)",
|
||||
thread_id,
|
||||
user_id,
|
||||
)
|
||||
claimed = True
|
||||
except Exception:
|
||||
logger.warning("Failed to claim orphan thread %s", thread_id, exc_info=True)
|
||||
if not claimed:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Thread {thread_id} not found",
|
||||
)
|
||||
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
|
||||
@@ -38,11 +38,14 @@
|
||||
``docs/roundtable-dag-orchestration.md`` §4 / §12.1 / §12.8 / §12.10。
|
||||
与 ``seat`` 的差异:``seat`` 禁 ``web_search``(旧「圆桌一律禁搜索」),
|
||||
``seat_parallel`` **放开搜索**(本期诉求);``seat`` 已放开 ``bash``,本角色仍禁。
|
||||
- ``summary`` —— Step3「总结报告」:写 Markdown 总结报告 + 支持问答/增量改。
|
||||
工具面与 ``report`` 完全一致(继承 ``seat`` 的 bash 放开 + 额外禁 ``str_replace`` /
|
||||
读前序席位),单列一个角色名便于以后独立调优。
|
||||
- ``report`` —— Step3 报告绘制:在 ``seat`` 基础上**额外禁 ``str_replace``**:
|
||||
* 继承 ``seat`` 的 bash 放开(2026-06)—— Step3 也可跑 bash 做辅助查询,同
|
||||
``seat`` 警示:**结果必须 write_file 落盘**,否则前端虚拟预览会空白
|
||||
(预览只抓 ``write_file.args.content``,抓不到脚本产物 → 预览停在半截
|
||||
``write_file``,正是「没写完就去生成 py 文件」的现象)。
|
||||
``write_file``,正是「没写完就去生成 py 文件」的现象);
|
||||
* 禁 ``str_replace`` → 强制返工用整文件 ``write_file`` 重写
|
||||
(``str_replace`` 没有 ``content`` 字段,会让前端虚拟预览读到 undefined
|
||||
而清空沙箱)。
|
||||
@@ -112,6 +115,10 @@ _SEAT_PARALLEL_EXCLUDED: list[str] = [
|
||||
# report:在 seat 基础上额外禁 str_replace(bash 已在 seat 禁)——强制整文件 write_file 重写,
|
||||
# 让前端虚拟预览能拿到完整 args.content。report 是 Step3 单独绘制,不读前序席位 → 禁 read_peer_delivery。
|
||||
_REPORT_EXCLUDED: list[str] = [*_SEAT_EXCLUDED, "str_replace", "read_peer_delivery"]
|
||||
# summary:Step3「总结报告」智能体(写 Markdown 报告 + 支持问答/增量改)。工具面与 report
|
||||
# 完全一致——可写文件(write_file 整文件重写),禁 str_replace(整文件重写才能让右侧 md 预览
|
||||
# 实时刷新)/bash/联网/派活/呈现文件/看图/读前序席位。单列出来便于以后独立调优。
|
||||
_SUMMARY_EXCLUDED: list[str] = [*_SEAT_EXCLUDED, "str_replace", "read_peer_delivery"]
|
||||
|
||||
# leader 内禀的 skill_stop:``agent_orchestration`` 调用后停图。调用方可在此基础上
|
||||
# 再叠加自己的 skill_stop(见 ``merge_skill_stop_names``)。
|
||||
@@ -167,7 +174,16 @@ def roundtable_run_policy(role: str) -> RoundtableRunPolicy:
|
||||
thinking_enabled=True,
|
||||
reasoning_effort="medium",
|
||||
)
|
||||
raise ValueError(f"unknown roundtable role: {role!r} (expected leader/seat/seat_parallel/report)")
|
||||
if role == "summary":
|
||||
return RoundtableRunPolicy(
|
||||
excluded_tools=list(_SUMMARY_EXCLUDED),
|
||||
skill_stop_names=[],
|
||||
thinking_enabled=True,
|
||||
reasoning_effort="medium",
|
||||
)
|
||||
raise ValueError(
|
||||
f"unknown roundtable role: {role!r} (expected leader/seat/seat_parallel/report/summary)"
|
||||
)
|
||||
|
||||
|
||||
def merge_skill_stop_names(policy_names: list[str], caller_names: list[str] | None) -> list[str]:
|
||||
|
||||
@@ -63,18 +63,24 @@ COORDINATOR_AGENT_ID = "roundtable-coordinator"
|
||||
# Step 3「结果绘制」用的方案可视化总结智能体:读各席位交付 + 共识,画自包含图表 HTML。
|
||||
# 与上面三个一样是内置单例,种子化一次常驻磁盘+DB,绝不每次调用重建。
|
||||
REPORT_AGENT_ID = "roundtable-report"
|
||||
# Step 3「总结报告」用的方案总结报告智能体:读各席位交付 + 共识,撰写一份 Markdown
|
||||
# 总结报告(写入 outputs/方案总结报告.md),并按业务链总控编排提示组织。与 report 并存
|
||||
# 的另一内置单例(report 画 HTML 看板,summary 写 Markdown 报告 + 支持问答/增量改)。
|
||||
SUMMARY_AGENT_ID = "roundtable-summary"
|
||||
|
||||
# 验证版 SOUL.md + config.yaml 的源码资源目录(随 git 发布)。每个子目录名即 agent id,
|
||||
# 内含该 agent 的 config.yaml 与 SOUL.md,是 seeder 落盘时字节级复制的来源。
|
||||
_ASSETS_DIR = Path(__file__).parent / "_roundtable_seed_assets"
|
||||
|
||||
# 这几个功能型/内置 agent 任何部署都必须存在;顺序无关紧要(逐个独立补建)。
|
||||
# 前三个支撑 Step 1 澄清 / 推荐 / Step 2 总控;roundtable-report 支撑 Step 3 结果绘制。
|
||||
# 前三个支撑 Step 1 澄清 / 推荐 / Step 2 总控;roundtable-report 支撑 Step 3 结果绘制
|
||||
# (HTML 看板);roundtable-summary 支撑 Step 3 总结报告(Markdown + 问答/增量改)。
|
||||
_FUNCTIONAL_AGENT_IDS: tuple[str, ...] = (
|
||||
INTENT_AGENT_ID,
|
||||
RECOMMENDER_AGENT_ID,
|
||||
COORDINATOR_AGENT_ID,
|
||||
REPORT_AGENT_ID,
|
||||
SUMMARY_AGENT_ID,
|
||||
)
|
||||
|
||||
# seeder 会复制资源目录里的这些文件(存在即复制,缺失则跳过)。
|
||||
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
# 圆桌方案总结报告智能体
|
||||
|
||||
## 核心定位
|
||||
你是多智能体圆桌研讨 **Step 3** 的「方案总结报告智能体」。各席位(情报收集 / 环境评估 / 方案设计 / 风险审查 / 执行规划 / 总结陈述等)已经完成研讨并产出交付,总控也给出了最终共识。
|
||||
|
||||
你的**职责**:把这些文字成果归纳、提炼,**撰写成一份条理清晰、以文字叙述为主的 Markdown 总结报告**。
|
||||
|
||||
这份报告是给**用户阅读**的——目的是让用户能清晰、轻松地看懂「这群智能体讨论出了什么方案、为什么是它」。所以**可读性第一**:用通顺的中文把研讨脉络和方案讲清楚。
|
||||
|
||||
你**不**调研、**不**联网搜索、**不**派活、**不**绘制 HTML 页面 / 不画图表——只做归纳与撰写。
|
||||
|
||||
## 输入说明
|
||||
用户消息里会给你这些原料(可能有缺省):
|
||||
1. **任务意图摘要**:目标、约束、关键假设(来自 Step 1);
|
||||
2. **各席位最终交付**:每个席位的名称 + 它产出的完整 markdown 正文;
|
||||
3. **总控最终共识结论**;
|
||||
4. **(可能有)业务链总控编排提示**:用户为这条业务链写给总控(协调)智能体的一段指引。它代表用户对这条业务链产出的期望——**这是最高优先级的口径要求**,请把它作为本次总结的**重点方向、结构与风格的依据**,严格按它强调的关注点来组织你的报告。
|
||||
|
||||
请通读全部原料:提炼每个席位的核心观点 / 交付,串成一份完整的方案总结。
|
||||
|
||||
## 产出要求(最重要)
|
||||
|
||||
你每一轮都要做**两件事**,缺一不可:
|
||||
|
||||
### A. 写一个 Markdown 文件
|
||||
- 用 `write_file` **一次性**把完整报告写入 `/mnt/user-data/outputs/方案总结报告.md`;
|
||||
- **就一个 `.md` 文件**——不要拆成多个文件、不要分多次拼接;
|
||||
- **纯 Markdown 文本**(标题、列表、表格、引用、加粗等),**不**输出 HTML、**不**画图表(SVG / Chart 等一律不要)、**不**引任何外链;
|
||||
- **严禁**写 Python / shell / 任何脚本去"生成"文件,也**严禁**用 bash——你必须自己把 Markdown 文本直接写进 `write_file`;
|
||||
- `write_file` **支持很长的内容,不会截断**——请放心一次性把整篇报告写进去。
|
||||
|
||||
### B. 在对话回复里给出报告正文
|
||||
- 写完文件后,**把这份报告的完整正文也输出到对话回复里**(直接给用户读),不要只回一句「已写入文件」。
|
||||
- 用户既能在对话里直接读到报告,也能在右侧预览 / 下载这个 `.md` 文件——两边内容应当一致。
|
||||
|
||||
## 报告结构(叙事型,文字为主线)
|
||||
按下面的章节顺序组织,缺料的章节可省略或合并。**若有「业务链总控编排提示」,以它的要求优先**:
|
||||
|
||||
| # | 章节 | 内容 |
|
||||
|---|------|------|
|
||||
| ① | 标题 + 一句话结论 | 用一句话点明最终方案 / 结论 |
|
||||
| ② | 任务背景与目标 | 交代要解决什么、目标与关键约束 |
|
||||
| ③ | 各席位观点与交付 | **每个席位一段提炼文字**(这位专家关注什么、给出了什么关键结论 / 建议) |
|
||||
| ④ | 综合分析 | 把跨席位的信息串起来分析(风险全景、资源权衡、阶段安排等),可用 Markdown 表格 / 列表结构化呈现 |
|
||||
| ⑤ | 总控最终共识方案 | **重点章节**,详写最终拍板的方案是什么、核心要点、为什么这么定 |
|
||||
| ⑥ | 执行路线 | 落地步骤 / 阶段说明 |
|
||||
|
||||
善用 Markdown 表格、有序 / 无序列表,把要点、阶段、里程碑、风险项、优劣势等结构化呈现,提升可读性。
|
||||
|
||||
## 多轮交互(用户会在输入框里继续和你对话)
|
||||
用户读完报告后,可能在输入框里继续给你发消息。你要**自己判断**意图,分两种情况处理:
|
||||
|
||||
1. **提出修改意见**(如「把执行路线写得更细」「补一节预算分析」「风险部分太长,精简」):
|
||||
- 在**原报告基础上做增量修改**——只改用户提到的地方,其余内容保持不变;
|
||||
- 用 `write_file` **整文件重写**到同一路径 `/mnt/user-data/outputs/方案总结报告.md`(`str_replace` 已被禁用,整文件重写才能让右侧预览实时刷新);
|
||||
- 然后在对话回复里给出修改后的完整报告正文。
|
||||
|
||||
2. **就报告 / 研讨内容提问**(如「为什么没采用方案 B?」「风险最高的是哪一项?」):
|
||||
- **只在对话里用文字回答**,依据已有的研讨原料和报告内容作答;
|
||||
- **不要**改写报告文件、**不要**重新 `write_file`。
|
||||
|
||||
判断不准时,倾向于「先简短回答,再问用户是否需要据此更新报告」。
|
||||
|
||||
## 禁止事项
|
||||
- ❌ 生成 HTML / 绘制可视化页面 / 画图表(只写 Markdown 文本)
|
||||
- ❌ 把报告拆成多文件、分多次拼接
|
||||
- ❌ 联网搜索、派活给其他智能体
|
||||
- ❌ 写 Python / shell / 任何脚本去生成文件,或用 bash 跑脚本产出文件
|
||||
- ❌ 写完文件后只回一句「已写入文件」而不给出报告正文(用户要在对话里读到报告)
|
||||
- ❌ 用户只是提问时还去改写报告文件
|
||||
- ❌ 在报告任何位置添加「AI 自动生成」「AI generated」之类的标签、徽章或水印
|
||||
- ❌ 编造与原料明显矛盾的内容
|
||||
- ❌ 忽视「业务链总控编排提示」的口径要求(它优先级最高)
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
description: 圆桌方案总结报告智能体,读取各席位的最终交付与总控共识,撰写一份以文字叙述为主的 Markdown 总结报告(写入 outputs/方案总结报告.md),同时把报告正文输出到对话供用户阅读,并支持用户在输入框里就报告提问或提出修改意见。它会遵循业务链写给总控的编排提示来组织报告。本身不调研、不联网、不派活、不绘制 HTML / 图表。
|
||||
id: roundtable-summary
|
||||
name: 方案总结报告
|
||||
@@ -66,6 +66,7 @@ from app.gateway.roundtable_run_policy import merge_skill_stop_names, roundtable
|
||||
from app.gateway.routers._roundtable_seed import (
|
||||
COORDINATOR_AGENT_ID,
|
||||
REPORT_AGENT_ID,
|
||||
SUMMARY_AGENT_ID,
|
||||
ensure_roundtable_functional_agents,
|
||||
)
|
||||
from deerflow.config import get_app_config
|
||||
@@ -915,6 +916,26 @@ async def init_report_thread(request: Request) -> dict[str, str]:
|
||||
return {"agent_name": REPORT_AGENT_ID, "thread_id": thread_id}
|
||||
|
||||
|
||||
@router.post("/summary/init")
|
||||
async def init_summary_thread(request: Request) -> dict[str, str]:
|
||||
"""为 Step 3「总结报告」创建一个供方案总结报告智能体(``roundtable-summary``)
|
||||
运行的线程,返回 ``{"agent_name", "thread_id"}``。
|
||||
|
||||
与 ``/report/init`` 同构,只是换成另一个内置单例 ``roundtable-summary``:
|
||||
- ``roundtable-summary`` 是**内置单例**(``_roundtable_seed`` 种子化,启动即同步进
|
||||
DB),这里**只建一个轻量 thread**,绝不重建 agent;
|
||||
- 线程打 ``_ROUNDTABLE_THREAD_METADATA`` 标记成系统会话,从「最近对话」里隐藏;
|
||||
- 入口先 ``ensure_roundtable_functional_agents()`` 自愈(磁盘目录被手删也能补建);
|
||||
- 建好后前端用现有 ``POST /run/stream``(``agent_type="special"``、
|
||||
``agent_name="roundtable-summary"``、``d_agent_thread_id={SUMMARY_AGENT_ID: thread_id}``)
|
||||
跑这一轮(后续问答/增量改**复用同一线程**,不重新 init),智能体把 Markdown 报告
|
||||
写进该线程的 ``outputs/方案总结报告.md``,再由沙箱预览。
|
||||
"""
|
||||
ensure_roundtable_functional_agents()
|
||||
thread_id = await _create_thread_direct(request, metadata=_ROUNDTABLE_THREAD_METADATA)
|
||||
return {"agent_name": SUMMARY_AGENT_ID, "thread_id": thread_id}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 上游 LangGraph SSE 透传(intent / recommend / leader / special 共用)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1572,6 +1593,8 @@ async def _special_run(
|
||||
# 后台进程内网关用同一份 policy,保证「后台挂起」与「前端手动调用」传参一致。
|
||||
if agent_name == REPORT_AGENT_ID:
|
||||
role = "report"
|
||||
elif agent_name == SUMMARY_AGENT_ID:
|
||||
role = "summary"
|
||||
elif no_file:
|
||||
role = "seat_parallel"
|
||||
else:
|
||||
|
||||
@@ -21,7 +21,7 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from app.gateway.authz import require_permission
|
||||
from app.gateway.deps import get_checkpointer, get_current_user, get_feedback_repo, get_run_event_store, get_run_manager, get_run_store, get_stream_bridge
|
||||
from app.gateway.services import sse_consumer, start_run
|
||||
from app.gateway.services import immediate_end_sse, sse_consumer, start_run
|
||||
from deerflow.runtime import RunRecord, serialize_channel_values
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -96,7 +96,7 @@ def _record_to_response(record: RunRecord) -> RunResponse:
|
||||
|
||||
|
||||
@router.post("/{thread_id}/runs", response_model=RunResponse)
|
||||
@require_permission("runs", "create", owner_check=True, require_existing=True)
|
||||
@require_permission("runs", "create", owner_check=True, require_existing=True, claim_orphan=True)
|
||||
async def create_run(thread_id: str, body: RunCreateRequest, request: Request) -> RunResponse:
|
||||
"""Create a background run (returns immediately)."""
|
||||
record = await start_run(body, thread_id, request)
|
||||
@@ -104,7 +104,7 @@ async def create_run(thread_id: str, body: RunCreateRequest, request: Request) -
|
||||
|
||||
|
||||
@router.post("/{thread_id}/runs/stream")
|
||||
@require_permission("runs", "create", owner_check=True, require_existing=True)
|
||||
@require_permission("runs", "create", owner_check=True, require_existing=True, claim_orphan=True)
|
||||
async def stream_run(thread_id: str, body: RunCreateRequest, request: Request) -> StreamingResponse:
|
||||
"""Create a run and stream events via SSE.
|
||||
|
||||
@@ -132,7 +132,7 @@ async def stream_run(thread_id: str, body: RunCreateRequest, request: Request) -
|
||||
|
||||
|
||||
@router.post("/{thread_id}/runs/wait", response_model=dict)
|
||||
@require_permission("runs", "create", owner_check=True, require_existing=True)
|
||||
@require_permission("runs", "create", owner_check=True, require_existing=True, claim_orphan=True)
|
||||
async def wait_run(thread_id: str, body: RunCreateRequest, request: Request) -> dict:
|
||||
"""Create a run and block until it completes, returning the final state."""
|
||||
record = await start_run(body, thread_id, request)
|
||||
@@ -173,7 +173,11 @@ async def get_run(thread_id: str, run_id: str, request: Request) -> RunResponse:
|
||||
run_mgr = get_run_manager(request)
|
||||
record = run_mgr.get(run_id)
|
||||
if record is None or record.thread_id != thread_id:
|
||||
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
|
||||
# In-memory miss: fall back to the persistent store so a run still
|
||||
# resolves after a Gateway restart instead of 404ing.
|
||||
record = await run_mgr.get_persisted(run_id)
|
||||
if record is None or record.thread_id != thread_id:
|
||||
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
|
||||
return _record_to_response(record)
|
||||
|
||||
|
||||
@@ -222,11 +226,27 @@ async def join_run(thread_id: str, run_id: str, request: Request) -> StreamingRe
|
||||
bridge = get_stream_bridge(request)
|
||||
run_mgr = get_run_manager(request)
|
||||
record = run_mgr.get(run_id)
|
||||
if record is None or record.thread_id != thread_id:
|
||||
if record is not None and record.thread_id == thread_id:
|
||||
return StreamingResponse(
|
||||
sse_consumer(bridge, record, request, run_mgr),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
# Not in memory: a run that still exists in the store but has no live task
|
||||
# means the Gateway restarted (or the finished run was cleaned up). There
|
||||
# is nothing to reconnect to, so stream a clean terminal end instead of a
|
||||
# 404 — the SDK then clears its reconnect key and renders history.
|
||||
persisted = await run_mgr.get_persisted(run_id)
|
||||
if persisted is None or persisted.thread_id != thread_id:
|
||||
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
|
||||
|
||||
return StreamingResponse(
|
||||
sse_consumer(bridge, record, request, run_mgr),
|
||||
immediate_end_sse(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
@@ -255,7 +275,22 @@ async def stream_existing_run(
|
||||
run_mgr = get_run_manager(request)
|
||||
record = run_mgr.get(run_id)
|
||||
if record is None or record.thread_id != thread_id:
|
||||
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
|
||||
# In-memory miss. If the run survives in the store it's a dead record
|
||||
# from before a restart (or already finished) — nothing to join or
|
||||
# cancel, so emit a clean terminal end so the SDK stops reconnecting
|
||||
# instead of 404ing. A truly unknown run still 404s.
|
||||
persisted = await run_mgr.get_persisted(run_id)
|
||||
if persisted is None or persisted.thread_id != thread_id:
|
||||
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
|
||||
return StreamingResponse(
|
||||
immediate_end_sse(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
# Cancel if an action was requested (stop-button / interrupt flow)
|
||||
if action is not None:
|
||||
|
||||
@@ -42,6 +42,20 @@ logger = logging.getLogger(__name__)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def immediate_end_sse():
|
||||
"""Yield a single terminal ``end`` SSE frame and stop.
|
||||
|
||||
Used when a run exists in the persistent store but not in the in-memory
|
||||
registry — typically after a Gateway restart, or once a finished run's
|
||||
record has been cleaned up. There is no live stream to join, so instead of
|
||||
returning 404 (which the SDK's ``joinStream`` surfaces as a hard error and
|
||||
never clears its reconnect key for) we stream a clean end. The SDK treats
|
||||
this as success, clears ``lg:stream:{threadId}``, and the conversation
|
||||
falls back to history rendering — killing the repeating switch-back 404.
|
||||
"""
|
||||
yield format_sse("end", None)
|
||||
|
||||
|
||||
def format_sse(event: str, data: Any, *, event_id: str | None = None) -> str:
|
||||
"""Format a single SSE frame.
|
||||
|
||||
|
||||
@@ -112,6 +112,52 @@ class RunManager:
|
||||
"""Return a run record by ID, or ``None``."""
|
||||
return self._runs.get(run_id)
|
||||
|
||||
def _record_from_store(self, data: dict) -> RunRecord:
|
||||
"""Rebuild a :class:`RunRecord` from a persisted store row.
|
||||
|
||||
The reconstructed record has no live ``task``/``abort_event`` wired to a
|
||||
running coroutine — it represents *history*, not an in-flight run.
|
||||
"""
|
||||
try:
|
||||
status = RunStatus(data.get("status") or RunStatus.success.value)
|
||||
except ValueError:
|
||||
status = RunStatus.success
|
||||
return RunRecord(
|
||||
run_id=data["run_id"],
|
||||
thread_id=data["thread_id"],
|
||||
assistant_id=data.get("assistant_id"),
|
||||
status=status,
|
||||
# Disconnect mode is irrelevant for a store-only record (no task to
|
||||
# cancel); ``continue_`` keeps sse_consumer's finally block a no-op.
|
||||
on_disconnect=DisconnectMode.continue_,
|
||||
multitask_strategy=data.get("multitask_strategy", "reject"),
|
||||
metadata=data.get("metadata") or {},
|
||||
kwargs=data.get("kwargs") or {},
|
||||
created_at=data.get("created_at") or "",
|
||||
updated_at=data.get("updated_at") or "",
|
||||
error=data.get("error"),
|
||||
)
|
||||
|
||||
async def get_persisted(self, run_id: str) -> RunRecord | None:
|
||||
"""Load a run from the backing store (process-restart fallback).
|
||||
|
||||
The in-memory registry is wiped on every Gateway restart, but the
|
||||
``RunStore`` row survives. When :meth:`get` misses, callers fall back
|
||||
here so a stale ``joinStream``/``GET run`` can report the run's final
|
||||
status instead of 404ing. Returns ``None`` when there is no store, no
|
||||
matching row, or the lookup fails (e.g. no user context).
|
||||
"""
|
||||
if self._store is None:
|
||||
return None
|
||||
try:
|
||||
data = await self._store.get(run_id)
|
||||
except Exception:
|
||||
logger.warning("Failed to load run %s from store", run_id, exc_info=True)
|
||||
return None
|
||||
if not data:
|
||||
return None
|
||||
return self._record_from_store(data)
|
||||
|
||||
async def list_by_thread(self, thread_id: str) -> list[RunRecord]:
|
||||
"""Return all runs for a given thread, newest first."""
|
||||
async with self._lock:
|
||||
|
||||
Reference in new issue
Block a user