新增 iframe 结果接口与圆桌 Step3 结果绘制
- 公开 API:GET /api/public/embed/sessions/{sessionId}/result 返回 messages、generated_text、generated_files
- 圆桌 Step3:内置 roundtable-report 智能体、report/init 与结果绘制 UI
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
0727b4564b
commit
40ba81ee6a
16 files changed
+1771
-50
No files matched your search
@@ -194,20 +194,80 @@ GET /api/public/embed/sessions/{session_id}/status
|
||||
| `latest_run_status` | 该线程最近一次 run 的状态:`success` / `error` / … |
|
||||
| `thread_id` | 解析出的 DeerFlow 线程 id(父页面可选用,非必须) |
|
||||
|
||||
### 4.3 按 thread_id 查询(可选)
|
||||
### 4.3 获取生成结果(父页面使用)
|
||||
|
||||
问答结束后(建议先确认 `finished === true`),拉取本次模型输出:
|
||||
|
||||
```http
|
||||
GET /api/public/embed/sessions/{session_id}/result
|
||||
```
|
||||
|
||||
**尚未登记:**
|
||||
|
||||
```json
|
||||
{
|
||||
"session_id": "parent-generated-uuid",
|
||||
"registered": false,
|
||||
"thread_id": null,
|
||||
"finished": false,
|
||||
"thread_status": "pending",
|
||||
"has_active_run": false,
|
||||
"latest_run_status": null,
|
||||
"messages": [],
|
||||
"generated_text": "",
|
||||
"generated_files": []
|
||||
}
|
||||
```
|
||||
|
||||
**已登记且已有内容:**
|
||||
|
||||
```json
|
||||
{
|
||||
"session_id": "parent-generated-uuid",
|
||||
"registered": true,
|
||||
"thread_id": "e9c7d09e-821d-421f-ae24-e5ddfe0cff5b",
|
||||
"finished": true,
|
||||
"thread_status": "idle",
|
||||
"has_active_run": false,
|
||||
"latest_run_status": "success",
|
||||
"messages": [
|
||||
{ "type": "human", "content": "请总结要点", "id": "..." },
|
||||
{ "type": "ai", "content": "根据分析,要点如下:...", "id": "..." }
|
||||
],
|
||||
"generated_text": "根据分析,要点如下:...",
|
||||
"generated_files": [
|
||||
"/mnt/user-data/outputs/summary.md",
|
||||
"/mnt/user-data/outputs/chart.png"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### 结果字段说明
|
||||
|
||||
| 字段 | 含义 |
|
||||
|------|------|
|
||||
| `messages` | 完整消息列表(与 checkpoint 中序列化结构一致,含 human / ai / tool 等) |
|
||||
| `generated_text` | 所有 `ai` / `assistant` 消息正文按顺序拼接(段落间空一行) |
|
||||
| `generated_files` | 本次会话产物的虚拟路径列表(与 UI 产物栏 `artifacts` 一致) |
|
||||
|
||||
未结束时也可调用,会返回当前已有内容;`finished` 仍为 false 表示可能还会继续生成。
|
||||
|
||||
### 4.4 按 thread_id 查询(可选)
|
||||
|
||||
调试或父页面已持有 thread_id 时:
|
||||
|
||||
```http
|
||||
GET /api/public/embed/threads/{thread_id}/status
|
||||
GET /api/public/embed/threads/{thread_id}/result
|
||||
```
|
||||
|
||||
响应不含 `session_id` / `registered`,其余与上表类似。
|
||||
状态接口响应不含 `messages` / `generated_text` / `generated_files`;结果接口字段与 §4.3 相同(无 `session_id` / `registered`)。
|
||||
|
||||
### 4.4 前端封装
|
||||
### 4.5 前端封装
|
||||
|
||||
```ts
|
||||
import {
|
||||
getEmbedSessionResult,
|
||||
getEmbedSessionStatus,
|
||||
registerEmbedSession,
|
||||
} from "@/core/embed/status-api";
|
||||
@@ -215,7 +275,8 @@ import {
|
||||
// 父页面(或你们自己的脚本)
|
||||
const status = await getEmbedSessionStatus(sessionId);
|
||||
if (status.registered && status.finished) {
|
||||
// 问答结束
|
||||
const result = await getEmbedSessionResult(sessionId);
|
||||
console.log(result.generated_text, result.generated_files);
|
||||
}
|
||||
|
||||
// 仅 iframe 内需要
|
||||
@@ -270,10 +331,14 @@ async function pollUntilFinished(sessionId) {
|
||||
|
||||
const timer = setInterval(async () => {
|
||||
try {
|
||||
const result = await pollUntilFinished(sessionId);
|
||||
if (result.done) {
|
||||
const poll = await pollUntilFinished(sessionId);
|
||||
if (poll.done) {
|
||||
clearInterval(timer);
|
||||
console.log("问答已结束", result.data);
|
||||
const output = await fetch(
|
||||
`${API}/api/public/embed/sessions/${encodeURIComponent(sessionId)}/result`,
|
||||
{ credentials: "omit" },
|
||||
).then((r) => r.json());
|
||||
console.log("问答已结束", output.generated_text, output.generated_files);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
@@ -350,3 +415,4 @@ A:可以:`#/embed/chats/{thread_id}?sessionId=...`,可选再带 `message`
|
||||
| 日期 | 说明 |
|
||||
|------|------|
|
||||
| 2026-06-02 | 初版:iframe 嵌入路由、`sessionId` 映射表、公开登记与状态 API |
|
||||
| 2026-06-02 | 新增 `GET /sessions/{session_id}/result`:返回 messages、generated_text、generated_files |
|
||||
@@ -10,6 +10,12 @@ export type EmbedSessionStatus = {
|
||||
latest_run_status: string | null;
|
||||
};
|
||||
|
||||
export type EmbedSessionResult = EmbedSessionStatus & {
|
||||
messages: Record<string, unknown>[];
|
||||
generated_text: string;
|
||||
generated_files: string[];
|
||||
};
|
||||
|
||||
async function parseEmbedError(response: Response): Promise<string> {
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (typeof body.detail === "string") {
|
||||
@@ -50,3 +56,18 @@ export async function getEmbedSessionStatus(
|
||||
}
|
||||
return response.json() as Promise<EmbedSessionStatus>;
|
||||
}
|
||||
|
||||
/** Fetch full messages, generated text, and artifact paths — parent uses sessionId only. */
|
||||
export async function getEmbedSessionResult(
|
||||
sessionId: string,
|
||||
): Promise<EmbedSessionResult> {
|
||||
const base = getBackendBaseURL();
|
||||
const response = await fetch(
|
||||
`${base}/api/public/embed/sessions/${encodeURIComponent(sessionId)}/result`,
|
||||
{ credentials: "omit" },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(await parseEmbedError(response));
|
||||
}
|
||||
return response.json() as Promise<EmbedSessionResult>;
|
||||
}
|
||||
@@ -747,3 +747,34 @@ export function buildArtifactDownloadUrl(
|
||||
const base = `${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}/artifacts/${segments}`;
|
||||
return options.download ? `${base}?download=true` : base;
|
||||
}
|
||||
|
||||
export interface ReportThreadResponse {
|
||||
/** Always the built-in singleton id `roundtable-report`. */
|
||||
agentName: string;
|
||||
/** Freshly created lightweight thread to run the report agent on. */
|
||||
threadId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a thread for the Step 3「结果绘制」visualization agent
|
||||
* (`roundtable-report`, a built-in singleton — this only spins up a cheap
|
||||
* thread, it never re-creates the agent). Run it afterwards with
|
||||
* {@link streamMultiAgent} (`agentType: "special"`,
|
||||
* `agentName: "roundtable-report"`, `dAgentThreadId: { [agentName]: threadId }`).
|
||||
*/
|
||||
export async function initReportThread(
|
||||
options: { signal?: AbortSignal } = {},
|
||||
): Promise<ReportThreadResponse> {
|
||||
const res = await apiFetch(`${getBackendBaseURL()}/api/multi-agent/report/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 report 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 };
|
||||
}
|
||||
@@ -81,6 +81,8 @@ const EXCLUDED_AGENT_IDS = new Set<string>([
|
||||
"roundtable-intent",
|
||||
"roundtable-recommender",
|
||||
"roundtable-coordinator",
|
||||
// Step 3「结果绘制」的方案可视化总结智能体,是内置功能型 agent,不应作为可选席位。
|
||||
"roundtable-report",
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Loader2,
|
||||
Printer,
|
||||
RefreshCw,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
@@ -20,6 +21,12 @@ import {
|
||||
type ThreadIdMap,
|
||||
} from "@/roundtable-planning/api/multi-agent";
|
||||
import { ArtifactPreviewModal } from "@/roundtable-planning/components/ArtifactPreviewModal";
|
||||
import { markdownComponents } from "@/roundtable-planning/components/markdown-components";
|
||||
import {
|
||||
Step3ReportMode,
|
||||
type ModelOption,
|
||||
} from "@/roundtable-planning/components/Step3ReportMode";
|
||||
import type { ReportIntent } from "@/roundtable-planning/hooks/useStep3Report";
|
||||
|
||||
/**
|
||||
* Text-ish files we render in an in-app preview modal. Other types (pdf,
|
||||
@@ -55,6 +62,14 @@ interface HighFidelityReportProps {
|
||||
seatDeliveries: SeatDelivery[];
|
||||
/** lowercase agent name → display name (e.g. "roundtable-intelligence" → "情报收集"). */
|
||||
seatNameMap: Record<string, string>;
|
||||
/** Step 1 整理出的结构化意图 —— 喂给「结果绘制」的方案可视化智能体。 */
|
||||
intentReady: ReportIntent | null;
|
||||
/** 当前推理模型 —— 跑可视化智能体用。 */
|
||||
selectedModel: string;
|
||||
/** 可选模型列表 + 加载态 + 切换器 —— 结果绘制右侧的模型选择(对齐第 2 步)。 */
|
||||
availableModels: ReadonlyArray<ModelOption>;
|
||||
modelsLoading: boolean;
|
||||
setSelectedModel: (model: string) => void;
|
||||
/** Resets state and navigates back to Step 1. */
|
||||
onReset: () => void;
|
||||
}
|
||||
@@ -80,46 +95,6 @@ function formatGeneratedAt(d: Date): string {
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
const markdownComponents = {
|
||||
p: ({ children }: any) => <p className="my-1.5 whitespace-pre-wrap leading-relaxed">{children}</p>,
|
||||
ul: ({ children }: any) => <ul className="list-disc pl-5 my-1.5 space-y-1">{children}</ul>,
|
||||
ol: ({ children }: any) => <ol className="list-decimal pl-5 my-1.5 space-y-1">{children}</ol>,
|
||||
li: ({ children }: any) => <li className="leading-snug">{children}</li>,
|
||||
h1: ({ children }: any) => <h1 className="text-sm font-bold mt-3 mb-1.5">{children}</h1>,
|
||||
h2: ({ children }: any) => <h2 className="text-sm font-bold mt-3 mb-1.5">{children}</h2>,
|
||||
h3: ({ children }: any) => <h3 className="text-xs font-bold mt-2 mb-1">{children}</h3>,
|
||||
h4: ({ children }: any) => <h4 className="text-xs font-bold mt-1.5 mb-1">{children}</h4>,
|
||||
strong: ({ children }: any) => <strong className="font-bold text-slate-900">{children}</strong>,
|
||||
em: ({ children }: any) => <em className="italic">{children}</em>,
|
||||
code: ({ children, className }: any) => {
|
||||
const isBlock = /language-/.test(className ?? "");
|
||||
if (isBlock) {
|
||||
return (
|
||||
<pre className="bg-slate-100 text-slate-800 rounded-md p-2 my-1 overflow-x-auto text-[11px] font-mono">
|
||||
<code>{children}</code>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<code className="bg-slate-100 text-slate-800 rounded px-1 py-0.5 text-[11px] font-mono">{children}</code>
|
||||
);
|
||||
},
|
||||
blockquote: ({ children }: any) => (
|
||||
<blockquote className="border-l-2 border-slate-300 pl-2 my-1 text-slate-600 italic">{children}</blockquote>
|
||||
),
|
||||
a: ({ children, href }: any) => (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer" className="text-primary underline hover:opacity-80">
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
table: ({ children }: any) => <table className="my-2 border-collapse text-[11px]">{children}</table>,
|
||||
th: ({ children }: any) => (
|
||||
<th className="border border-slate-300 px-1.5 py-0.5 bg-slate-50 font-bold text-left">{children}</th>
|
||||
),
|
||||
td: ({ children }: any) => <td className="border border-slate-200 px-1.5 py-0.5">{children}</td>,
|
||||
hr: () => <hr className="my-2 border-slate-200" />,
|
||||
};
|
||||
|
||||
export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
threadIds,
|
||||
coordinatorName,
|
||||
@@ -127,8 +102,15 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
hasConsensus,
|
||||
seatDeliveries,
|
||||
seatNameMap,
|
||||
intentReady,
|
||||
selectedModel,
|
||||
availableModels,
|
||||
modelsLoading,
|
||||
setSelectedModel,
|
||||
onReset,
|
||||
}) => {
|
||||
// 「结果绘制」模式:把现有报告内容收起到左栏,右侧由方案可视化智能体绘制图表 HTML。
|
||||
const [drawMode, setDrawMode] = useState(false);
|
||||
// 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(() => {
|
||||
@@ -253,6 +235,25 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
[],
|
||||
);
|
||||
|
||||
if (drawMode) {
|
||||
return (
|
||||
<Step3ReportMode
|
||||
threadIds={threadIds}
|
||||
coordinatorName={coordinatorName}
|
||||
consensusText={consensusText}
|
||||
hasConsensus={hasConsensus}
|
||||
seatDeliveries={seatDeliveries}
|
||||
seatNameMap={seatNameMap}
|
||||
intentReady={intentReady}
|
||||
selectedModel={selectedModel}
|
||||
availableModels={availableModels}
|
||||
modelsLoading={modelsLoading}
|
||||
setSelectedModel={setSelectedModel}
|
||||
onBack={() => setDrawMode(false)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-y-auto text-slate-800">
|
||||
{/* Header */}
|
||||
@@ -275,6 +276,40 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
id="step3-report-model-select-entry"
|
||||
value={selectedModel}
|
||||
onChange={(e) => setSelectedModel(e.target.value)}
|
||||
disabled={modelsLoading || availableModels.length === 0}
|
||||
title={
|
||||
modelsLoading
|
||||
? "正在加载模型列表…"
|
||||
: availableModels.length === 0
|
||||
? "未在 config.yaml 中配置任何模型"
|
||||
: "切换结果绘制使用的推理模型"
|
||||
}
|
||||
className="shrink-0 max-w-[180px] bg-slate-50/50 border border-slate-200 rounded-xl px-2.5 py-1.5 text-[10.5px] 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>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDrawMode(true)}
|
||||
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" />
|
||||
结果绘制
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
|
||||
@@ -407,7 +407,7 @@ export function Step2Panel(props: Step2PanelProps) {
|
||||
*
|
||||
* 「插话干预」气泡有特殊红底,通过 containerClassName 传给 MessageBubble。
|
||||
*/
|
||||
interface Step2MessageProps {
|
||||
export interface Step2MessageProps {
|
||||
msg: Step2Dialogue;
|
||||
/** 把派活 chip 的 agent id 解析成中文席位名。 */
|
||||
resolveAgentName?: (idOrName: string) => string;
|
||||
@@ -418,7 +418,7 @@ interface Step2MessageProps {
|
||||
onFileCardClick?: (path: string) => void;
|
||||
}
|
||||
|
||||
function Step2Message({
|
||||
export function Step2Message({
|
||||
msg,
|
||||
resolveAgentName,
|
||||
onArtifactStepClick,
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
* 拉取 / 文本类预览 / window.open 下载 / 「重新起草」按钮等。
|
||||
*/
|
||||
import { HighFidelityReport } from "@/roundtable-planning/components/HighFidelityReport";
|
||||
import type { ModelOption } from "@/roundtable-planning/components/Step3ReportMode";
|
||||
import type { Step2Dialogue } from "@/roundtable-planning/hooks/useStep2Orchestration";
|
||||
import type { ReportIntent } from "@/roundtable-planning/hooks/useStep3Report";
|
||||
import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent";
|
||||
|
||||
export interface Step3PanelProps {
|
||||
@@ -21,6 +23,13 @@ export interface Step3PanelProps {
|
||||
string,
|
||||
{ speakerId: number; avatarType: string; displayName: string }
|
||||
>;
|
||||
/** Step 1 结构化意图 + 当前模型 —— 透传给「结果绘制」的方案可视化智能体。 */
|
||||
intentReady: ReportIntent | null;
|
||||
selectedModel: string;
|
||||
/** 可选模型列表 + 加载态 + 切换器 —— 透传给「结果绘制」的模型选择。 */
|
||||
availableModels: ReadonlyArray<ModelOption>;
|
||||
modelsLoading: boolean;
|
||||
setSelectedModel: (model: string) => void;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
@@ -32,6 +41,11 @@ export function Step3Panel(props: Step3PanelProps) {
|
||||
hasConsensus,
|
||||
step2RoundtableDialogues,
|
||||
agentNameToRole,
|
||||
intentReady,
|
||||
selectedModel,
|
||||
availableModels,
|
||||
modelsLoading,
|
||||
setSelectedModel,
|
||||
onReset,
|
||||
} = props;
|
||||
|
||||
@@ -55,6 +69,11 @@ export function Step3Panel(props: Step3PanelProps) {
|
||||
hasConsensus={hasConsensus}
|
||||
seatDeliveries={seatDeliveries}
|
||||
seatNameMap={seatNameMap}
|
||||
intentReady={intentReady}
|
||||
selectedModel={selectedModel}
|
||||
availableModels={availableModels}
|
||||
modelsLoading={modelsLoading}
|
||||
setSelectedModel={setSelectedModel}
|
||||
onReset={onReset}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,682 @@
|
||||
/**
|
||||
* Step 3「结果绘制」模式。
|
||||
*
|
||||
* 布局对齐第 2 步:
|
||||
* - 左栏(~300px,第 2 步白卡风格):把原本的最终方案内容「收起」成三张源卡
|
||||
* (总控结论 / 各席位交付 / 各席位生成文件),点击才弹窗展开。
|
||||
* - 右栏:复用 `Step2SandboxLayout`(与主聊天同一套沙箱)。左 60% 显示方案总结
|
||||
* 智能体的流式绘制进度;右 40% 沙箱在它写 HTML 时显示代码、写完自动切 HTML 预览。
|
||||
*
|
||||
* 进入即自动开跑(`useStep3Report.start`),右上角「重新绘制」可重跑。
|
||||
*/
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import {
|
||||
ArrowLeft,
|
||||
CheckCircle,
|
||||
Download,
|
||||
Eye,
|
||||
FileText,
|
||||
ListOrdered,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Sparkles,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
buildArtifactDownloadUrl,
|
||||
listSessionArtifacts,
|
||||
type SessionArtifactFile,
|
||||
type ThreadIdMap,
|
||||
} from "@/roundtable-planning/api/multi-agent";
|
||||
import { ArtifactPreviewModal } from "@/roundtable-planning/components/ArtifactPreviewModal";
|
||||
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 { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
|
||||
import {
|
||||
useStep3Report,
|
||||
type ReportIntent,
|
||||
} from "@/roundtable-planning/hooks/useStep3Report";
|
||||
|
||||
interface SeatDelivery {
|
||||
sender: string;
|
||||
avatarType: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface ModelOption {
|
||||
name: string;
|
||||
display_name?: string;
|
||||
}
|
||||
|
||||
interface Step3ReportModeProps {
|
||||
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;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
type SourceModal = "consensus" | "seats" | "files" | null;
|
||||
|
||||
export function Step3ReportMode({
|
||||
threadIds,
|
||||
coordinatorName,
|
||||
consensusText,
|
||||
hasConsensus,
|
||||
seatDeliveries,
|
||||
seatNameMap,
|
||||
intentReady,
|
||||
selectedModel,
|
||||
availableModels,
|
||||
modelsLoading,
|
||||
setSelectedModel,
|
||||
onBack,
|
||||
}: Step3ReportModeProps) {
|
||||
const reportSeatDeliveries = useMemo(
|
||||
() => seatDeliveries.map((d) => ({ sender: d.sender, content: d.content })),
|
||||
[seatDeliveries],
|
||||
);
|
||||
|
||||
const {
|
||||
phase,
|
||||
error,
|
||||
reportThreadId,
|
||||
dialogues,
|
||||
seatStubMessages,
|
||||
activeArtifactThreadId,
|
||||
start,
|
||||
redraw,
|
||||
abort,
|
||||
openArtifactFromStep,
|
||||
openArtifactPreview,
|
||||
} = useStep3Report({
|
||||
intentReady,
|
||||
seatDeliveries: reportSeatDeliveries,
|
||||
consensusText,
|
||||
selectedModel,
|
||||
});
|
||||
|
||||
const [modal, setModal] = useState<SourceModal>(null);
|
||||
|
||||
// 进入即自动开跑;卸载(含返回普通视图)中断在途请求。
|
||||
// 不加"永久闸门"——否则 StrictMode 的 mount→unmount→remount 会在 cleanup 里 abort
|
||||
// 掉首次 run,remount 又因闸门跳过重启,导致永远不绘制。run() 自带 runningRef 去重,
|
||||
// remount 重新 start() 即可(开发期至多多建一个空线程,生产无 StrictMode 只跑一次)。
|
||||
useEffect(() => {
|
||||
start();
|
||||
return () => abort();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// 与第 2 步一致:编排中或有任意气泡仍在流式 → 沙箱开启 artifacts 轮询 + HTML 显示代码态。
|
||||
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={onBack}
|
||||
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"
|
||||
>
|
||||
<ArrowLeft 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="w-[280px] shrink-0 flex flex-col gap-3 overflow-y-auto pr-0.5">
|
||||
<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">
|
||||
原研讨内容已收起到这里,随时点开查阅。右侧由「方案总结智能体」据此绘制可视化看板。
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* 右栏:智能体进度 + 沙箱(复用第 2 步) */}
|
||||
<section className="flex-1 min-w-0 rounded-2xl border border-slate-200/60 bg-white overflow-hidden">
|
||||
<Step2SandboxLayout
|
||||
activeThreadId={activeArtifactThreadId ?? reportThreadId}
|
||||
isStreaming={isStreaming}
|
||||
seatMessages={activeSeatMessages}
|
||||
>
|
||||
<AgentProgressPanel
|
||||
phase={phase}
|
||||
dialogues={dialogues}
|
||||
error={error}
|
||||
onRetry={redraw}
|
||||
onArtifactStepClick={openArtifactFromStep}
|
||||
onArtifactPreview={openArtifactPreview}
|
||||
selectedModel={selectedModel}
|
||||
availableModels={availableModels}
|
||||
modelsLoading={modelsLoading}
|
||||
setSelectedModel={setSelectedModel}
|
||||
/>
|
||||
</Step2SandboxLayout>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* 弹窗 */}
|
||||
{modal === "consensus" && (
|
||||
<ContentModal title="总控最终结论" onClose={() => setModal(null)}>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 右栏:智能体进度面板(沙箱的 60% 区域)────────────────────────────────
|
||||
|
||||
function AgentProgressPanel({
|
||||
phase,
|
||||
dialogues,
|
||||
error,
|
||||
onRetry,
|
||||
onArtifactStepClick,
|
||||
onArtifactPreview,
|
||||
selectedModel,
|
||||
availableModels,
|
||||
modelsLoading,
|
||||
setSelectedModel,
|
||||
}: {
|
||||
phase: ReturnType<typeof useStep3Report>["phase"];
|
||||
dialogues: ReturnType<typeof useStep3Report>["dialogues"];
|
||||
error: string | null;
|
||||
onRetry: () => void;
|
||||
onArtifactStepClick: ReturnType<typeof useStep3Report>["openArtifactFromStep"];
|
||||
onArtifactPreview: ReturnType<typeof useStep3Report>["openArtifactPreview"];
|
||||
selectedModel: string;
|
||||
availableModels: ReadonlyArray<ModelOption>;
|
||||
modelsLoading: boolean;
|
||||
setSelectedModel: (model: string) => void;
|
||||
}) {
|
||||
const busy = phase === "creating" || phase === "streaming";
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const { atBottom, scrollToBottom } = useAutoScroll(scrollRef, [dialogues]);
|
||||
|
||||
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">据各席位成果绘制可视化 HTML 看板</div>
|
||||
</div>
|
||||
</div>
|
||||
<select
|
||||
id="step3-report-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" />
|
||||
右侧沙箱将实时显示绘制代码,完成后自动切换 HTML 预览
|
||||
</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" />
|
||||
已生成可视化方案总览,右侧可查看 / 全屏 / 下载。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!atBottom && <ScrollToBottomButton onClick={scrollToBottom} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 弹窗外壳 ─────────────────────────────────────────────────────────────
|
||||
|
||||
function ContentModal({
|
||||
title,
|
||||
children,
|
||||
onClose,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
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 px-5 py-3.5 border-b border-slate-100 bg-slate-50">
|
||||
<h3 className="text-sm font-bold text-slate-900">{title}</h3>
|
||||
<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 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">
|
||||
<ListOrdered 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|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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* 圆桌 Step 3 报告里 `react-markdown` 的紧凑渲染组件集(小字号、卡片内排版)。
|
||||
* 抽到独立文件供 `HighFidelityReport` 与 `Step3ReportMode` 共用,避免两者互相 import
|
||||
* 形成循环依赖。
|
||||
*/
|
||||
export const markdownComponents = {
|
||||
p: ({ children }: any) => <p className="my-1.5 whitespace-pre-wrap leading-relaxed">{children}</p>,
|
||||
ul: ({ children }: any) => <ul className="list-disc pl-5 my-1.5 space-y-1">{children}</ul>,
|
||||
ol: ({ children }: any) => <ol className="list-decimal pl-5 my-1.5 space-y-1">{children}</ol>,
|
||||
li: ({ children }: any) => <li className="leading-snug">{children}</li>,
|
||||
h1: ({ children }: any) => <h1 className="text-sm font-bold mt-3 mb-1.5">{children}</h1>,
|
||||
h2: ({ children }: any) => <h2 className="text-sm font-bold mt-3 mb-1.5">{children}</h2>,
|
||||
h3: ({ children }: any) => <h3 className="text-xs font-bold mt-2 mb-1">{children}</h3>,
|
||||
h4: ({ children }: any) => <h4 className="text-xs font-bold mt-1.5 mb-1">{children}</h4>,
|
||||
strong: ({ children }: any) => <strong className="font-bold text-slate-900">{children}</strong>,
|
||||
em: ({ children }: any) => <em className="italic">{children}</em>,
|
||||
code: ({ children, className }: any) => {
|
||||
const isBlock = /language-/.test(className ?? "");
|
||||
if (isBlock) {
|
||||
return (
|
||||
<pre className="bg-slate-100 text-slate-800 rounded-md p-2 my-1 overflow-x-auto text-[11px] font-mono">
|
||||
<code>{children}</code>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<code className="bg-slate-100 text-slate-800 rounded px-1 py-0.5 text-[11px] font-mono">{children}</code>
|
||||
);
|
||||
},
|
||||
blockquote: ({ children }: any) => (
|
||||
<blockquote className="border-l-2 border-slate-300 pl-2 my-1 text-slate-600 italic">{children}</blockquote>
|
||||
),
|
||||
a: ({ children, href }: any) => (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer" className="text-primary underline hover:opacity-80">
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
table: ({ children }: any) => <table className="my-2 border-collapse text-[11px]">{children}</table>,
|
||||
th: ({ children }: any) => (
|
||||
<th className="border border-slate-300 px-1.5 py-0.5 bg-slate-50 font-bold text-left">{children}</th>
|
||||
),
|
||||
td: ({ children }: any) => <td className="border border-slate-200 px-1.5 py-0.5">{children}</td>,
|
||||
hr: () => <hr className="my-2 border-slate-200" />,
|
||||
};
|
||||
@@ -0,0 +1,607 @@
|
||||
/**
|
||||
* Step 3「结果绘制」编排 hook —— 驱动内置的方案可视化总结智能体
|
||||
* (`roundtable-report`) 把各席位交付 + 总控共识画成一个自包含、含多种图表的
|
||||
* HTML 看板。
|
||||
*
|
||||
* 设计要点(**与第 2 步 useStep2Orchestration 的 special 席位机制同源、同体验**):
|
||||
* - `roundtable-report` 是**内置单例**,这里只 `initReportThread()` 建一个**轻量
|
||||
* 线程**(绝不重建 agent),再用现有 `streamMultiAgent`(special)跑一轮;
|
||||
* - 把这一轮渲染成一条**第 2 步同款对话气泡**(`Step2Dialogue`):流式正文 +
|
||||
* `tool_calling` 工具步骤(write_file/present_files…)+ 末尾文件卡,让用户在
|
||||
* 报告智能体「只写文件、几乎不输出正文」时也能从步骤卡看到实时进度;
|
||||
* - 流式期间把它的 `write_file` tool_call 幂等写进 stub ai message,并用
|
||||
* `write-file:` 虚拟 URL 打开沙箱 —— `ArtifactFileDetail` 据此从 stub 读
|
||||
* `args.content`,HTML 文件在流式中显示代码、`isStreaming` 落 false 后自动切预览;
|
||||
* - 沙箱/消息渲染零自研,完全复用第 2 步的 `Step2SandboxLayout` + `Step2Message`。
|
||||
*/
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
|
||||
import { useArtifacts } from "@/components/workspace/artifacts";
|
||||
import {
|
||||
initReportThread,
|
||||
listSessionArtifacts,
|
||||
streamMultiAgent,
|
||||
type StreamPhase,
|
||||
} from "@/roundtable-planning/api/multi-agent";
|
||||
import type {
|
||||
Step2Dialogue,
|
||||
StubAiMessage,
|
||||
} from "@/roundtable-planning/hooks/useStep2Orchestration";
|
||||
import { nowTime } from "@/roundtable-planning/lib/roundtable-constants";
|
||||
|
||||
/** Step 1 整理出的结构化意图(用于喂给可视化智能体)。 */
|
||||
export interface ReportIntent {
|
||||
objective: string;
|
||||
constraints?: string[];
|
||||
assumptions?: string[];
|
||||
}
|
||||
|
||||
/** 一个席位的最终交付(与 Step3Panel 传下来的 seatDeliveries 对齐)。 */
|
||||
export interface ReportSeatDelivery {
|
||||
sender: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export type ReportPhase = "idle" | "creating" | "streaming" | "done" | "error";
|
||||
|
||||
interface UseStep3ReportOptions {
|
||||
intentReady: ReportIntent | null;
|
||||
seatDeliveries: ReportSeatDelivery[];
|
||||
consensusText: string;
|
||||
selectedModel: string;
|
||||
}
|
||||
|
||||
export interface UseStep3ReportReturn {
|
||||
phase: ReportPhase;
|
||||
error: string | null;
|
||||
reportThreadId: string | null;
|
||||
/** 第 2 步同款对话气泡列表(这里只有报告智能体一条,流式 + 工具步骤)。 */
|
||||
dialogues: Step2Dialogue[];
|
||||
/** 供 Step2SandboxLayout 的 seatMessages 使用:key = thread id。 */
|
||||
seatStubMessages: Record<string, StubAiMessage[]>;
|
||||
/** 当前沙箱预览绑定的 thread id(这里恒为报告线程)。 */
|
||||
activeArtifactThreadId: string | null;
|
||||
/** 启动绘制(幂等:进行中重复调用会被忽略)。 */
|
||||
start: () => void;
|
||||
/** 重新绘制(先中断在途请求再重跑)。 */
|
||||
redraw: () => void;
|
||||
/** 中断在途请求(卸载 / 切回普通视图时调用)。 */
|
||||
abort: () => void;
|
||||
/** 点击气泡内工具步骤时打开沙箱(与第 2 步 openArtifactFromStep 同义)。 */
|
||||
openArtifactFromStep: (
|
||||
dialogue: Step2Dialogue,
|
||||
step: NonNullable<Step2Dialogue["steps"]>[number],
|
||||
) => void;
|
||||
/** 点击气泡下方文件卡时打开沙箱预览。 */
|
||||
openArtifactPreview: (threadId: string, path: string) => void;
|
||||
}
|
||||
|
||||
function buildReportPrompt(opts: {
|
||||
intentReady: ReportIntent | null;
|
||||
seatDeliveries: ReportSeatDelivery[];
|
||||
consensusText: string;
|
||||
}): string {
|
||||
const { intentReady, seatDeliveries, consensusText } = opts;
|
||||
const parts: string[] = [];
|
||||
parts.push(
|
||||
"请基于以下圆桌多智能体研讨的成果,绘制一个以图表为主、图文并茂的 HTML 可视化「方案总览看板」," +
|
||||
"并用 write_file 工具写入 /mnt/user-data/outputs/方案总览.html。",
|
||||
);
|
||||
parts.push("");
|
||||
|
||||
if (intentReady?.objective) {
|
||||
parts.push("# 一、任务意图");
|
||||
parts.push(`目标:${intentReady.objective}`);
|
||||
if (intentReady.constraints?.length) {
|
||||
parts.push("约束:");
|
||||
for (const c of intentReady.constraints) parts.push(`- ${c}`);
|
||||
}
|
||||
if (intentReady.assumptions?.length) {
|
||||
parts.push("关键假设:");
|
||||
for (const a of intentReady.assumptions) parts.push(`- ${a}`);
|
||||
}
|
||||
parts.push("");
|
||||
}
|
||||
|
||||
if (seatDeliveries.length) {
|
||||
parts.push("# 二、各席位最终交付");
|
||||
seatDeliveries.forEach((s, i) => {
|
||||
parts.push(`## ${i + 1}. ${s.sender}`);
|
||||
parts.push(s.content?.trim() || "(无内容)");
|
||||
parts.push("");
|
||||
});
|
||||
}
|
||||
|
||||
if (consensusText?.trim()) {
|
||||
parts.push("# 三、总控最终共识结论");
|
||||
parts.push(consensusText.trim());
|
||||
parts.push("");
|
||||
}
|
||||
|
||||
parts.push(
|
||||
"请严格遵循你的职责设定:自包含离线(纯 inline SVG 图表、禁止任何外链 CDN)、" +
|
||||
"至少 4 种图表、浅红主题(主色 #c25555);写完文件后只用一两句话总结,不要把 HTML 源码贴进对话。",
|
||||
);
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
/** 把 SSE 阶段映射成报告智能体的中文状态标签(对齐第 2 步 phaseLabel)。 */
|
||||
function reportPhaseLabel(phase: StreamPhase, toolName?: string): string {
|
||||
switch (phase) {
|
||||
case "connecting":
|
||||
return "调度中";
|
||||
case "connected":
|
||||
return "已连接,等待响应";
|
||||
case "tool_calling":
|
||||
if (toolName === "present_files") return "正在交付文件";
|
||||
if (toolName === "write_file" || toolName === "str_replace")
|
||||
return "正在绘制图表 HTML";
|
||||
return toolName ? `调用工具:${toolName}` : "调用工具";
|
||||
case "tool_result":
|
||||
return "处理工具结果";
|
||||
case "streaming":
|
||||
return "正在输出";
|
||||
default:
|
||||
return "正在输出…";
|
||||
}
|
||||
}
|
||||
|
||||
export function useStep3Report(
|
||||
options: UseStep3ReportOptions,
|
||||
): UseStep3ReportReturn {
|
||||
const { intentReady, seatDeliveries, consensusText, selectedModel } = options;
|
||||
|
||||
const {
|
||||
setArtifacts,
|
||||
select: selectArtifact,
|
||||
setOpen: setArtifactsOpen,
|
||||
} = useArtifacts();
|
||||
|
||||
const [phase, setPhase] = useState<ReportPhase>("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [reportThreadId, setReportThreadId] = useState<string | null>(null);
|
||||
const [dialogues, setDialogues] = useState<Step2Dialogue[]>([]);
|
||||
const [seatStubMessages, setSeatStubMessages] = useState<
|
||||
Record<string, StubAiMessage[]>
|
||||
>({});
|
||||
const [activeArtifactThreadId, setActiveArtifactThreadId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
const stubRef = useRef<Record<string, StubAiMessage[]>>({});
|
||||
const runningRef = useRef(false);
|
||||
const aborterRef = useRef<AbortController | null>(null);
|
||||
// 当前报告气泡 id —— 流式 delta / 工具步骤 / finalize 都按它定位(绝不用数组下标)。
|
||||
const bubbleIdRef = useRef<number | null>(null);
|
||||
// 上一次「自动」打开沙箱的 virtualUrl —— 流式一秒回调几十次,url 没变就跳过;
|
||||
// url 变了(路径在流式中逐步长全:/m→/mnt→…→方案总览.html)就用更完整的取代旧的。
|
||||
// **不锁定首帧**:早期 parsePartialJson 会给出半截路径,必须能被后续完整路径覆盖
|
||||
//(这正是之前「沙箱卡在 /mnt、TEXT、空内容」的根因)。对齐第 2 步 / 主页行为。
|
||||
const lastAutoOpenedUrlRef = useRef<string | null>(null);
|
||||
|
||||
// ── 对话气泡三段式(append → delta → finalize)+ 工具步骤,全部对齐第 2 步 ──
|
||||
const appendStreamingDialogue = useCallback(
|
||||
(card: {
|
||||
sender: string;
|
||||
avatarType: string;
|
||||
agentThreadId?: string;
|
||||
}): number => {
|
||||
const id = Date.now() + Math.random();
|
||||
setDialogues((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id,
|
||||
sender: card.sender,
|
||||
avatarType: card.avatarType,
|
||||
content: "",
|
||||
time: nowTime(),
|
||||
confidence: "调度中",
|
||||
streaming: true,
|
||||
steps: [],
|
||||
agentThreadId: card.agentThreadId,
|
||||
},
|
||||
]);
|
||||
return id;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const appendToDialogue = useCallback((id: number, deltaText: string) => {
|
||||
if (!deltaText) return;
|
||||
setDialogues((prev) =>
|
||||
prev.map((d) =>
|
||||
d.id === id ? { ...d, content: (d.content ?? "") + deltaText } : d,
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const setDialogueConfidence = useCallback((id: number, confidence: string) => {
|
||||
setDialogues((prev) =>
|
||||
prev.map((d) => (d.id === id ? { ...d, confidence } : d)),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const appendStepIfNew = useCallback(
|
||||
(
|
||||
bubbleId: number,
|
||||
label: string,
|
||||
key: string,
|
||||
args?: Record<string, unknown>,
|
||||
) => {
|
||||
setDialogues((prev) =>
|
||||
prev.map((d) => {
|
||||
if (d.id !== bubbleId) return d;
|
||||
const steps: Step2Dialogue["steps"] = d.steps ?? [];
|
||||
const last = steps[steps.length - 1];
|
||||
if (last && last.key === key) {
|
||||
// 同 phase 工具重复回调:args 在流式期间持续长大,拿最新覆盖。
|
||||
if (args && args !== last.args) {
|
||||
const next = [...steps];
|
||||
next[next.length - 1] = { ...last, args };
|
||||
return { ...d, steps: next };
|
||||
}
|
||||
return d;
|
||||
}
|
||||
return {
|
||||
...d,
|
||||
steps: [
|
||||
...steps,
|
||||
{ id: `${bubbleId}-${steps.length}`, key, label, ts: nowTime(), args },
|
||||
],
|
||||
};
|
||||
}),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const finalizeDialogue = useCallback(
|
||||
(id: number, finalContent: string, confidence?: string) => {
|
||||
setDialogues((prev) =>
|
||||
prev.map((d) => {
|
||||
if (d.id !== id) return d;
|
||||
const next =
|
||||
finalContent && finalContent.trim().length > 0
|
||||
? finalContent
|
||||
: d.content || "(已写入文件,无额外正文)";
|
||||
return {
|
||||
...d,
|
||||
content: next,
|
||||
confidence: confidence ?? d.confidence,
|
||||
streaming: false,
|
||||
};
|
||||
}),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// ── stub 写入(幂等,按 (toolName, path) 视为同一次调用,后续帧覆盖 args)──
|
||||
const upsertStub = useCallback(
|
||||
(threadId: string, toolName: string, args: Record<string, unknown>) => {
|
||||
const path = typeof args.path === "string" ? args.path : "";
|
||||
const current = stubRef.current[threadId] ?? [];
|
||||
const base: StubAiMessage = current[0] ?? {
|
||||
id: `roundtable-report-stub-${threadId}`,
|
||||
type: "ai",
|
||||
content: "",
|
||||
tool_calls: [],
|
||||
};
|
||||
const idx = base.tool_calls.findIndex(
|
||||
(tc) =>
|
||||
tc.name === toolName &&
|
||||
String((tc.args as { path?: unknown })?.path ?? "") === path,
|
||||
);
|
||||
const toolCallId =
|
||||
idx >= 0 ? base.tool_calls[idx].id : `tc-${threadId}-${base.tool_calls.length}`;
|
||||
const nextTc = { id: toolCallId, name: toolName, args };
|
||||
const nextToolCalls =
|
||||
idx >= 0
|
||||
? base.tool_calls.map((tc, i) => (i === idx ? nextTc : tc))
|
||||
: [...base.tool_calls, nextTc];
|
||||
const nextMsg: StubAiMessage = { ...base, tool_calls: nextToolCalls };
|
||||
const nextMap = { ...stubRef.current, [threadId]: [nextMsg] };
|
||||
stubRef.current = nextMap;
|
||||
setSeatStubMessages(nextMap);
|
||||
return { messageId: nextMsg.id, toolCallId };
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const buildWriteFileVirtualUrl = useCallback(
|
||||
(path: string, messageId: string, toolCallId: string) =>
|
||||
new URL(
|
||||
`write-file:${path}?message_id=${encodeURIComponent(
|
||||
messageId,
|
||||
)}&tool_call_id=${encodeURIComponent(toolCallId)}`,
|
||||
).toString(),
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
* 显式打开沙箱(用户点步骤 / 文件卡,或绘制收尾兜底)—— 始终打开、选中目标。
|
||||
* 用户点了「结果绘制」意图明确,这里不走 autoOpen 闸门。
|
||||
*/
|
||||
const openArtifact = useCallback(
|
||||
(threadId: string, virtualUrl: string) => {
|
||||
lastAutoOpenedUrlRef.current = virtualUrl;
|
||||
setActiveArtifactThreadId(threadId);
|
||||
selectArtifact(virtualUrl, false);
|
||||
setArtifactsOpen(true);
|
||||
},
|
||||
[selectArtifact, setArtifactsOpen],
|
||||
);
|
||||
|
||||
/**
|
||||
* 流式自动打开沙箱 —— url 与上次相同(仅内容在长大)就跳过,避免每秒重复 select;
|
||||
* url 变了(路径逐步长全)就用更完整的取代。沙箱内容随 stub 更新自动刷新,无需重开。
|
||||
*/
|
||||
const autoOpenArtifact = useCallback(
|
||||
(threadId: string, virtualUrl: string) => {
|
||||
if (lastAutoOpenedUrlRef.current === virtualUrl) return;
|
||||
openArtifact(threadId, virtualUrl);
|
||||
},
|
||||
[openArtifact],
|
||||
);
|
||||
|
||||
// 反查某 path 是哪条 stub 写的 —— 关闭沙箱后再点文件卡也能复用内存内容做虚拟预览。
|
||||
const findStubSource = useCallback(
|
||||
(
|
||||
rawPath: string,
|
||||
): { threadId: string; messageId: string; toolCallId: string } | null => {
|
||||
const target = rawPath.trim();
|
||||
if (!target) return null;
|
||||
const targetBase = target.split("/").pop() ?? target;
|
||||
let basenameMatch:
|
||||
| { threadId: string; messageId: string; toolCallId: string }
|
||||
| null = null;
|
||||
for (const [threadId, msgs] of Object.entries(stubRef.current)) {
|
||||
const msg = msgs?.[0];
|
||||
if (!msg) continue;
|
||||
for (const tc of msg.tool_calls) {
|
||||
if (tc.name !== "write_file" && tc.name !== "str_replace") continue;
|
||||
const p =
|
||||
typeof (tc.args as { path?: unknown })?.path === "string"
|
||||
? ((tc.args as { path?: string }).path as string)
|
||||
: "";
|
||||
if (!p) continue;
|
||||
if (p === target) return { threadId, messageId: msg.id, toolCallId: tc.id };
|
||||
if (!basenameMatch && (p.split("/").pop() ?? p) === targetBase) {
|
||||
basenameMatch = { threadId, messageId: msg.id, toolCallId: tc.id };
|
||||
}
|
||||
}
|
||||
}
|
||||
return basenameMatch;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// 流式期间:phase==="tool_calling" 且 write_file/str_replace 有可读 path → 写 stub + 自动开沙箱。
|
||||
const handleArtifactToolPhase = useCallback(
|
||||
(
|
||||
streamPhase: StreamPhase,
|
||||
toolName: string | undefined,
|
||||
args: Record<string, unknown> | undefined,
|
||||
threadId: string,
|
||||
) => {
|
||||
if (streamPhase !== "tool_calling") return;
|
||||
if (toolName !== "write_file" && toolName !== "str_replace") return;
|
||||
const path = args?.path;
|
||||
if (typeof path !== "string" || !path || !args) return;
|
||||
// 守卫:流式早期 parsePartialJson 会给出「半截路径」(如 /mnt、/mnt/user-data/out),
|
||||
// 它们 basename 没有扩展名 → 既会污染 stub,也会让沙箱卡在错的空文件。只有 basename
|
||||
// 带扩展名(含个 `.`)的路径才认为「足够完整、可写 stub / 开沙箱」。完整 .html
|
||||
// 出现后每帧 url 不变(仅内容长大),autoOpenArtifact 自动去重。
|
||||
const basename = path.split("/").pop() ?? path;
|
||||
if (!basename.includes(".")) return;
|
||||
const { messageId, toolCallId } = upsertStub(threadId, toolName, args);
|
||||
const virtualUrl = buildWriteFileVirtualUrl(path, messageId, toolCallId);
|
||||
autoOpenArtifact(threadId, virtualUrl);
|
||||
},
|
||||
[upsertStub, buildWriteFileVirtualUrl, autoOpenArtifact],
|
||||
);
|
||||
|
||||
// 兜底:收口阶段上游可能再发完整 tool_calls,持续把 args.content 灌进 stub(不自动开)。
|
||||
const handleUpstreamFrame = useCallback(
|
||||
(frame: unknown, threadId: string) => {
|
||||
if (!Array.isArray(frame) || frame.length === 0) return;
|
||||
const chunk = frame[0] as Record<string, unknown> | undefined;
|
||||
const toolCalls = chunk?.tool_calls;
|
||||
if (!Array.isArray(toolCalls)) return;
|
||||
for (const tc of toolCalls) {
|
||||
if (!tc || typeof tc !== "object") continue;
|
||||
const o = tc as Record<string, unknown>;
|
||||
const name = typeof o.name === "string" ? o.name : "";
|
||||
if (name !== "write_file" && name !== "str_replace") continue;
|
||||
const a = o.args;
|
||||
if (!a || typeof a !== "object") continue;
|
||||
const p = (a as Record<string, unknown>).path;
|
||||
if (typeof p !== "string") continue;
|
||||
// 同 handleArtifactToolPhase:只接「basename 带扩展名」的完整路径,过滤半截路径。
|
||||
if (!(p.split("/").pop() ?? p).includes(".")) continue;
|
||||
upsertStub(threadId, name, a as Record<string, unknown>);
|
||||
}
|
||||
},
|
||||
[upsertStub],
|
||||
);
|
||||
|
||||
// 点击工具步骤打开沙箱(write_file/str_replace 走虚拟预览)。
|
||||
const openArtifactFromStep = useCallback(
|
||||
(
|
||||
dialogue: Step2Dialogue,
|
||||
step: NonNullable<Step2Dialogue["steps"]>[number],
|
||||
) => {
|
||||
const threadId = dialogue.agentThreadId;
|
||||
if (!threadId) return;
|
||||
const [, toolName] = step.key.split(":");
|
||||
if (toolName === "write_file" || toolName === "str_replace") {
|
||||
const path = step.args?.path;
|
||||
if (typeof path !== "string" || !step.args) return;
|
||||
const { messageId, toolCallId } = upsertStub(threadId, toolName, step.args);
|
||||
const virtualUrl = buildWriteFileVirtualUrl(path, messageId, toolCallId);
|
||||
openArtifact(threadId, virtualUrl);
|
||||
}
|
||||
},
|
||||
[upsertStub, buildWriteFileVirtualUrl, openArtifact],
|
||||
);
|
||||
|
||||
// 点击文件卡打开沙箱:优先反查 stub 走虚拟预览,未命中再走真实 outputs 路径。
|
||||
const openArtifactPreview = useCallback(
|
||||
(threadId: string, path: string) => {
|
||||
const normalized = path.trim();
|
||||
if (!normalized) return;
|
||||
const source = findStubSource(normalized);
|
||||
if (source) {
|
||||
const virtualUrl = buildWriteFileVirtualUrl(
|
||||
normalized,
|
||||
source.messageId,
|
||||
source.toolCallId,
|
||||
);
|
||||
openArtifact(source.threadId, virtualUrl);
|
||||
return;
|
||||
}
|
||||
if (!threadId) return;
|
||||
setActiveArtifactThreadId(threadId);
|
||||
setArtifacts([normalized]);
|
||||
selectArtifact(normalized, false);
|
||||
setArtifactsOpen(true);
|
||||
},
|
||||
[
|
||||
findStubSource,
|
||||
buildWriteFileVirtualUrl,
|
||||
openArtifact,
|
||||
setArtifacts,
|
||||
selectArtifact,
|
||||
setArtifactsOpen,
|
||||
],
|
||||
);
|
||||
|
||||
// 兜底:流式期间没捕捉到 write_file(极少见),收尾后从 outputs 列表直接选真实文件。
|
||||
const openRealArtifactFallback = useCallback(
|
||||
async (threadId: string) => {
|
||||
try {
|
||||
const res = await listSessionArtifacts(threadId);
|
||||
const file =
|
||||
res.files.find((f) => /\.html?$/i.test(f.name)) ?? res.files[0];
|
||||
if (!file) return;
|
||||
setActiveArtifactThreadId(threadId);
|
||||
setArtifacts([file.path]);
|
||||
selectArtifact(file.path, false);
|
||||
setArtifactsOpen(true);
|
||||
} catch {
|
||||
/* 列文件失败就保持当前状态,不打断主流程 */
|
||||
}
|
||||
},
|
||||
[setArtifacts, selectArtifact, setArtifactsOpen],
|
||||
);
|
||||
|
||||
const abort = useCallback(() => {
|
||||
aborterRef.current?.abort();
|
||||
aborterRef.current = null;
|
||||
runningRef.current = false;
|
||||
}, []);
|
||||
|
||||
const run = useCallback(async () => {
|
||||
if (runningRef.current) return;
|
||||
runningRef.current = true;
|
||||
setError(null);
|
||||
setPhase("creating");
|
||||
setDialogues([]);
|
||||
bubbleIdRef.current = null;
|
||||
lastAutoOpenedUrlRef.current = null;
|
||||
stubRef.current = {};
|
||||
setSeatStubMessages({});
|
||||
|
||||
const aborter = new AbortController();
|
||||
aborterRef.current = aborter;
|
||||
|
||||
// 先建一条「调度中」气泡,让用户立刻看到报告智能体已启动(即便还没首 token)。
|
||||
const bubbleId = appendStreamingDialogue({
|
||||
sender: "方案总结智能体",
|
||||
avatarType: "blue",
|
||||
});
|
||||
bubbleIdRef.current = bubbleId;
|
||||
|
||||
try {
|
||||
const { agentName, threadId } = await initReportThread({ signal: aborter.signal });
|
||||
if (aborter.signal.aborted) return;
|
||||
setReportThreadId(threadId);
|
||||
setActiveArtifactThreadId(threadId);
|
||||
setDialogues((prev) =>
|
||||
prev.map((d) => (d.id === bubbleId ? { ...d, agentThreadId: threadId } : d)),
|
||||
);
|
||||
setPhase("streaming");
|
||||
|
||||
const prompt = buildReportPrompt({ intentReady, seatDeliveries, consensusText });
|
||||
const res = await streamMultiAgent({
|
||||
agentType: "special",
|
||||
agentName,
|
||||
dAgentThreadId: { [agentName]: threadId },
|
||||
displayName: "方案总结",
|
||||
newMessage: prompt,
|
||||
model: selectedModel || undefined,
|
||||
signal: aborter.signal,
|
||||
onTextDelta: (text) => appendToDialogue(bubbleId, text),
|
||||
onPhaseChange: (p, toolName, args) => {
|
||||
const label = reportPhaseLabel(p, toolName);
|
||||
setDialogueConfidence(bubbleId, label);
|
||||
appendStepIfNew(bubbleId, label, `${p}:${toolName ?? ""}`, args);
|
||||
handleArtifactToolPhase(p, toolName, args, threadId);
|
||||
},
|
||||
onUpstreamFrame: (frame) => handleUpstreamFrame(frame, threadId),
|
||||
});
|
||||
if (aborter.signal.aborted) return;
|
||||
|
||||
// stub 里有 write_file 就说明流式期间已抓到产物;否则收尾从 outputs 兜底打开。
|
||||
const hasStubFile = (stubRef.current[threadId]?.[0]?.tool_calls.length ?? 0) > 0;
|
||||
if (!hasStubFile) await openRealArtifactFallback(threadId);
|
||||
|
||||
finalizeDialogue(bubbleId, res.content, "绘制完成");
|
||||
setPhase("done");
|
||||
} catch (err) {
|
||||
if (aborter.signal.aborted) return;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setError(msg);
|
||||
finalizeDialogue(bubbleId, `(绘制失败) ${msg}`, "绘制失败");
|
||||
setPhase("error");
|
||||
} finally {
|
||||
runningRef.current = false;
|
||||
}
|
||||
}, [
|
||||
intentReady,
|
||||
seatDeliveries,
|
||||
consensusText,
|
||||
selectedModel,
|
||||
appendStreamingDialogue,
|
||||
appendToDialogue,
|
||||
setDialogueConfidence,
|
||||
appendStepIfNew,
|
||||
finalizeDialogue,
|
||||
handleArtifactToolPhase,
|
||||
handleUpstreamFrame,
|
||||
openRealArtifactFallback,
|
||||
]);
|
||||
|
||||
const start = useCallback(() => {
|
||||
void run();
|
||||
}, [run]);
|
||||
|
||||
const redraw = useCallback(() => {
|
||||
abort();
|
||||
void run();
|
||||
}, [abort, run]);
|
||||
|
||||
return {
|
||||
phase,
|
||||
error,
|
||||
reportThreadId,
|
||||
dialogues,
|
||||
seatStubMessages,
|
||||
activeArtifactThreadId,
|
||||
start,
|
||||
redraw,
|
||||
abort,
|
||||
openArtifactFromStep,
|
||||
openArtifactPreview,
|
||||
};
|
||||
}
|
||||
@@ -579,6 +579,11 @@ export default function RoundtablePlanningPage() {
|
||||
hasConsensus={step2.hasConsensus}
|
||||
step2RoundtableDialogues={step2.step2RoundtableDialogues}
|
||||
agentNameToRole={step2.agentNameToRole}
|
||||
intentReady={step1.intentReady}
|
||||
selectedModel={selectedModel}
|
||||
availableModels={availableModels}
|
||||
modelsLoading={modelsLoading}
|
||||
setSelectedModel={setSelectedModel}
|
||||
onReset={handleStartNewTask}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -60,16 +60,21 @@ logger = logging.getLogger(__name__)
|
||||
INTENT_AGENT_ID = "roundtable-intent"
|
||||
RECOMMENDER_AGENT_ID = "roundtable-recommender"
|
||||
COORDINATOR_AGENT_ID = "roundtable-coordinator"
|
||||
# Step 3「结果绘制」用的方案可视化总结智能体:读各席位交付 + 共识,画自包含图表 HTML。
|
||||
# 与上面三个一样是内置单例,种子化一次常驻磁盘+DB,绝不每次调用重建。
|
||||
REPORT_AGENT_ID = "roundtable-report"
|
||||
|
||||
# 验证版 SOUL.md + config.yaml 的源码资源目录(随 git 发布)。每个子目录名即 agent id,
|
||||
# 内含该 agent 的 config.yaml 与 SOUL.md,是 seeder 落盘时字节级复制的来源。
|
||||
_ASSETS_DIR = Path(__file__).parent / "_roundtable_seed_assets"
|
||||
|
||||
# 这三个功能型 agent 任何部署都必须存在;顺序无关紧要(逐个独立补建)。
|
||||
# 这几个功能型/内置 agent 任何部署都必须存在;顺序无关紧要(逐个独立补建)。
|
||||
# 前三个支撑 Step 1 澄清 / 推荐 / Step 2 总控;roundtable-report 支撑 Step 3 结果绘制。
|
||||
_FUNCTIONAL_AGENT_IDS: tuple[str, ...] = (
|
||||
INTENT_AGENT_ID,
|
||||
RECOMMENDER_AGENT_ID,
|
||||
COORDINATOR_AGENT_ID,
|
||||
REPORT_AGENT_ID,
|
||||
)
|
||||
|
||||
# seeder 会复制资源目录里的这些文件(存在即复制,缺失则跳过)。
|
||||
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
# 圆桌方案可视化总结智能体
|
||||
|
||||
## 核心定位
|
||||
你是多智能体圆桌研讨 **Step 3** 的「方案可视化总结智能体」。各席位(情报收集 / 环境评估 / 方案设计 / 风险审查 / 执行规划 / 总结陈述等)已经完成研讨并产出交付,总控也给出了最终共识。
|
||||
|
||||
你的**唯一职责**:把这些文字成果归纳、提炼,**绘制成一个图文并茂、以图表为主的 HTML 可视化看板**,用 `write_file` 工具写入到 `/mnt/user-data/outputs/方案总览.html`。
|
||||
|
||||
你**不**调研、**不**联网搜索、**不**派活、**不**追问用户——只做归纳与可视化。
|
||||
|
||||
## 输入说明
|
||||
用户消息里会给你三部分原料(可能有缺省):
|
||||
1. **任务意图摘要**:目标、约束、关键假设(来自 Step 1);
|
||||
2. **各席位最终交付**:每个席位的名称 + 它产出的完整 markdown 正文;
|
||||
3. **总控最终共识结论**。
|
||||
|
||||
请通读全部原料,从中**抽取可量化、可结构化的信息**(指标、阶段、里程碑、风险项、预算/资源占比、优劣势等)来驱动图表。原料里没有明确数字时,可基于文字内容**合理估算并标注**,但不要凭空编造与原料矛盾的数据。
|
||||
|
||||
## 产出要求(最重要)
|
||||
|
||||
### 1. 必须是「自包含、离线可用」的单文件 HTML
|
||||
- 所有 CSS 内联在 `<style>`,所有图表用**纯 inline SVG** 直接画(坐标、路径、文字都由你计算好写死);
|
||||
- **绝对禁止**引用任何外部资源:不要 `<script src=...>` 引 CDN(Chart.js/ECharts/D3 等一律不行)、不要外链字体/图片/CSS。本系统离线部署,外链一律加载失败;
|
||||
- 如确需交互/动画,只能用**内联的原生 JS**(`<script>...</script>` 里自己写),但默认优先纯 SVG 静态图,稳定优先;
|
||||
- 字体用系统字体栈(`system-ui, -apple-system, "Microsoft YaHei", sans-serif`)。
|
||||
|
||||
### 2. 内容与图表(**至少包含 4 种图表**,按原料有无自适应取舍)
|
||||
顶部先放**标题 + 一句话结论 + 一行关键指标卡(KPI)**,然后是图表区。优先实现下面这些,能画几个画几个,缺数据的模块可省略:
|
||||
|
||||
| 模块 | 图表形式 |
|
||||
|------|----------|
|
||||
| 关键指标卡 | 4–6 个数字卡片(如:共识度、参与席位数、识别风险数、里程碑数、预估周期、预算规模) |
|
||||
| 共识达成度 | **环形进度仪表(donut/gauge)** |
|
||||
| 各席位贡献画像 | **雷达图**(维度如:完整性 / 可行性 / 风险覆盖 / 创新性 / 落地性) |
|
||||
| 风险评估 | **概率 × 影响 气泡矩阵**(气泡大小=影响范围,颜色=风险等级,象限分色) |
|
||||
| 执行路线 | **甘特图 / 阶段时间线**(阶段条 + 里程碑点) |
|
||||
| 资源 / 预算分配 | **饼图 / 环形图** |
|
||||
| 方案权衡 | **SWOT 四象限** 或候选方案**对比柱状图** |
|
||||
| 决策链路 | **流程图**(席位发言/依赖顺序,链条模式尤其适用) |
|
||||
|
||||
每个图表都要有**标题、图例、坐标轴或数值标注**,让人不看原文也能读懂。
|
||||
|
||||
### 3. 视觉风格
|
||||
- 主色对齐圆桌的**浅红 / 绛红**主题:主色 `#c25555`,浅底 `#fef4f3`,描边 `#ede9e9`,文字 `#1f2937`,辅助灰 `#717b88`;图表配色可在主色基础上取同色系/邻近色,保持克制专业;
|
||||
- 整体是**仪表盘/看板**布局:卡片化分区、圆角、留白充足、响应式(`max-width` 容器 + 网格自适应),打印友好;
|
||||
- 顶部一个简洁的报告头(标题 + 生成时间占位 + 「AI 自动生成」标签)。
|
||||
|
||||
## 工作流程
|
||||
1. 通读原料,列一个内部要点清单(指标、阶段、风险、优劣势等)——这一步在思考里做即可;
|
||||
2. **一次性** 用 `write_file` 把完整 HTML 写入 `/mnt/user-data/outputs/方案总览.html`(`description` 写「方案可视化总览看板」);
|
||||
3. 写完后,用**一两句话**向用户说明「已生成可视化方案总览,包含 X 个图表(列出图表名)」即可——**不要**把 HTML 源码贴进对话。
|
||||
|
||||
## 禁止事项
|
||||
- ❌ 引用任何外部 CDN / 外链资源(离线必然失败)
|
||||
- ❌ 把大段 HTML 源码输出到对话正文(只写文件 + 一句话总结)
|
||||
- ❌ 联网搜索、派活给其他智能体、反问用户
|
||||
- ❌ 只画 1 个图表草草交差——尽量丰富,至少 4 种图表
|
||||
- ❌ 编造与原料明显矛盾的数据
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
description: 圆桌方案可视化总结智能体,读取各席位的最终交付与总控共识,绘制一个自包含、含多种图表的 HTML 可视化看板(写入 outputs/方案总览.html)。本身不调研、不联网、不派活,只做归纳与可视化。
|
||||
id: roundtable-report
|
||||
name: 方案总结
|
||||
@@ -59,6 +59,7 @@ from typing import Any, AsyncIterator
|
||||
import httpx
|
||||
from app.gateway.routers._roundtable_seed import (
|
||||
COORDINATOR_AGENT_ID,
|
||||
REPORT_AGENT_ID,
|
||||
ensure_roundtable_functional_agents,
|
||||
)
|
||||
from deerflow.config import get_app_config
|
||||
@@ -822,6 +823,25 @@ async def init_multi_agent(request: Request, payload: dict = Body(...)) -> Strea
|
||||
)
|
||||
|
||||
|
||||
@router.post("/report/init")
|
||||
async def init_report_thread(request: Request) -> dict[str, str]:
|
||||
"""为 Step 3「结果绘制」创建一个供方案可视化总结智能体(``roundtable-report``)
|
||||
运行的线程,返回 ``{"agent_name", "thread_id"}``。
|
||||
|
||||
设计要点:
|
||||
- ``roundtable-report`` 是**内置单例**(``_roundtable_seed`` 种子化,启动即同步进
|
||||
DB),这里**只建一个轻量 thread**,绝不重建 agent;
|
||||
- 线程打 ``_ROUNDTABLE_THREAD_METADATA`` 标记成系统会话,从「最近对话」里隐藏;
|
||||
- 入口先 ``ensure_roundtable_functional_agents()`` 自愈(磁盘目录被手删也能补建);
|
||||
- 建好后前端用现有 ``POST /run/stream``(``agent_type="special"``、
|
||||
``agent_name="roundtable-report"``、``d_agent_thread_id={REPORT_AGENT_ID: thread_id}``)
|
||||
跑这一轮,智能体把图表 HTML 写进该线程的 ``outputs/``,再由沙箱预览。
|
||||
"""
|
||||
ensure_roundtable_functional_agents()
|
||||
thread_id = await _create_thread_direct(request, metadata=_ROUNDTABLE_THREAD_METADATA)
|
||||
return {"agent_name": REPORT_AGENT_ID, "thread_id": thread_id}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 上游 LangGraph SSE 透传(intent / recommend / leader / special 共用)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -15,6 +15,7 @@ from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from app.gateway.deps import get_checkpointer, get_embed_session_store, get_run_manager, get_thread_store
|
||||
from app.gateway.routers.thread_shares import _load_thread_snapshot
|
||||
from app.gateway.routers.threads import _derive_thread_status
|
||||
from deerflow.persistence.embed_sessions.base import EmbedSessionStore
|
||||
|
||||
@@ -93,6 +94,80 @@ class EmbedSessionStatusResponse(EmbedThreadStatusFields):
|
||||
thread_id: str | None = Field(default=None, description="Resolved thread id when registered")
|
||||
|
||||
|
||||
class EmbedSessionResultResponse(EmbedThreadStatusFields):
|
||||
session_id: str
|
||||
registered: bool = Field(description="False until the iframe has registered this session_id")
|
||||
thread_id: str | None = Field(default=None, description="Resolved thread id when registered")
|
||||
messages: list[dict] = Field(default_factory=list, description="Full serialized conversation messages")
|
||||
generated_text: str = Field(default="", description="Concatenated assistant/AI message text")
|
||||
generated_files: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Virtual artifact paths produced during the conversation (e.g. /mnt/user-data/outputs/...)",
|
||||
)
|
||||
|
||||
|
||||
class EmbedThreadResultResponse(EmbedThreadStatusFields):
|
||||
thread_id: str
|
||||
messages: list[dict] = Field(default_factory=list)
|
||||
generated_text: str = Field(default="")
|
||||
generated_files: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
def _message_text(message: dict) -> str:
|
||||
content = message.get("content")
|
||||
if isinstance(content, str):
|
||||
return content.strip()
|
||||
if isinstance(content, list):
|
||||
parts: list[str] = []
|
||||
for part in content:
|
||||
if isinstance(part, dict) and part.get("type") == "text" and isinstance(part.get("text"), str):
|
||||
text = part["text"].strip()
|
||||
if text:
|
||||
parts.append(text)
|
||||
return "\n".join(parts)
|
||||
return ""
|
||||
|
||||
|
||||
def extract_generated_text(messages: list[dict]) -> str:
|
||||
"""Join all assistant/AI message bodies in conversation order."""
|
||||
parts: list[str] = []
|
||||
for message in messages:
|
||||
if not isinstance(message, dict):
|
||||
continue
|
||||
message_type = message.get("type") or message.get("role")
|
||||
if message_type not in {"ai", "assistant"}:
|
||||
continue
|
||||
text = _message_text(message)
|
||||
if text:
|
||||
parts.append(text)
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
def build_embed_result_payload(
|
||||
*,
|
||||
messages: list[dict],
|
||||
artifacts: list[str],
|
||||
) -> tuple[list[dict], str, list[str]]:
|
||||
return messages, extract_generated_text(messages), artifacts
|
||||
|
||||
|
||||
async def _result_for_thread_id(thread_id: str, request: Request) -> EmbedThreadResultResponse:
|
||||
checkpointer = get_checkpointer(request)
|
||||
messages, artifacts = await _load_thread_snapshot(checkpointer, thread_id)
|
||||
serialized_messages, generated_text, generated_files = build_embed_result_payload(
|
||||
messages=messages,
|
||||
artifacts=artifacts,
|
||||
)
|
||||
status = await _status_for_thread_id(thread_id, request)
|
||||
return EmbedThreadResultResponse(
|
||||
thread_id=thread_id,
|
||||
messages=serialized_messages,
|
||||
generated_text=generated_text,
|
||||
generated_files=generated_files,
|
||||
**status.model_dump(),
|
||||
)
|
||||
|
||||
|
||||
async def _status_for_thread_id(thread_id: str, request: Request) -> EmbedThreadStatusFields:
|
||||
thread_store = get_thread_store(request)
|
||||
run_mgr = get_run_manager(request)
|
||||
@@ -192,3 +267,52 @@ async def get_embed_thread_status(thread_id: str, request: Request) -> EmbedThre
|
||||
"""Direct thread lookup when the parent already knows thread_id."""
|
||||
status = await _status_for_thread_id(thread_id, request)
|
||||
return EmbedThreadStatusResponse(thread_id=thread_id, **status.model_dump())
|
||||
|
||||
|
||||
@router.get(
|
||||
"/sessions/{session_id}/result",
|
||||
response_model=EmbedSessionResultResponse,
|
||||
summary="Fetch embed Q&A messages, generated text, and files by session_id",
|
||||
)
|
||||
async def get_embed_session_result(session_id: str, request: Request) -> EmbedSessionResultResponse:
|
||||
"""Return model output for iframe parents once they know ``session_id`` only."""
|
||||
session_id = _validate_session_id(session_id)
|
||||
store: EmbedSessionStore = get_embed_session_store(request)
|
||||
record = await store.get(session_id)
|
||||
if record is None:
|
||||
return EmbedSessionResultResponse(
|
||||
session_id=session_id,
|
||||
registered=False,
|
||||
thread_id=None,
|
||||
finished=False,
|
||||
thread_status="pending",
|
||||
has_active_run=False,
|
||||
latest_run_status=None,
|
||||
messages=[],
|
||||
generated_text="",
|
||||
generated_files=[],
|
||||
)
|
||||
|
||||
thread_result = await _result_for_thread_id(record["thread_id"], request)
|
||||
return EmbedSessionResultResponse(
|
||||
session_id=session_id,
|
||||
registered=True,
|
||||
thread_id=thread_result.thread_id,
|
||||
finished=thread_result.finished,
|
||||
thread_status=thread_result.thread_status,
|
||||
has_active_run=thread_result.has_active_run,
|
||||
latest_run_status=thread_result.latest_run_status,
|
||||
messages=thread_result.messages,
|
||||
generated_text=thread_result.generated_text,
|
||||
generated_files=thread_result.generated_files,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/threads/{thread_id}/result",
|
||||
response_model=EmbedThreadResultResponse,
|
||||
summary="Fetch embed Q&A messages, generated text, and files by thread_id",
|
||||
)
|
||||
async def get_embed_thread_result(thread_id: str, request: Request) -> EmbedThreadResultResponse:
|
||||
"""Direct thread lookup when the parent already knows thread_id."""
|
||||
return await _result_for_thread_id(thread_id, request)
|
||||
Reference in new issue
Block a user