Merge branch 'dhk-1' into 'master'
fix(roundtable): 会商 Step2/Step3 沙箱产物按对话创建者解析,修复历史/跨用户查看不了 See merge request knowledge-base-bj/deerflow-server!140
This commit is contained in:
commit
9278fd772f
37 files changed
+2354
-90
No files matched your search
@@ -0,0 +1,333 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 会商诊断日志(仅 admin):展示多智能体会商全流程(第一步意图 / 第二步研讨 /
|
||||
* 第三步报告 + 后台挂起执行器 / 进程内网关)各处的报错与关键事件,支持按
|
||||
* scope(前台/后台)、阶段、级别、事件码、作业/任务/草稿 id、关键词筛选 + 分页。
|
||||
*
|
||||
* 数据源:`GET /api/roundtable-diagnostics`(admin-gated)。入口:多智能体会商页右上角
|
||||
* 设置按钮(admin)。见 [[roundtable-diagnostics]]。
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { Navigate, useNavigate } from "react-router-dom";
|
||||
import { ArrowLeft, RefreshCw, Search } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Pagination } from "@/components/ui/pagination";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import {
|
||||
type DiagnosticFilters,
|
||||
type DiagnosticItem,
|
||||
useRoundtableDiagnosticFacets,
|
||||
useRoundtableDiagnostics,
|
||||
} from "@/strategy-components/api/roundtable-diagnostics";
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
const ALL = "__all__";
|
||||
|
||||
// 阶段中文名(已知集合;未知阶段原样显示)。
|
||||
const STAGE_LABELS: Record<string, string> = {
|
||||
step1_intent: "第一步 · 意图",
|
||||
step2_init: "第二步 · 初始化",
|
||||
step2_leader: "第二步 · 总控",
|
||||
step2_seat: "第二步 · 席位",
|
||||
step2_recommend: "第二步 · 推荐",
|
||||
step2_orchestration: "第二步 · 编排",
|
||||
step3_summary: "第三步 · 总结报告",
|
||||
step3_report: "第三步 · 结果绘制",
|
||||
step3_dashboard: "第三步 · 大屏数据",
|
||||
step3_structure: "第三步 · 数据入库",
|
||||
upstream: "回环代理",
|
||||
job: "后台作业",
|
||||
};
|
||||
|
||||
const SCOPE_LABELS: Record<string, string> = {
|
||||
background: "后台挂起",
|
||||
foreground: "前台实时(服务端)",
|
||||
client: "前端上报",
|
||||
};
|
||||
|
||||
// 范围切换:后台挂起(background) / 前台调用(foreground 服务端观测 + client 前端上报)。
|
||||
type ScopeCategory = "background" | "frontend";
|
||||
|
||||
// 每个范围下可选的阶段(按用户要求:后台挂起只到第二/三步;前台调用含第一步)。
|
||||
const STAGE_OPTIONS: Record<ScopeCategory, string[]> = {
|
||||
background: [
|
||||
"step2_init",
|
||||
"step2_leader",
|
||||
"step2_seat",
|
||||
"step2_orchestration",
|
||||
"step3_summary",
|
||||
"step3_report",
|
||||
"step3_dashboard",
|
||||
"step3_structure",
|
||||
"job",
|
||||
],
|
||||
frontend: [
|
||||
"step1_intent",
|
||||
"step2_init",
|
||||
"step2_leader",
|
||||
"step2_seat",
|
||||
"step2_recommend",
|
||||
"step3_summary",
|
||||
"step3_report",
|
||||
"step3_dashboard",
|
||||
"step3_structure",
|
||||
],
|
||||
};
|
||||
|
||||
const LEVEL_STYLES: Record<string, string> = {
|
||||
error: "bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-300",
|
||||
warning: "bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-300",
|
||||
info: "bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300",
|
||||
};
|
||||
|
||||
function stageLabel(stage: string): string {
|
||||
return STAGE_LABELS[stage] ?? stage;
|
||||
}
|
||||
|
||||
function fmtTime(iso: string | null): string {
|
||||
if (!iso) return "";
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString("zh-CN", { hour12: false });
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function DiagnosticRow({ item }: { item: DiagnosticItem }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const hasDetail = item.detail != null && Object.keys(item.detail as object).length > 0;
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-200 dark:border-border bg-white dark:bg-card px-3 py-2 text-sm">
|
||||
<div className="flex items-start gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded px-1.5 py-0.5 text-xs font-medium uppercase",
|
||||
LEVEL_STYLES[item.level] ?? LEVEL_STYLES.info,
|
||||
)}
|
||||
>
|
||||
{item.level}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{stageLabel(item.stage)}
|
||||
</Badge>
|
||||
<code className="text-xs text-slate-500 dark:text-muted-foreground">{item.event}</code>
|
||||
{item.agentName ? (
|
||||
<span className="text-xs text-slate-500 dark:text-muted-foreground">· {item.agentName}</span>
|
||||
) : null}
|
||||
{item.userName ? (
|
||||
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-600 dark:bg-slate-800 dark:text-slate-300">
|
||||
👤 {item.userName}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="ml-auto text-xs text-slate-400 dark:text-muted-foreground">{fmtTime(item.createdAt)}</span>
|
||||
</div>
|
||||
<p className="mt-1 whitespace-pre-wrap break-words text-slate-700 dark:text-foreground">
|
||||
{item.message || <span className="text-slate-400">(无说明)</span>}
|
||||
</p>
|
||||
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-0.5 text-xs text-slate-400 dark:text-muted-foreground">
|
||||
<span>{SCOPE_LABELS[item.scope] ?? item.scope}</span>
|
||||
{item.jobId ? <span>job: {item.jobId.slice(0, 8)}</span> : null}
|
||||
{item.taskId ? <span>task: {item.taskId}</span> : null}
|
||||
{item.draftId ? <span>draft: {item.draftId.slice(0, 8)}</span> : null}
|
||||
{item.cycle != null ? <span>cycle: {item.cycle}</span> : null}
|
||||
{hasDetail ? (
|
||||
<button type="button" className="text-blue-500 hover:underline" onClick={() => setOpen((v) => !v)}>
|
||||
{open ? "收起详情" : "展开详情"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{open && hasDetail ? (
|
||||
<pre className="mt-2 max-h-64 overflow-auto rounded bg-slate-50 dark:bg-muted p-2 text-xs text-slate-600 dark:text-muted-foreground">
|
||||
{JSON.stringify(item.detail, null, 2)}
|
||||
</pre>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RoundtableDiagnosticsPage() {
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [scopeCategory, setScopeCategory] = useState<ScopeCategory>("background");
|
||||
const [stage, setStage] = useState<string>(ALL);
|
||||
const [level, setLevel] = useState<string>(ALL);
|
||||
const [event, setEvent] = useState<string>(ALL);
|
||||
const [qInput, setQInput] = useState("");
|
||||
const [q, setQ] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
// 「前台调用」= 服务端观测(foreground) + 前端上报(client) 两种 scope 合并查(后端按逗号分隔多值)。
|
||||
const scopeParam = scopeCategory === "background" ? "background" : "foreground,client";
|
||||
|
||||
const filters: DiagnosticFilters = useMemo(
|
||||
() => ({
|
||||
scope: scopeParam,
|
||||
stage: stage === ALL ? undefined : stage,
|
||||
level: level === ALL ? undefined : level,
|
||||
event: event === ALL ? undefined : event,
|
||||
q: q || undefined,
|
||||
limit: PAGE_SIZE,
|
||||
offset: (page - 1) * PAGE_SIZE,
|
||||
}),
|
||||
[scopeParam, stage, level, event, q, page],
|
||||
);
|
||||
|
||||
const { data, isLoading, isFetching, refetch } = useRoundtableDiagnostics(filters, isAdmin);
|
||||
const { data: facets } = useRoundtableDiagnosticFacets(isAdmin);
|
||||
|
||||
if (!isAdmin) {
|
||||
return <Navigate to="/page/workspace/chats/new" replace />;
|
||||
}
|
||||
|
||||
const items = data?.items ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
|
||||
const resetPageAnd = (fn: () => void) => {
|
||||
fn();
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
// 切换范围时清空阶段筛选(两种范围的阶段选项不同)+ 回到第一页。
|
||||
const switchScope = (next: ScopeCategory) => {
|
||||
setScopeCategory(next);
|
||||
setStage(ALL);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const applySearch = () => resetPageAnd(() => setQ(qInput.trim()));
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-slate-50 dark:bg-background">
|
||||
{/* Header */}
|
||||
<header className="flex items-center gap-3 border-b border-slate-200 dark:border-border bg-white dark:bg-card px-4 py-3">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate(-1)} className="gap-1">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-base font-semibold text-slate-800 dark:text-foreground">会商诊断日志</h1>
|
||||
<p className="text-xs text-slate-400 dark:text-muted-foreground">
|
||||
多智能体会商全流程报错与关键事件(含后台挂起卡死、超时、产物缺失等)
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => refetch()}
|
||||
className="ml-auto gap-1"
|
||||
disabled={isFetching}
|
||||
>
|
||||
<RefreshCw className={cn("h-4 w-4", isFetching && "animate-spin")} />
|
||||
刷新
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-slate-200 dark:border-border bg-white dark:bg-card px-4 py-2">
|
||||
{/* 范围切换:后台挂起 / 前台调用 */}
|
||||
<div className="inline-flex rounded-lg border border-slate-200 dark:border-border p-0.5">
|
||||
{(["background", "frontend"] as ScopeCategory[]).map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => switchScope(c)}
|
||||
className={cn(
|
||||
"rounded-md px-3 py-1 text-xs font-medium transition-colors",
|
||||
scopeCategory === c
|
||||
? "bg-slate-800 text-white dark:bg-primary dark:text-primary-foreground"
|
||||
: "text-slate-500 hover:text-slate-700 dark:text-muted-foreground dark:hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{c === "background" ? "后台挂起" : "前台调用"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<FilterSelect label="阶段" value={stage} onChange={(v) => resetPageAnd(() => setStage(v))}
|
||||
options={STAGE_OPTIONS[scopeCategory]} labels={STAGE_LABELS} />
|
||||
<FilterSelect label="级别" value={level} onChange={(v) => resetPageAnd(() => setLevel(v))}
|
||||
options={facets?.level ?? []} />
|
||||
<FilterSelect label="事件" value={event} onChange={(v) => resetPageAnd(() => setEvent(v))}
|
||||
options={facets?.event ?? []} />
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
value={qInput}
|
||||
onChange={(e) => setQInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && applySearch()}
|
||||
placeholder="搜索报错说明…"
|
||||
className="h-8 w-48"
|
||||
/>
|
||||
<Button variant="secondary" size="sm" onClick={applySearch} className="gap-1">
|
||||
<Search className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<span className="ml-auto text-xs text-slate-400 dark:text-muted-foreground">共 {total} 条</span>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="flex-1 overflow-auto px-4 py-3">
|
||||
{isLoading ? (
|
||||
<p className="py-10 text-center text-sm text-slate-400">加载中…</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="py-10 text-center text-sm text-slate-400">暂无诊断记录</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{items.map((it) => (
|
||||
<DiagnosticRow key={it.id} item={it} />
|
||||
))}
|
||||
<Pagination page={page} pageSize={PAGE_SIZE} total={total} onPageChange={setPage} className="pt-2" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterSelect({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
labels,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
options: string[];
|
||||
labels?: Record<string, string>;
|
||||
}) {
|
||||
return (
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger className="h-8 w-auto min-w-[120px] text-xs">
|
||||
<SelectValue placeholder={label} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL}>{label}:全部</SelectItem>
|
||||
{options.map((o) => (
|
||||
<SelectItem key={o} value={o}>
|
||||
{labels?.[o] ?? o}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
@@ -41,6 +41,7 @@ import LightAppManagementPage from "./LightAppManagementPage";
|
||||
import MenuManagementPage from "./MenuManagementPage";
|
||||
import SensitiveWordsPage from "./SensitiveWordsPage";
|
||||
import TaskButtonsPage from "./TaskButtonsPage";
|
||||
import RoundtableDiagnosticsPage from "./RoundtableDiagnosticsPage";
|
||||
import LightAppNewNotePage from "./LightAppNewNotePage";
|
||||
import LightAppNoteListPage from "./LightAppNoteListPage";
|
||||
import LightAppNoteQAPage from "./LightAppNoteQAPage";
|
||||
@@ -393,6 +394,16 @@ export default function StrategyRoutes() {
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/roundtable-diagnostics"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<RoundtableDiagnosticsPage />
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/scheduled-tasks"
|
||||
element={
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* 会商「前端调用报错」上报器。
|
||||
*
|
||||
* 服务端诊断只能记到「处理函数跑起来后抛的错」;而用户实际遇到的很多报错(nginx/网关
|
||||
* 返回 502、网络中断、fetch 直接失败、SSE 里收到的 error 帧)发生在**客户端**侧。这里把
|
||||
* 这些前端可观测到的报错 best-effort 上报到 `POST /api/roundtable-diagnostics/report`
|
||||
* (scope="client",非 admin 接口),便于管理员在诊断页统一筛选用户使用时的报错。
|
||||
*
|
||||
* 设计:fire-and-forget + `keepalive`(页面跳转也能发出去)+ 绝不抛错(上报失败就算了,
|
||||
* 不能让「报错上报」本身再影响会商流程)。
|
||||
*/
|
||||
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
export interface RoundtableErrorReport {
|
||||
stage: string;
|
||||
event: string;
|
||||
message: string;
|
||||
level?: "error" | "warning" | "info";
|
||||
detail?: unknown;
|
||||
agentId?: string;
|
||||
agentName?: string;
|
||||
cycle?: number;
|
||||
draftId?: string;
|
||||
taskId?: string;
|
||||
jobId?: string;
|
||||
httpStatus?: number;
|
||||
}
|
||||
|
||||
// 页面级上下文(draftId / taskId),由会商页设置,使每条上报都能归属到草稿/任务,
|
||||
// 而无需把它们穿进每个底层 api 调用。
|
||||
let _ctx: { draftId?: string; taskId?: string } = {};
|
||||
|
||||
export function setRoundtableErrorContext(ctx: { draftId?: string; taskId?: string }): void {
|
||||
_ctx = { ..._ctx, ...ctx };
|
||||
}
|
||||
|
||||
/** 据 agentType + agentName 推断诊断 stage(与后端 `_stage_for_agent` 对齐)。 */
|
||||
export function stageForRoundtableAgent(
|
||||
agentType: string | undefined,
|
||||
agentName: string | null | undefined,
|
||||
): string {
|
||||
switch (agentName) {
|
||||
case "roundtable-report":
|
||||
return "step3_report";
|
||||
case "roundtable-summary":
|
||||
return "step3_summary";
|
||||
case "roundtable-dashboard":
|
||||
return "step3_dashboard";
|
||||
case "roundtable-structure":
|
||||
return "step3_structure";
|
||||
default:
|
||||
return agentType === "leader" ? "step2_leader" : "step2_seat";
|
||||
}
|
||||
}
|
||||
|
||||
export function reportRoundtableError(input: RoundtableErrorReport): void {
|
||||
try {
|
||||
const detail =
|
||||
input.httpStatus != null && typeof input.detail !== "object"
|
||||
? { httpStatus: input.httpStatus, raw: input.detail }
|
||||
: input.httpStatus != null
|
||||
? { httpStatus: input.httpStatus, ...(input.detail as Record<string, unknown>) }
|
||||
: input.detail;
|
||||
const body = JSON.stringify({
|
||||
stage: input.stage,
|
||||
event: input.event,
|
||||
level: input.level ?? "error",
|
||||
message: (input.message ?? "").slice(0, 2000),
|
||||
detail,
|
||||
jobId: input.jobId,
|
||||
draftId: input.draftId ?? _ctx.draftId,
|
||||
taskId: input.taskId ?? _ctx.taskId,
|
||||
agentId: input.agentId,
|
||||
agentName: input.agentName,
|
||||
cycle: input.cycle,
|
||||
});
|
||||
void apiFetch(`${getBackendBaseURL()}/api/roundtable-diagnostics/report`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body,
|
||||
keepalive: true,
|
||||
}).catch(() => {
|
||||
/* 上报失败静默 */
|
||||
});
|
||||
} catch {
|
||||
/* 绝不抛错 */
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
import type { ClarificationPayload } from "./multi-agent";
|
||||
import { reportRoundtableError } from "./diagnostics-report";
|
||||
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
|
||||
|
||||
export type { ClarificationPayload };
|
||||
@@ -340,6 +341,11 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
|
||||
|
||||
if (!res.ok || !res.body) {
|
||||
const text = await res.text().catch(() => "");
|
||||
reportRoundtableError({
|
||||
stage: "step1_intent", event: "intent_stream_http_error",
|
||||
message: `第一步意图生成失败 (HTTP ${res.status}): ${text.slice(0, 500)}`,
|
||||
httpStatus: res.status,
|
||||
});
|
||||
throw new Error(`intent/stream failed (${res.status}): ${text}`);
|
||||
}
|
||||
|
||||
@@ -425,6 +431,11 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
|
||||
}
|
||||
if (parsed.status === "error") {
|
||||
result.errorDetail = parseIntentErrorDetail(parsed.error_detail);
|
||||
reportRoundtableError({
|
||||
stage: "step1_intent", event: "intent_error_frame",
|
||||
message: `第一步意图生成报错:${(result.content || "").slice(0, 800)}`,
|
||||
detail: result.errorDetail,
|
||||
});
|
||||
}
|
||||
if (parsed.status === "clarification" && result.clarifications.length === 0) {
|
||||
// Fallback path: no live `type:"tool"` clarification frames were seen
|
||||
|
||||
@@ -2,6 +2,7 @@ import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
|
||||
import { reportRoundtableError, stageForRoundtableAgent } from "@/roundtable-planning/api/diagnostics-report";
|
||||
|
||||
export type ThreadIdMap = Record<string, string>;
|
||||
|
||||
@@ -74,6 +75,12 @@ export async function initMultiAgent(
|
||||
tlog(`fetch.headers status=${res.status}`);
|
||||
if (!res.ok || !res.body) {
|
||||
const text = await res.text().catch(() => "");
|
||||
reportRoundtableError({
|
||||
stage: "step2_init", event: "init_http_error",
|
||||
message: `第二步会商初始化失败 (HTTP ${res.status}): ${text.slice(0, 500)}`,
|
||||
httpStatus: res.status,
|
||||
detail: { agentNames: payload.agentNames, mainAgentName: payload.mainAgentName },
|
||||
});
|
||||
throw new Error(`init multi-agent failed (${res.status}): ${text}`);
|
||||
}
|
||||
|
||||
@@ -150,18 +157,34 @@ export async function initMultiAgent(
|
||||
}
|
||||
} catch (err) {
|
||||
aborted = (err as Error)?.name === "AbortError" || payload.signal?.aborted === true;
|
||||
if (!aborted) throw err;
|
||||
if (!aborted) {
|
||||
reportRoundtableError({
|
||||
stage: "step2_init", event: "init_stream_exception",
|
||||
message: `第二步会商初始化中断(网络不可达 / 连接中断 / 超时):${(err as Error)?.message ?? String(err)}`,
|
||||
detail: { seatCount },
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
tlog(`total ${(performance.now() - t0).toFixed(0)}ms (seats=${seatCount})`);
|
||||
|
||||
if (errorDetail) {
|
||||
reportRoundtableError({
|
||||
stage: "step2_init", event: "init_error_frame",
|
||||
message: `第二步会商初始化报错:${errorDetail.slice(0, 800)}`,
|
||||
});
|
||||
throw new Error(`init multi-agent failed: ${errorDetail}`);
|
||||
}
|
||||
if (aborted) {
|
||||
throw new DOMException("init aborted", "AbortError");
|
||||
}
|
||||
if (!threadIds || !mainAgentName) {
|
||||
reportRoundtableError({
|
||||
stage: "step2_init", event: "init_incomplete",
|
||||
message: "第二步会商初始化流提前结束(未收到 init_done,可能连接被中断)",
|
||||
detail: { seatCount, hasThreadIds: !!threadIds, mainAgentName },
|
||||
});
|
||||
throw new Error("init multi-agent stream ended without init_done");
|
||||
}
|
||||
return {
|
||||
@@ -442,6 +465,11 @@ function extractAiTextDelta(parsed: unknown): { text: string; id: string } | nul
|
||||
export async function streamMultiAgent(
|
||||
req: StreamMultiAgentRequest,
|
||||
): Promise<StreamMultiAgentResult> {
|
||||
// 整段包一层兜底:除已在具体分支上报的(HTTP 错误 / error 帧 / worker error 事件)外,
|
||||
// 再兜住 fetch 直接 reject(网络不可达 / 连接被拒)与流式中途中断(连接重置 / 超时),
|
||||
// 做到「能收集到的报错越全越好」。`reported` 防止与具体分支重复上报。
|
||||
let reported = false;
|
||||
try {
|
||||
const res = await apiFetch(`${getBackendBaseURL()}/api/multi-agent/run/stream`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -474,6 +502,17 @@ export async function streamMultiAgent(
|
||||
|
||||
if (!res.ok || !res.body) {
|
||||
const text = await res.text().catch(() => "");
|
||||
// 用户实际遇到的报错(含进第三步常见的 502):HTTP 层就失败、SSE 都没建起来。
|
||||
reportRoundtableError({
|
||||
stage: stageForRoundtableAgent(req.agentType, req.agentName),
|
||||
event: "run_stream_http_error",
|
||||
message: `「${req.displayName || req.agentName}」调用失败 (HTTP ${res.status}): ${text.slice(0, 500)}`,
|
||||
httpStatus: res.status,
|
||||
agentId: req.agentName,
|
||||
agentName: req.displayName || req.agentName,
|
||||
detail: { agentType: req.agentType },
|
||||
});
|
||||
reported = true;
|
||||
throw new Error(`run/stream failed (${res.status}): ${text}`);
|
||||
}
|
||||
|
||||
@@ -611,6 +650,21 @@ export async function streamMultiAgent(
|
||||
frameSeq += 1;
|
||||
if (debugEnabled) console.debug(debugLabel, `frame#${frameSeq} event=${lastEvent}`, parsed);
|
||||
|
||||
// worker 把 run 内异常(常见:第二步席位/Step3 模型调用失败)发成 `event: error`
|
||||
// SSE 帧 `{message,name}`——它既不是 HTTP 错误也不是 `{status:"error"}` 终帧,
|
||||
// 单独在这里捕获上报,否则这类「模型没调用成功」的报错会漏收集。
|
||||
if (lastEvent === "error") {
|
||||
const obj = parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : { raw: parsed };
|
||||
reportRoundtableError({
|
||||
stage: stageForRoundtableAgent(req.agentType, req.agentName),
|
||||
event: "run_worker_error_event",
|
||||
message: `「${req.displayName || req.agentName}」运行报错:${String(obj.message ?? obj.name ?? "").slice(0, 800)}`,
|
||||
agentId: req.agentName,
|
||||
agentName: req.displayName || req.agentName,
|
||||
detail: { ...obj, agentType: req.agentType, framesBeforeError: frameSeq },
|
||||
});
|
||||
}
|
||||
|
||||
// Phase inference from LangGraph messages-tuple frames. We only flip
|
||||
// to tool_calling/tool_result on chunks that clearly indicate them;
|
||||
// everything else with non-empty AI text means we're streaming.
|
||||
@@ -711,6 +765,18 @@ export async function streamMultiAgent(
|
||||
if (typeof parsed.agent_name === "string") {
|
||||
result.agentName = parsed.agent_name;
|
||||
}
|
||||
// SSE error 帧(后端 stream_generator 在席位/Step3 run 失败时下发
|
||||
// `{status:"error", content, agent_name}`):上报,便于收集用户侧报错。
|
||||
if (result.message === "error") {
|
||||
reportRoundtableError({
|
||||
stage: stageForRoundtableAgent(req.agentType, result.agentName || req.agentName),
|
||||
event: "run_stream_error_frame",
|
||||
message: `「${req.displayName || req.agentName}」运行报错:${(result.content || "").slice(0, 800)}`,
|
||||
agentId: result.agentName || req.agentName,
|
||||
agentName: req.displayName || req.agentName,
|
||||
detail: { agentType: req.agentType, framesBeforeError: frameSeq },
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -728,6 +794,19 @@ export async function streamMultiAgent(
|
||||
console.debug(debugLabel, `stream ended after ${frameSeq} frames; final content length=${result.content.length}`);
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
if (!reported && req.signal?.aborted !== true && (err as Error)?.name !== "AbortError") {
|
||||
reportRoundtableError({
|
||||
stage: stageForRoundtableAgent(req.agentType, req.agentName),
|
||||
event: "run_stream_exception",
|
||||
message: `「${req.displayName || req.agentName}」调用异常(网络不可达 / 连接中断 / 超时):${(err as Error)?.message ?? String(err)}`,
|
||||
agentId: req.agentName,
|
||||
agentName: req.displayName || req.agentName,
|
||||
detail: { agentType: req.agentType },
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export interface SessionArtifactFile {
|
||||
@@ -829,6 +908,11 @@ export async function initReportThread(
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
reportRoundtableError({
|
||||
stage: "step3_report", event: "init_thread_http_error",
|
||||
message: `结果绘制线程创建失败 (HTTP ${res.status}): ${text.slice(0, 500)}`,
|
||||
httpStatus: res.status, agentId: "roundtable-report", agentName: "结果绘制",
|
||||
});
|
||||
throw new Error(`init report thread failed (${res.status}): ${text}`);
|
||||
}
|
||||
const json = (await res.json()) as { agent_name: string; thread_id: string };
|
||||
@@ -855,6 +939,11 @@ export async function initDashboardThread(
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
reportRoundtableError({
|
||||
stage: "step3_dashboard", event: "init_thread_http_error",
|
||||
message: `大屏页面线程创建失败 (HTTP ${res.status}): ${text.slice(0, 500)}`,
|
||||
httpStatus: res.status, agentId: "roundtable-dashboard", agentName: "大屏页面",
|
||||
});
|
||||
throw new Error(`init dashboard thread failed (${res.status}): ${text}`);
|
||||
}
|
||||
const json = (await res.json()) as { agent_name: string; thread_id: string };
|
||||
@@ -880,6 +969,11 @@ export async function initSummaryThread(
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
reportRoundtableError({
|
||||
stage: "step3_summary", event: "init_thread_http_error",
|
||||
message: `方案报告线程创建失败 (HTTP ${res.status}): ${text.slice(0, 500)}`,
|
||||
httpStatus: res.status, agentId: "roundtable-summary", agentName: "方案报告",
|
||||
});
|
||||
throw new Error(`init summary thread failed (${res.status}): ${text}`);
|
||||
}
|
||||
const json = (await res.json()) as { agent_name: string; thread_id: string };
|
||||
@@ -906,6 +1000,11 @@ export async function initStructureThread(
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
reportRoundtableError({
|
||||
stage: "step3_structure", event: "init_thread_http_error",
|
||||
message: `结构化输出线程创建失败 (HTTP ${res.status}): ${text.slice(0, 500)}`,
|
||||
httpStatus: res.status, agentId: "roundtable-structure", agentName: "结构化输出",
|
||||
});
|
||||
throw new Error(`init structure thread failed (${res.status}): ${text}`);
|
||||
}
|
||||
const json = (await res.json()) as { agent_name: string; thread_id: string };
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getBackendBaseURL } from "@/core/config";
|
||||
import type { Agent } from "@/core/agents";
|
||||
|
||||
import type { IntentSummary } from "./intent";
|
||||
import { reportRoundtableError } from "./diagnostics-report";
|
||||
|
||||
/**
|
||||
* Agent-recommendation API. Sits between Step 1 (intent clarification) and
|
||||
@@ -318,6 +319,11 @@ export async function streamRecommend(req: StreamRecommendRequest): Promise<Stre
|
||||
|
||||
if (!res.ok || !res.body) {
|
||||
const text = await res.text().catch(() => "");
|
||||
reportRoundtableError({
|
||||
stage: "step2_recommend", event: "recommend_stream_http_error",
|
||||
message: `智能体推荐失败 (HTTP ${res.status}): ${text.slice(0, 500)}`,
|
||||
httpStatus: res.status,
|
||||
});
|
||||
throw new Error(`recommend/stream failed (${res.status}): ${text}`);
|
||||
}
|
||||
|
||||
@@ -373,6 +379,12 @@ export async function streamRecommend(req: StreamRecommendRequest): Promise<Stre
|
||||
if (Array.isArray(parsed.picks)) {
|
||||
result.picks = parsed.picks as RecommendPick[];
|
||||
}
|
||||
if (parsed.status === "error") {
|
||||
reportRoundtableError({
|
||||
stage: "step2_recommend", event: "recommend_error_frame",
|
||||
message: `智能体推荐报错:${(result.content || "").slice(0, 800)}`,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
import { getAvatarTypeFor } from "../lib/roundtable-constants";
|
||||
import { reportRoundtableError } from "./diagnostics-report";
|
||||
import { seatModeToRunParams } from "../lib/seat-mode";
|
||||
import {
|
||||
isTerminalJobStatus,
|
||||
@@ -29,6 +30,11 @@ const BASE = () => `${getBackendBaseURL()}/api/roundtable-jobs`;
|
||||
async function readJson<T>(res: Response, ctx: string): Promise<T> {
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
reportRoundtableError({
|
||||
stage: "job", event: "job_api_http_error",
|
||||
message: `后台作业接口「${ctx}」失败 (HTTP ${res.status}): ${text.slice(0, 500)}`,
|
||||
httpStatus: res.status,
|
||||
});
|
||||
throw new Error(`${ctx} failed (${res.status}): ${text}`);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
@@ -221,7 +227,13 @@ export function streamJob(jobId: string, handlers: StreamJobHandlers, taskId?: s
|
||||
continue;
|
||||
}
|
||||
if (frame.event === "error") {
|
||||
handlers.onError?.(String(frame.detail ?? "unknown stream error"));
|
||||
const detail = String(frame.detail ?? "unknown stream error");
|
||||
reportRoundtableError({
|
||||
stage: "job", event: "job_stream_error_frame",
|
||||
message: `后台作业进度流报错:${detail.slice(0, 500)}`,
|
||||
jobId, detail: { taskId },
|
||||
});
|
||||
handlers.onError?.(detail);
|
||||
continue;
|
||||
}
|
||||
const job = mapJob(frame);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import type { ReactNode } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
@@ -214,7 +215,9 @@ export const ArtifactPreviewModal: React.FC<ArtifactPreviewModalProps> = ({
|
||||
[content],
|
||||
);
|
||||
|
||||
return (
|
||||
// 经 portal 挂到 body:脱离会商页 <main>(`relative z-0`) 的层叠上下文,
|
||||
// 否则 fixed 弹窗会被会商页头(z-40)/全局顶栏(z-45)盖住顶部。
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-slate-900/40 backdrop-blur-sm p-6"
|
||||
onMouseDown={(e) => {
|
||||
@@ -287,6 +290,7 @@ export const ArtifactPreviewModal: React.FC<ArtifactPreviewModalProps> = ({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
};
|
||||
@@ -147,18 +147,21 @@ export function BusinessChainPicker({
|
||||
const chainMeta = { id: selectedChain.id, title: selectedChain.title };
|
||||
const seatNameById = new Map(selectedChain.seats.map((s) => [s.agent_id, s.name]));
|
||||
const nameOf = (id: string) => nameById.get(id) ?? seatNameById.get(id) ?? id;
|
||||
// 席位模型覆盖(链条配置):按 agent_id 取,空/缺省 = 跟随全局模型。
|
||||
const modelById = new Map(selectedChain.seats.map((s) => [s.agent_id, s.model ?? null]));
|
||||
const modelOf = (id: string) => modelById.get(id) ?? null;
|
||||
|
||||
// 链条带分层、过滤存活后仍并行 → dag 编排(携带快速/沙箱子模式);否则退回线性 chain。
|
||||
if (dagPlan) {
|
||||
// dag:席位顺序按 stage 展平(与 plan 一致)。
|
||||
const agents = dagPlan.stages
|
||||
.flatMap((st) => st.agentIds)
|
||||
.map((id) => ({ agent_id: id, name: nameOf(id) }));
|
||||
.map((id) => ({ agent_id: id, name: nameOf(id), model: modelOf(id) }));
|
||||
onConfirm({ agents, mode: "dag", chain: chainMeta, plan: dagPlan, dagExecMode });
|
||||
return;
|
||||
}
|
||||
|
||||
const agents = survivingSeats.map((s) => ({ agent_id: s.agent_id, name: nameOf(s.agent_id) }));
|
||||
const agents = survivingSeats.map((s) => ({ agent_id: s.agent_id, name: nameOf(s.agent_id), model: modelOf(s.agent_id) }));
|
||||
onConfirm({ agents, mode: "chain", chain: chainMeta });
|
||||
};
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
* 互不影响(report 画 HTML 看板,summary 写 Markdown 报告 + 问答)。
|
||||
*/
|
||||
import { useEffect, useMemo, useRef, useState, type FormEvent } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import {
|
||||
@@ -1005,7 +1006,10 @@ function ContentModal({
|
||||
onClose: () => void;
|
||||
headerAction?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
// 经 portal 挂到 body:页面 <main> 是 `relative z-0`(浅蓝主题层叠基线),会把
|
||||
// 内部 fixed 弹窗困在 z-0 层叠上下文里,被会商页头(z-40)/全局顶栏(z-45)盖住顶部。
|
||||
// 挂到 body 后脱离该上下文,z-50 才真正盖在顶栏之上,弹窗标题栏正常显示。
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-[1px] p-4"
|
||||
onClick={onClose}
|
||||
@@ -1029,7 +1033,8 @@ function ContentModal({
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-5">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,13 @@ export interface ChainSeat {
|
||||
name: string;
|
||||
/** 描述快照,同样作为实时数据的回退。 */
|
||||
description?: string;
|
||||
/**
|
||||
* 可选「席位模型覆盖」:该席位作为子智能体执行时用的 LLM model name(须是后端
|
||||
* `config.yaml` `models[]` 里的一个 `name`)。空 / 缺省 = 不覆盖,运行时回退到全局
|
||||
* 所选模型。选入研讨时灌进 `agentConfigs[speakerId].model`(见 `RoundtablePlanningPage`
|
||||
* 的 `handleConfirmAgents`)→ 走现有 per-seat 模型链路。
|
||||
*/
|
||||
model?: string | null;
|
||||
}
|
||||
|
||||
/** 一条可复用的业务链条(与后端 `/api/roundtable-chains` 记录对应)。 */
|
||||
@@ -70,8 +77,11 @@ export interface BusinessChain {
|
||||
* `agents` + 一个 `mode`。
|
||||
*/
|
||||
export interface SeatSelectionResult {
|
||||
/** 有序席位 —— 与现有 `handleConfirmAgents` 入参兼容。dag 模式下按 stage 展平排序。 */
|
||||
agents: Array<{ agent_id: string; name: string }>;
|
||||
/**
|
||||
* 有序席位 —— 与现有 `handleConfirmAgents` 入参兼容。dag 模式下按 stage 展平排序。
|
||||
* `model` 为该席位在来源链条里配置的模型覆盖(空 / 缺省 = 跟随全局模型)。
|
||||
*/
|
||||
agents: Array<{ agent_id: string; name: string; model?: string | null }>;
|
||||
/**
|
||||
* 决定 Step 2 走哪种编排:
|
||||
* - `recommend` 总控自由派活;
|
||||
@@ -122,6 +132,9 @@ export function buildChainSeatSelection(
|
||||
const nameById = new Map(candidates.map((c) => [c.agent_id, c.name]));
|
||||
const seatNameById = new Map(chain.seats.map((s) => [s.agent_id, s.name]));
|
||||
const nameOf = (id: string) => nameById.get(id) ?? seatNameById.get(id) ?? id;
|
||||
// 席位模型覆盖(链条配置):按 agent_id 取,空/缺省 = 跟随全局。
|
||||
const modelById = new Map(chain.seats.map((s) => [s.agent_id, s.model ?? null]));
|
||||
const modelOf = (id: string) => modelById.get(id) ?? null;
|
||||
|
||||
const survivingSeats = chain.seats.filter((s) => existingIds.has(s.agent_id));
|
||||
if (survivingSeats.length < CHAIN_MIN_SEATS) return null;
|
||||
@@ -143,12 +156,12 @@ export function buildChainSeatSelection(
|
||||
if (hasParallel || hasGoal || hasCoord) {
|
||||
const agents = plan.stages
|
||||
.flatMap((st) => st.agentIds)
|
||||
.map((id) => ({ agent_id: id, name: nameOf(id) }));
|
||||
.map((id) => ({ agent_id: id, name: nameOf(id), model: modelOf(id) }));
|
||||
return { agents, mode: "dag", chain: chainMeta, plan, dagExecMode: "fast", seatMode: chain.seatMode };
|
||||
}
|
||||
}
|
||||
|
||||
const agents = survivingSeats.map((s) => ({ agent_id: s.agent_id, name: nameOf(s.agent_id) }));
|
||||
const agents = survivingSeats.map((s) => ({ agent_id: s.agent_id, name: nameOf(s.agent_id), model: modelOf(s.agent_id) }));
|
||||
return { agents, mode: "chain", chain: chainMeta, seatMode: chain.seatMode };
|
||||
}
|
||||
|
||||
|
||||
@@ -112,6 +112,20 @@ export function setStageGoal(groups: StageGroups, stageIdx: number, goal: string
|
||||
return groups.map((g, i) => (i === stageIdx ? { ...g, goal } : g));
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置某个席位的「模型覆盖」(不可变,供编辑器席位下拉 onChange)。
|
||||
* `model` 为 null/"" 表示清除覆盖(跟随全局模型)。按 agent_id 定位,跨 stage 适用。
|
||||
*/
|
||||
export function setSeatModel(groups: StageGroups, agentId: string, model: string | null): StageGroups {
|
||||
return groups.map((g) => {
|
||||
if (!g.seats.some((s) => s.agent_id === agentId)) return g;
|
||||
return {
|
||||
...g,
|
||||
seats: g.seats.map((s) => (s.agent_id === agentId ? { ...s, model: model || null } : s)),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ── 变换(均返回新结构,不可变)──────────────────────────────────────────────
|
||||
|
||||
/** 末尾追加一个**新单席位 stage**(线性追加,pool 点击加入默认行为)。 */
|
||||
|
||||
@@ -65,6 +65,7 @@ import { CSS } from "@dnd-kit/utilities";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
|
||||
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
|
||||
import { ModelNameSelect } from "@/components/workspace/model-name-select";
|
||||
import { listAgents } from "@/core/agents";
|
||||
|
||||
import {
|
||||
@@ -103,6 +104,7 @@ import {
|
||||
moveStage,
|
||||
removeSeat as removeSeatFromGroups,
|
||||
seatExistsInGroups,
|
||||
setSeatModel,
|
||||
setStageGoal,
|
||||
splitSeatToNewStage,
|
||||
stageGroupsToSeats,
|
||||
@@ -359,6 +361,11 @@ export default function BusinessChainEditorPage() {
|
||||
setStageGroups((prev) => setStageGoal(prev, stageIdx, goal));
|
||||
}, []);
|
||||
|
||||
// 席位模型覆盖:编辑器席位下拉 onChange(null = 清除,跟随全局)。
|
||||
const setSeatModelCb = useCallback((agentId: string, model: string | null) => {
|
||||
setStageGroups((prev) => setSeatModel(prev, agentId, model));
|
||||
}, []);
|
||||
|
||||
// ── 提示词 AI 优化(总控提示词 / 各 Stage 阶段目标)────────────────────────
|
||||
const [optimizingCoordinator, setOptimizingCoordinator] = useState(false);
|
||||
const [goalOptimizingIdx, setGoalOptimizingIdx] = useState<number | null>(null);
|
||||
@@ -555,6 +562,8 @@ export default function BusinessChainEditorPage() {
|
||||
agent_id: s.agent_id,
|
||||
name: live?.name ?? s.name,
|
||||
description: live?.description ?? s.description ?? "",
|
||||
// 席位模型覆盖:随席位一起落库(空 → null = 跟随全局)。
|
||||
model: s.model ?? null,
|
||||
};
|
||||
});
|
||||
// 总控编排提示:trim 后空串落 null(清空),与后端「空=不注入」一致。
|
||||
@@ -1121,6 +1130,7 @@ export default function BusinessChainEditorPage() {
|
||||
onRemoveSeat={removeSeat}
|
||||
onMergeSeat={mergeSeatToStage}
|
||||
onSplitSeat={splitSeat}
|
||||
onSeatModelChange={setSeatModelCb}
|
||||
onStageGoalChange={setGoal}
|
||||
goalOptimizingIdx={goalOptimizingIdx}
|
||||
onOptimizeGoal={handleOptimizeStageGoal}
|
||||
@@ -1298,6 +1308,7 @@ function StagesColumn({
|
||||
onRemoveSeat,
|
||||
onMergeSeat,
|
||||
onSplitSeat,
|
||||
onSeatModelChange,
|
||||
onStageGoalChange,
|
||||
goalOptimizingIdx,
|
||||
onOptimizeGoal,
|
||||
@@ -1313,6 +1324,7 @@ function StagesColumn({
|
||||
onRemoveSeat: (agentId: string) => void;
|
||||
onMergeSeat: (agentId: string, targetStageIdx: number) => void;
|
||||
onSplitSeat: (agentId: string) => void;
|
||||
onSeatModelChange: (agentId: string, model: string | null) => void;
|
||||
onStageGoalChange: (stageIdx: number, goal: string) => void;
|
||||
/** 正在 AI 优化阶段目标的 stage 下标(null = 没有进行中的优化)。 */
|
||||
goalOptimizingIdx: number | null;
|
||||
@@ -1410,6 +1422,7 @@ function StagesColumn({
|
||||
onRemoveSeat={onRemoveSeat}
|
||||
onMergeSeat={onMergeSeat}
|
||||
onSplitSeat={onSplitSeat}
|
||||
onSeatModelChange={onSeatModelChange}
|
||||
onGoalChange={(goal) => onStageGoalChange(stageIdx, goal)}
|
||||
goalOptimizing={goalOptimizingIdx === stageIdx}
|
||||
anyGoalOptimizing={goalOptimizingIdx !== null}
|
||||
@@ -1438,6 +1451,7 @@ function StageSection({
|
||||
onRemoveSeat,
|
||||
onMergeSeat,
|
||||
onSplitSeat,
|
||||
onSeatModelChange,
|
||||
onGoalChange,
|
||||
goalOptimizing,
|
||||
anyGoalOptimizing,
|
||||
@@ -1455,6 +1469,7 @@ function StageSection({
|
||||
onRemoveSeat: (agentId: string) => void;
|
||||
onMergeSeat: (agentId: string, targetStageIdx: number) => void;
|
||||
onSplitSeat: (agentId: string) => void;
|
||||
onSeatModelChange: (agentId: string, model: string | null) => void;
|
||||
onGoalChange: (goal: string) => void;
|
||||
/** 本 Stage 的阶段目标正在 AI 优化中。 */
|
||||
goalOptimizing: boolean;
|
||||
@@ -1615,6 +1630,7 @@ function StageSection({
|
||||
onMergeUp={() => onMergeSeat(seat.agent_id, stageIdx - 1)}
|
||||
onMergeDown={() => onMergeSeat(seat.agent_id, stageIdx + 1)}
|
||||
onSplit={() => onSplitSeat(seat.agent_id)}
|
||||
onModelChange={(model) => onSeatModelChange(seat.agent_id, model)}
|
||||
onDetail={() => onSeatDetail(seat.agent_id, seat.name)}
|
||||
/>
|
||||
))}
|
||||
@@ -1637,6 +1653,7 @@ function SeatStrip({
|
||||
onMergeUp,
|
||||
onMergeDown,
|
||||
onSplit,
|
||||
onModelChange,
|
||||
onDetail,
|
||||
}: {
|
||||
seat: ChainSeat;
|
||||
@@ -1649,6 +1666,8 @@ function SeatStrip({
|
||||
onMergeUp: () => void;
|
||||
onMergeDown: () => void;
|
||||
onSplit: () => void;
|
||||
/** 席位模型覆盖变更(null = 清除,跟随全局)。 */
|
||||
onModelChange: (model: string | null) => void;
|
||||
onDetail: () => void;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
|
||||
@@ -1699,6 +1718,27 @@ function SeatStrip({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 席位模型覆盖(右侧操作区):空 = 跟随全局所选模型;选了则该席位作为子智能体
|
||||
执行时用此模型。技能重的席位配强模型、边角席位配快模型,配合技能白名单实现
|
||||
「强弱搭配」。放到右侧操作区,与名称/描述解耦,卡片更整洁。 */}
|
||||
<div className="flex shrink-0 items-center border-l border-border px-2.5">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 border border-border/70 bg-muted/40 py-1 pl-2 pr-1",
|
||||
R_CTRL,
|
||||
)}
|
||||
title="该席位作为子智能体执行时使用的模型;「跟随全局」= 用第二步顶部所选模型"
|
||||
>
|
||||
<span className="shrink-0 select-none text-[10px] font-medium text-muted-foreground">模型</span>
|
||||
<ModelNameSelect
|
||||
value={seat.model ?? null}
|
||||
onChange={onModelChange}
|
||||
defaultLabel="跟随全局"
|
||||
triggerClassName="h-6 max-w-[150px] border-none bg-transparent text-[11px] shadow-none hover:bg-background/60"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 控制:查看详情 / 并入上一阶段 / 独立成阶段 / 并入下一阶段 / 移除 */}
|
||||
<div className="flex shrink-0 items-center gap-0.5 border-l border-border px-2">
|
||||
<button
|
||||
|
||||
@@ -24,6 +24,8 @@
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { setRoundtableErrorContext } from "@/roundtable-planning/api/diagnostics-report";
|
||||
import {
|
||||
Activity,
|
||||
BookOpen,
|
||||
@@ -195,6 +197,11 @@ function readWujiePrefill(): RoundtablePrefill | null {
|
||||
*/
|
||||
const ROUNDTABLE_DEEPLINK_PREFILL_KEY = "roundtable:deeplink-prefill";
|
||||
|
||||
// 进第三步三流「错峰起跑」的基准间隔(毫秒)。结果绘制延迟 1×、大屏延迟 2×,把三个
|
||||
// start_run 的写尖峰摊开,避免单 worker + 单 sqlite 锁瞬时被打满导致回环 502(削峰,
|
||||
// 不影响并行展示进度)。取值偏小,用户基本无感。
|
||||
const STEP3_STAGGER_MS = 300;
|
||||
|
||||
/** 按路由 path 持久化深链 prefill,供刷新后恢复。隐私模式/quota 异常静默失败。 */
|
||||
function persistDeeplinkPrefill(path: string, prefill: RoundtablePrefill): void {
|
||||
try {
|
||||
@@ -912,6 +919,19 @@ export default function RoundtablePlanningPage() {
|
||||
dashboardFlow.abort();
|
||||
step2.resetStep2State();
|
||||
step2.setSelectedAgents(normalized);
|
||||
// per-seat 模型覆盖:业务链条为某些席位指定了模型 → 写进 agentConfigs。speakerId =
|
||||
// 席位序号 + 2(与 selectedAgents 索引对齐,1 号是总控),与派活时
|
||||
// `agentConfigs[meta.speakerId]?.model` 的读取一致。空 / 未指定 → model:""(跟随全局),
|
||||
// 同时清掉上一轮选择残留的席位模型覆盖;prompt/temp/weight 沿用既有 Persona 配置。
|
||||
step2.setAgentConfigs((prev) => {
|
||||
const next = { ...prev };
|
||||
result.agents.forEach((a, i) => {
|
||||
const speakerId = i + 2;
|
||||
const base = next[speakerId] ?? { model: "", weight: 85, temp: 0.3, prompt: "" };
|
||||
next[speakerId] = { ...base, model: a.model ?? "" };
|
||||
});
|
||||
return next;
|
||||
});
|
||||
step2.resetInitGate();
|
||||
// 这是从第一步确认席位、开启的一轮**实时**研讨(非查看历史/草稿) → 清掉
|
||||
// “查看历史”闸门,否则之前看过历史记录留下的 true 会一直把「后台挂起」按钮藏掉。
|
||||
@@ -1112,6 +1132,14 @@ export default function RoundtablePlanningPage() {
|
||||
return emitWujieEvent("roundtable:consensus", buildWujieConsensusPayload());
|
||||
}, [buildWujieConsensusPayload, inWujieEmbed]);
|
||||
|
||||
// 让前端报错上报器能把每条报错归属到当前草稿 / 任务(无需穿进每个底层 api 调用)。
|
||||
useEffect(() => {
|
||||
setRoundtableErrorContext({
|
||||
draftId: drafts.currentDraftId ?? undefined,
|
||||
taskId: prefillRef.current?.taskId ?? undefined,
|
||||
});
|
||||
}, [drafts.currentDraftId]);
|
||||
|
||||
const handleSettingsClick = useCallback(() => {
|
||||
if (inWujieEmbed) {
|
||||
const deliverables = buildWujieConsensusPayload().deliverables;
|
||||
@@ -1124,8 +1152,13 @@ export default function RoundtablePlanningPage() {
|
||||
}
|
||||
return;
|
||||
}
|
||||
// 管理员:打开「会商诊断日志」页(全流程报错排查)。非管理员维持原占位提示。
|
||||
if (getStoredAuth()?.system_role === "admin") {
|
||||
navigate("/page/strategy/admin/roundtable-diagnostics");
|
||||
return;
|
||||
}
|
||||
triggerToast("⚙️ 系统管理面板拉取中(暂不开放外部审计)");
|
||||
}, [buildWujieConsensusPayload, emitConsensusToParent, inWujieEmbed, triggerToast]);
|
||||
}, [buildWujieConsensusPayload, emitConsensusToParent, inWujieEmbed, navigate, triggerToast]);
|
||||
|
||||
// C4(无界回传):第二步**本次 live 研讨** newly 达成共识时,把总控结论 + 各智能体交付广播给父页面。
|
||||
// 草稿/任务回显(抽屉重开)已在 hydrate 时 markRestoredConsensusAsEmitted,不再重复 emit。
|
||||
@@ -1251,6 +1284,19 @@ export default function RoundtablePlanningPage() {
|
||||
// 已有存稿则恢复展示。只在「本次会话首次进入第三步」时编排一次 —— 之后进出第三步
|
||||
// 全是续看(流在页面层不中断);切换草稿 / 新建任务时复位入口闸门。
|
||||
const step3EnteredRef = useRef(false);
|
||||
// 进第三步「错峰起跑」定时器。三流虽互无依赖、要并行展示进度,但若**同一刻**一起
|
||||
// 调 start()(各自 init thread + run/stream),会把单 worker 网关 + 单连接 sqlite
|
||||
// checkpoint 锁的瞬时压力顶满,回环 /runs/stream 概率性返回非 200 → 前端冒 502。
|
||||
// 这里把后两个流的**起跑**各往后挪一点点(仅错开 start_run 的写尖峰,不是串行等前一个
|
||||
// 跑完),削峰即可。定时器存 ref,切草稿 / 重新 hydrate / 新建任务时统一清掉,避免迟发的
|
||||
// start() 打到已被复位/中断的上下文。
|
||||
const step3StartTimersRef = useRef<number[]>([]);
|
||||
const clearStep3StartTimers = useCallback(() => {
|
||||
for (const t of step3StartTimersRef.current) {
|
||||
if (typeof window !== "undefined") window.clearTimeout(t);
|
||||
}
|
||||
step3StartTimersRef.current = [];
|
||||
}, []);
|
||||
// step3Report 用 ref 持最新值,供 hydrate 同步读取(避免闭包取到旧草稿快照)。
|
||||
const step3ReportRef = useRef(step3Report);
|
||||
step3ReportRef.current = step3Report;
|
||||
@@ -1272,8 +1318,24 @@ export default function RoundtablePlanningPage() {
|
||||
// 保证总结/经典/大屏的展示数据跟随当前草稿实时切换。
|
||||
const hydrateStep3Flows = useCallback(() => {
|
||||
step3EnteredRef.current = true;
|
||||
// 重新编排前先清掉上一轮可能还没触发的错峰定时器(切草稿重 hydrate 时)。
|
||||
clearStep3StartTimers();
|
||||
const rep = step3ReportRef.current;
|
||||
// ① 方案总结报告
|
||||
// 错峰起跑助手:把一个尚未起跑的流延迟 delayMs 后再 start();触发前再校验
|
||||
// step3EnteredRef(被新建任务 / 切草稿复位为 false 则跳过),避免迟发的 start()
|
||||
// 打到已被中断的上下文。delay=0 表示立即(同步)起跑。
|
||||
const staggerStart = (start: () => void, delayMs: number) => {
|
||||
if (delayMs <= 0 || typeof window === "undefined") {
|
||||
start();
|
||||
return;
|
||||
}
|
||||
const id = window.setTimeout(() => {
|
||||
if (!step3EnteredRef.current) return;
|
||||
start();
|
||||
}, delayMs);
|
||||
step3StartTimersRef.current.push(id);
|
||||
};
|
||||
// ① 方案总结报告(主页,立即起跑)
|
||||
const savedSum = rep?.summaryReport;
|
||||
if (savedSum && (savedSum.md.trim() || (savedSum.closing ?? "").trim())) {
|
||||
summaryFlow.hydrateSaved(savedSum);
|
||||
@@ -1281,14 +1343,14 @@ export default function RoundtablePlanningPage() {
|
||||
summaryFlow.start();
|
||||
}
|
||||
// ② 结果绘制(经典模式一)。旧版草稿可能把大屏 HTML 存在 html 槽位(带 marker),
|
||||
// 那不是经典产物 → 经典照常启动。
|
||||
// 那不是经典产物 → 经典照常启动。错峰 ~300ms,削掉与总结的同刻 start 尖峰。
|
||||
if (rep?.html && !isDashboardHtml(rep.html)) {
|
||||
reportFlow.hydrateSaved(rep);
|
||||
} else {
|
||||
reportFlow.start(); // 与总结报告并行绘制,进入「结果绘制」即见进行中过程/结果
|
||||
staggerStart(() => reportFlow.start(), STEP3_STAGGER_MS); // 仍并行绘制,只是起跑错开
|
||||
}
|
||||
// ③ 大屏多页(模式二):优先 dashboardJson(按当前主题渲染);旧版 marker html 兜底。
|
||||
// 带上大屏生成过程的完整对话(文字 + 步骤条),恢复时整段回显。
|
||||
// 带上大屏生成过程的完整对话(文字 + 步骤条),恢复时整段回显。错峰 ~600ms。
|
||||
const dashSaved = {
|
||||
dialogues: rep?.dashboardDialogues,
|
||||
seatStubMessages: rep?.dashboardSeatStubMessages,
|
||||
@@ -1298,10 +1360,10 @@ export default function RoundtablePlanningPage() {
|
||||
} else if (rep?.html && isDashboardHtml(rep.html)) {
|
||||
dashboardFlow.hydrateSaved(rep.html, dashSaved);
|
||||
} else {
|
||||
dashboardFlow.start();
|
||||
staggerStart(() => dashboardFlow.start(), STEP3_STAGGER_MS * 2);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [summaryFlow, reportFlow, dashboardFlow]);
|
||||
}, [summaryFlow, reportFlow, dashboardFlow, clearStep3StartTimers]);
|
||||
|
||||
// 切换草稿:先停掉旧草稿的在途 Step3 流(防串台)。声明在 hydrate effect **之前**,
|
||||
// 保证同一提交内先 abort 旧流、再由下方 effect hydrate 新草稿,顺序不颠倒。
|
||||
@@ -1312,6 +1374,7 @@ export default function RoundtablePlanningPage() {
|
||||
const prev = abortPrevDraftIdRef.current;
|
||||
abortPrevDraftIdRef.current = drafts.currentDraftId;
|
||||
if (prev && drafts.currentDraftId && prev !== drafts.currentDraftId) {
|
||||
clearStep3StartTimers(); // 切草稿:取消尚未触发的错峰 start,防打到新草稿上下文
|
||||
summaryFlow.abort();
|
||||
reportFlow.abort();
|
||||
dashboardFlow.abort();
|
||||
@@ -1373,6 +1436,7 @@ export default function RoundtablePlanningPage() {
|
||||
setStep3Report(null);
|
||||
// 停掉页面层的 Step3 三个生成流(新任务不该带着旧任务的流跑)+ 复位进入闸门,
|
||||
// 并停止后台作业实时跟随(作业本身继续在后台跑)。
|
||||
clearStep3StartTimers(); // 取消尚未触发的错峰 start
|
||||
summaryFlow.abort();
|
||||
reportFlow.abort();
|
||||
dashboardFlow.abort();
|
||||
@@ -1819,7 +1883,13 @@ export default function RoundtablePlanningPage() {
|
||||
<button
|
||||
id="settings-btn"
|
||||
onClick={handleSettingsClick}
|
||||
title={inWujieEmbed ? "回传各智能体交付结果给宿主" : "系统设置"}
|
||||
title={
|
||||
inWujieEmbed
|
||||
? "回传各智能体交付结果给宿主"
|
||||
: getStoredAuth()?.system_role === "admin"
|
||||
? "会商诊断日志"
|
||||
: "系统设置"
|
||||
}
|
||||
className="p-1.5 text-slate-400 hover:text-slate-600 rounded-lg hover:bg-slate-100 transition-colors dark:text-muted-foreground dark:hover:bg-accent dark:hover:text-foreground"
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* 圆桌会商诊断日志(管理员)API 客户端 + React Query hooks。
|
||||
*
|
||||
* 后端:`/api/roundtable-diagnostics`(见 app/gateway/routers/roundtable_diagnostics.py)。
|
||||
* 全部 admin-gated。snake_case ⇄ camelCase 在此边界收敛。
|
||||
*/
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
const BASE = () => `${getBackendBaseURL()}/api/roundtable-diagnostics`;
|
||||
|
||||
export interface DiagnosticItem {
|
||||
id: string;
|
||||
scope: string;
|
||||
stage: string;
|
||||
level: string;
|
||||
event: string;
|
||||
message: string | null;
|
||||
detail: unknown;
|
||||
jobId: string | null;
|
||||
draftId: string | null;
|
||||
taskId: string | null;
|
||||
userId: string | null;
|
||||
agentId: string | null;
|
||||
agentName: string | null;
|
||||
cycle: number | null;
|
||||
createdAt: string | null;
|
||||
userName: string | null;
|
||||
}
|
||||
|
||||
export interface DiagnosticListResponse {
|
||||
items: DiagnosticItem[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export interface DiagnosticFacets {
|
||||
scope: string[];
|
||||
stage: string[];
|
||||
level: string[];
|
||||
event: string[];
|
||||
}
|
||||
|
||||
export interface DiagnosticFilters {
|
||||
scope?: string;
|
||||
stage?: string;
|
||||
level?: string;
|
||||
event?: string;
|
||||
jobId?: string;
|
||||
draftId?: string;
|
||||
taskId?: string;
|
||||
userId?: string;
|
||||
q?: string;
|
||||
since?: string;
|
||||
until?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
async function readJson<T>(res: Response, ctx: string): Promise<T> {
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null as unknown);
|
||||
const detail =
|
||||
data && typeof data === "object" && "detail" in data
|
||||
? String((data as { detail: unknown }).detail)
|
||||
: `${ctx} failed (${res.status})`;
|
||||
throw new Error(detail);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
function buildQuery(f: DiagnosticFilters): string {
|
||||
const p = new URLSearchParams();
|
||||
const map: Record<string, string | number | undefined> = {
|
||||
scope: f.scope,
|
||||
stage: f.stage,
|
||||
level: f.level,
|
||||
event: f.event,
|
||||
job_id: f.jobId,
|
||||
draft_id: f.draftId,
|
||||
task_id: f.taskId,
|
||||
user_id: f.userId,
|
||||
q: f.q,
|
||||
since: f.since,
|
||||
until: f.until,
|
||||
limit: f.limit,
|
||||
offset: f.offset,
|
||||
};
|
||||
for (const [k, v] of Object.entries(map)) {
|
||||
if (v !== undefined && v !== null && String(v).length > 0) p.set(k, String(v));
|
||||
}
|
||||
const qs = p.toString();
|
||||
return qs ? `?${qs}` : "";
|
||||
}
|
||||
|
||||
export async function listDiagnostics(f: DiagnosticFilters): Promise<DiagnosticListResponse> {
|
||||
const res = await apiFetch(`${BASE()}${buildQuery(f)}`, { method: "GET" });
|
||||
return readJson<DiagnosticListResponse>(res, "list diagnostics");
|
||||
}
|
||||
|
||||
export async function getDiagnosticFacets(): Promise<DiagnosticFacets> {
|
||||
const res = await apiFetch(`${BASE()}/facets`, { method: "GET" });
|
||||
return readJson<DiagnosticFacets>(res, "diagnostic facets");
|
||||
}
|
||||
|
||||
// ── React Query hooks ──────────────────────────────────────────────────────
|
||||
|
||||
const KEY = "roundtable-diagnostics";
|
||||
|
||||
export function useRoundtableDiagnostics(filters: DiagnosticFilters, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: [KEY, filters],
|
||||
queryFn: () => listDiagnostics(filters),
|
||||
enabled,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useRoundtableDiagnosticFacets(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: [KEY, "facets"],
|
||||
queryFn: getDiagnosticFacets,
|
||||
enabled,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
@@ -247,6 +247,7 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA
|
||||
| **Public Shares** (`/api/public/shares`) | **Unauthenticated** (the `/api/public/` prefix is whitelisted in `auth_middleware._PUBLIC_PATH_PREFIXES`). `GET /{code}` - read-only conversation snapshot (title + serialized messages + artifacts) for a `view`-mode share; messages are read straight from the latest checkpoint. `GET /{code}/files/{path}` - serves a file referenced by that conversation (images, Markdown, text, PDF, ?) so the shared page's links are openable; path resolution is confined to the owner's thread directory. Active content (`text/html`, `application/xhtml+xml`, `image/svg+xml`) is forced to download (`_public_share_disposition`); everything else is served inline. Missing/revoked/non-`view` codes all return 404. |
|
||||
| **Roundtable Drafts** (`/api/roundtable-drafts`) | **Strictly per-user** persistence for the roundtable-planning page (replaces the old browser `localStorage`). `GET /` - list drafts (lightweight meta); `POST /` - create; `GET/PUT/DELETE /{id}` - full read / partial update / delete (all owner-scoped). A draft = one full session (Step 1 intent + Step 2 roundtable), step snapshots stored as JSON in `PortableLongText`. Recommendation history bound to a draft: `GET /{id}/recommendations` - list newest-first; `POST /{id}/recommendations` - append one run (objective/status/rationale/picks/candidates). taskId 深链会商记录 are **not** here — they live in the separate `/api/roundtable-task-drafts` store (see below) so personal history never mixes with task records. Backed by `deerflow.persistence.roundtable_drafts` (tables `roundtable_drafts`, `roundtable_recommend_history`); store wired in `deps.py` as `app.state.roundtable_draft_store`. Tests: `tests/test_roundtable_task_drafts.py`. |
|
||||
| **Roundtable Task Drafts** (`/api/roundtable-task-drafts`) | **Task-scoped, unpartitioned** store for taskId-deeplink roundtable 聊天记录 (无界嵌入抽屉). Keyed **only by `task_id`** (one task → many records), **never partitioned by user** — any authenticated caller may read/write/delete a task's records (`created_by` is audit-only). Physically separate from `roundtable_drafts` so the two never mix. `GET /by-task/{task_id}` - all drafts for a task, newest-first (history dropdown), enriched with the latest background-job status per draft via `RoundtableJobRepository.status_map_by_drafts`; `GET /by-task/{task_id}/latest` - newest draft (回显最新一条); `POST /` - create (`task_id` required); `GET/PUT/DELETE /{id}` - full read / partial update / delete (all user-agnostic). Backed by `deerflow.persistence.roundtable_task_drafts` (table `roundtable_task_drafts`, auto-created by `create_all`; migration `20260618_01`); store wired in `deps.py` as `app.state.roundtable_task_draft_store`. Existing `task_id`-bearing `roundtable_drafts` rows are migrated over by `scripts/migrate_roundtable_task_drafts.py`. Tests: `tests/test_roundtable_task_drafts.py`, `tests/test_roundtable_drafts_by_task.py`. |
|
||||
| **Roundtable Diagnostics** (`/api/roundtable-diagnostics`) | 会商全流程诊断日志 (报错 + 关键事件). One row per event in `roundtable_diagnostics`: `{scope (background = 后台挂起 / foreground = 前台服务端观测 / client = 前端上报), stage (step1_intent/step2_init/step2_leader/step2_seat/step2_recommend/step2_orchestration/step3_summary/step3_report/step3_dashboard/step3_structure/upstream/job), level (info/warning/error), event, message, detail (JSON), job_id/draft_id/task_id/user_id/agent_id/agent_name/cycle, created_at}`. **Reads are admin-only**: `GET /` - filter (scope/stage/level/event/job_id/draft_id/task_id/user_id/q/since/until; `scope` accepts a comma-separated set so 前端「前台调用」= `foreground,client`) + paginate (newest-first), each item enriched with `userName` (user_id → 邮箱 `@`-prefix via `get_local_provider().get_user`, best-effort batched cache); `GET /facets` - distinct values for filter dropdowns; `POST /admin/cleanup` - retention purge. **`POST /report` is NOT admin-gated** — any authenticated user's frontend posts the errors *the user actually hit* (HTTP 502 before any SSE frame / network drop / SSE error frame) so they're collected too; stored as `scope="client"`. Backed by `deerflow.persistence.roundtable_diagnostics` (table `roundtable_diagnostics`, auto-created by `create_all`; migration `20260619_01`); store wired in `deps.py` as `app.state.roundtable_diagnostic_store` + a process-level default store (`register_default_store`) so the harness-side engine/gateway can write across the app/harness boundary. **Writers** (all best-effort — diagnostics never break a run): background path uses `DiagnosticsRecorder` (bound to job ctx) threaded through `RoundtableJobExecutor` → `JobProgressWriter.diag(...)` (engine transitions/step3 failures/empty-delivery) + `InProcessRoundtableGateway` (per-turn timeout/report·summary·dashboard truncation+missing); foreground path uses `app/gateway/roundtable_diag.py::record_foreground_diag` in `multi_agent.py`/`intent.py`/`recommend.py` error frames. **Bug-fix shipped alongside**: `InProcessRoundtableGateway._run_agent_turn` guards each agent run with an **inactivity (idle) timeout** (`_await_run_with_idle_guard`, `ROUNDTABLE_SEAT_IDLE_TIMEOUT_SECONDS` default 1200s = 20min, tuned high for unstable intranet models) — **not** a total-duration cap, so a slow-but-alive run that keeps streaming tokens runs as long as it needs; only a run that emits **zero StreamBridge events for `idle_timeout` seconds straight** (a true freeze: 内网模型连接池挂死 / sqlite 锁在挂载卷上挂死) is cancelled + raises (顶层把 job 标 error,而非永久冻结) + records a `seat_turn_timeout` diagnostic. Activity is observed via `app.state.stream_bridge` (the worker publishes every chunk there). Optional absolute cap `ROUNDTABLE_SEAT_MAX_TOTAL_SECONDS` (default 0 = off). Frontend: admin page `pages/RoundtableDiagnosticsPage.tsx` (route `/page/strategy/admin/roundtable-diagnostics`), opened from the **会商页右上角设置按钮** (admin only — `handleSettingsClick` in `roundtable-planning/pages/RoundtablePlanningPage.tsx`); admin read client `strategy-components/api/roundtable-diagnostics.ts`. **Client error reporter** `roundtable-planning/api/diagnostics-report.ts` (`reportRoundtableError` fire-and-forget + `keepalive`, `setRoundtableErrorContext` for draft/task attribution set by the page, `stageForRoundtableAgent`) is called at every client-observable failure: `multi-agent.ts` (`streamMultiAgent` — covers Step2 leader/seat AND Step3 report/summary/dashboard/structure — reports HTTP error + SSE `{status:"error"}` frame + worker `event:error` frame [模型调用没成功] + fetch-reject [网络不可达] + mid-stream drop [连接重置/超时], deduped via a `reported` flag; `initMultiAgent` HTTP/error-frame/incomplete/mid-stream), `intent.ts` (Step1 HTTP+error-frame), `recommend.ts` (Step2 recommend HTTP+error-frame), `roundtable-jobs.ts` (job api/stream). The admin page's first filter is a **后台挂起 / 前台调用** toggle (frontend = `foreground,client`); the stage dropdown options adapt to it (background → step2+step3+job; frontend → step1+step2+step3) and each row shows the reporting user's name + 👤. Tests: `tests/test_roundtable_diagnostics.py`, `tests/test_roundtable_inprocess_timeout.py`. **Seat skill-recognition (弱模型补偿)**: a seat = one custom agent run whose configured skills (`agent config.yaml → skills`) are injected **only** via the system prompt's `<skill_system>` block (model must `read_file` the SKILL.md itself) — weak models (deepseek-chat 等) ignore it and never use their skills. `app/gateway/roundtable_seat_skills.py::append_seat_skill_directive(agent_id, task)` appends an explicit Chinese directive (each configured-and-enabled skill's name + description + SKILL.md container path + "先 read_file 再按其流程取信息/执行") to the **seat's per-turn tLine truncated
|
||||
| **Roundtable Draft Shares** (`/api/roundtable-draft-shares`) | Per-draft sharing for the roundtable-planning page, mirroring Thread Shares but the shared resource is a `roundtable_drafts` row. Two modes (`roundtable_draft_shares.mode`). **`import`**: `POST /` `{draft_id, mode}` - create (or reuse) a revocable code; `GET /{code}/preview` + `POST /{code}/import` - copy that draft (step1/2/3) into the caller's account under a deterministic per-(code,recipient) id (`imp_<sha1>` ? re-import is a no-op). **`view`**: a public, read-only result page ? see Public Roundtable Shares. Both: `GET /` - list my codes; `DELETE /{code}` - revoke. Backed by `deerflow.persistence.roundtable_draft_shares` (table `roundtable_draft_shares`, migration `20260606_01`); store wired in `deps.py` as `app.state.roundtable_draft_share_store`. Tests: `tests/test_roundtable_draft_shares.py`. |
|
||||
| **Public Roundtable Shares** (`/api/public/roundtable-shares`) | **Unauthenticated** (`/api/public/` whitelisted). `GET /{code}` - read-only snapshot for a `view`-mode draft share: returns the Step 3??????report HTML (`step3.html`, a self-contained document) + title/summary/`has_report`. The frontend renders it in a sandboxed `iframe` (no `allow-scripts`) at `#/roundtable-share/<code>`. Missing/revoked/non-`view` codes all return 404. |
|
||||
| **Scheduled Tasks** (`/api/scheduled-tasks`) | Per-user cron-like agent tasks. `GET/POST /` - list/create owned tasks; `PATCH/DELETE /{id}` - update/delete (owner); `POST /{id}/{pause,resume,trigger}`; `GET /{id}/runs`, `GET /runs/{rid}`, run-file preview/edit/download; `GET /runs/{rid}/messages` - agent messages for the run's underlying agent run (used to inspect a stuck/running run; authorized via `_load_run`); `GET /runs/{rid}/html` - generated HTML document for an `html_page` run; `DELETE /runs/{rid}` - owner-scoped, with an admin fallback so admins can delete any user's run; publish/subscribe under `/{id}/{publish,unpublish,subscribe}` and `GET /published`. **Admin (cross-user):** `GET /admin/all` - every task; `PATCH/DELETE /admin/{id}` - edit/delete any task; `POST /admin/{id}/trigger`; `GET /admin/{id}/runs`. Admin routes require `system_role == "admin"`. |
|
||||
@@ -459,7 +460,7 @@ Operators upgrading an existing DB run the whole file (`USE <db>;` then execute)
|
||||
- Physical: `backend/.deer-flow/users/{user_id}/threads/{thread_id}/user-data/...`, `deer-flow/skills/`
|
||||
- Translation: `replace_virtual_path()` / `replace_virtual_paths_in_command()`
|
||||
- Detection: `is_local_sandbox()` checks `sandbox_id == "local"`
|
||||
- **`{user_id}` for path resolution = `resolve_path_user_id(thread_id)`, NOT the current login.** Task-deeplink conversations (rwfx/3qfx/xdfx, `threads_meta.metadata.taskId`) are shared across all users (see the `taskId` escape branches in `thread_meta/sql.py`), but generated files/uploads live under a *per-user* dir. Resolving by current login meant account B opening a shared task conversation 404'd on the md/artifacts account A generated ("切换账号后生成的文件找不到"). `deerflow/runtime/thread_paths.py` (`resolve_path_user_id` sync / `aresolve_path_user_id` async) resolves a task-shared thread to its **creator** (`threads_meta.user_id`), so every account reads/writes the same physical directory; personal threads resolve to the current login unchanged (no behavior change). Wired at every thread-path seam: write source `ThreadDataMiddleware.before_agent` (drives all sandbox writes via `thread_data` paths), `present_file_tool`, `uploads/manager.get_uploads_dir` + `uploads_middleware` + uploads router, artifact serving (`path_utils.{a,}resolve_thread_virtual_path` → artifacts/skills routers), embedded `client.get_artifact`. Positive (taskId→creator) results are cached process-wide (immutable mapping); any failure / memory-mode store / missing row falls back to the current login. **Caveat**: files a *non-creator* wrote into a shared thread before this fix stay orphaned under that writer's dir (no data loss); all new writes consolidate under the creator. Tests: `tests/test_thread_paths_owner.py`.
|
||||
- **`{user_id}` for path resolution = `resolve_path_user_id(thread_id)`, NOT the current login.** Task-deeplink conversations (rwfx/3qfx/xdfx, `threads_meta.metadata.taskId`) are shared across all users (see the `taskId` escape branches in `thread_meta/sql.py`), but generated files/uploads live under a *per-user* dir. Resolving by current login meant account B opening a shared task conversation 404'd on the md/artifacts account A generated ("切换账号后生成的文件找不到"). `deerflow/runtime/thread_paths.py` (`resolve_path_user_id` sync / `aresolve_path_user_id` async) resolves a task-shared thread to its **creator** (`threads_meta.user_id`), so every account reads/writes the same physical directory; personal threads resolve to the current login unchanged (no behavior change). Wired at every thread-path seam: write source `ThreadDataMiddleware.before_agent` (drives all sandbox writes via `thread_data` paths), `present_file_tool`, `uploads/manager.get_uploads_dir` + `uploads_middleware` + uploads router, artifact serving (`path_utils.{a,}resolve_thread_virtual_path` → artifacts/skills routers), embedded `client.get_artifact`. Positive (taskId→creator) results are cached process-wide (immutable mapping); any failure / memory-mode store / missing row falls back to the current login. **Caveat**: files a *non-creator* wrote into a shared thread before this fix stay orphaned under that writer's dir (no data loss); all new writes consolidate under the creator. **Extended to 圆桌会商 system threads** (`metadata.thread_type == "roundtable"` — Step 2 seats/coordinator + Step 3 report/summary/dashboard/structure, created by `multi_agent.py` with `_ROUNDTABLE_THREAD_METADATA`): their Step2/Step3 sandbox artifacts (md/流程图/报告) must stay viewable when a历史草稿 is reopened or a taskId会商 draft is opened by another account, so `thread_paths._is_creator_shared_thread(metadata)` treats both `taskId` **and** `thread_type=="roundtable"` as creator-shared, and `ThreadMetaRepository.check_access` (the artifacts router's `owner_check`) bypasses the owner filter for both — roundtable thread ids are only surfaced inside the owner's draft, so this does not let unrelated accounts *discover* others' 会商 files. Tests: `tests/test_thread_paths_owner.py`, `tests/test_thread_paths_roundtable.py`.
|
||||
|
||||
**Sandbox Tools** (in `packages/harness/deerflow/sandbox/tools.py`):
|
||||
- `bash` - Execute commands with path translation and error handling
|
||||
|
||||
@@ -61,6 +61,7 @@ from app.gateway.routers import (
|
||||
flow_extract,
|
||||
recommended_questions,
|
||||
roundtable_chains,
|
||||
roundtable_diagnostics,
|
||||
roundtable_draft_shares,
|
||||
roundtable_drafts,
|
||||
roundtable_jobs,
|
||||
@@ -1370,6 +1371,7 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
||||
|
||||
# Roundtable-planning background jobs at /api/roundtable-jobs
|
||||
app.include_router(roundtable_jobs.router)
|
||||
app.include_router(roundtable_diagnostics.router)
|
||||
|
||||
# Suggestions API is mounted at /api/threads/{thread_id}/suggestions
|
||||
app.include_router(suggestions.router)
|
||||
|
||||
@@ -295,6 +295,18 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
# (Phase 2 后台编排图写进度用)。
|
||||
_register_roundtable_job_default_store(app.state.roundtable_job_store)
|
||||
|
||||
# 圆桌会商「诊断日志」store:会商全流程各处报错 / 关键事件落库,供管理员诊断页筛选。
|
||||
# 同样登记进程级默认仓储——harness 侧编排引擎 / 进程内网关跨边界写诊断用。
|
||||
from deerflow.persistence.roundtable_diagnostics import (
|
||||
make_roundtable_diagnostic_store,
|
||||
)
|
||||
from deerflow.persistence.roundtable_diagnostics import (
|
||||
register_default_store as _register_roundtable_diagnostic_default_store,
|
||||
)
|
||||
|
||||
app.state.roundtable_diagnostic_store = make_roundtable_diagnostic_store(sf)
|
||||
_register_roundtable_diagnostic_default_store(app.state.roundtable_diagnostic_store)
|
||||
|
||||
# 圆桌后台作业执行器:把编排引擎作为后台任务跑。
|
||||
# 网关由环境变量 ``ROUNDTABLE_JOB_GATEWAY`` 选择(见 resolve_roundtable_gateway_mode):
|
||||
# - 缺省 / "inprocess"(Phase 3b):进程内驱动真模型(leader/seat/report)——**默认**,
|
||||
@@ -327,6 +339,10 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
seat_thinking_enabled=p.seat_thinking_enabled,
|
||||
seat_reasoning_effort=p.seat_reasoning_effort,
|
||||
seat_subagent_enabled=p.seat_subagent_enabled,
|
||||
# 诊断日志上下文:网关每轮 leader/seat/report/summary/dashboard 报错均带这些归属。
|
||||
job_id=p.job_id,
|
||||
draft_id=p.draft_id,
|
||||
task_id=p.task_id,
|
||||
)
|
||||
logger.info("[roundtable-jobs] gateway = inprocess (real models, default)")
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""前台(实时会商)诊断日志助手。
|
||||
|
||||
后台挂起路径用 ``DiagnosticsRecorder``(绑定作业上下文);前台 multi_agent 路由用本助手
|
||||
从 ``request`` 取 store + 当前用户,best-effort 落一条 ``scope="foreground"`` 的诊断事件。
|
||||
任何异常静默吞掉——诊断日志绝不能反过来打断会商。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
from deerflow.persistence.roundtable_diagnostics import record_diagnostic
|
||||
from deerflow.runtime.user_context import get_effective_user_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _resolve_user_id(request: Request) -> str | None:
|
||||
try:
|
||||
user = getattr(request.state, "user", None)
|
||||
if user is not None:
|
||||
return str(user.id)
|
||||
return get_effective_user_id()
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
|
||||
|
||||
async def record_foreground_diag(
|
||||
request: Request,
|
||||
*,
|
||||
stage: str,
|
||||
level: str,
|
||||
event: str,
|
||||
message: str | None = None,
|
||||
detail: Any = None,
|
||||
thread_id: str | None = None,
|
||||
draft_id: str | None = None,
|
||||
task_id: str | None = None,
|
||||
agent_id: str | None = None,
|
||||
agent_name: str | None = None,
|
||||
) -> None:
|
||||
"""从 request 落一条前台诊断事件(best-effort)。"""
|
||||
try:
|
||||
store = getattr(request.app.state, "roundtable_diagnostic_store", None)
|
||||
await record_diagnostic(
|
||||
store,
|
||||
scope="foreground",
|
||||
stage=stage,
|
||||
level=level,
|
||||
event=event,
|
||||
message=message,
|
||||
detail=detail,
|
||||
draft_id=draft_id,
|
||||
task_id=task_id,
|
||||
user_id=_resolve_user_id(request),
|
||||
agent_id=agent_id,
|
||||
agent_name=agent_name,
|
||||
)
|
||||
except Exception: # noqa: BLE001 — 诊断日志绝不打断会商
|
||||
logger.debug("foreground roundtable diag failed", exc_info=True)
|
||||
@@ -25,6 +25,7 @@ from typing import Any
|
||||
from langgraph.checkpoint.base import empty_checkpoint
|
||||
|
||||
from app.gateway.roundtable_run_policy import roundtable_run_policy
|
||||
from app.gateway.roundtable_seat_skills import append_seat_skill_directive
|
||||
from app.gateway.routers._roundtable_seed import (
|
||||
COORDINATOR_AGENT_ID,
|
||||
DASHBOARD_AGENT_ID,
|
||||
@@ -41,10 +42,31 @@ from deerflow.agents.roundtable_orchestrator.types import (
|
||||
SummaryResult,
|
||||
)
|
||||
from deerflow.config.paths import get_paths
|
||||
from deerflow.persistence.roundtable_diagnostics import DiagnosticsRecorder, get_default_store
|
||||
from deerflow.runtime.serialization import serialize_channel_values
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 单轮 agent run(leader/seat/report/summary/dashboard)的**无活动(idle)超时**(秒)。
|
||||
#
|
||||
# 背景:后台编排是**串行 await 每一个 run**,且历史上**没有任何超时保护**——只要某一轮
|
||||
# ``agent.astream`` 卡死(内网模型端点连接被打满 / sqlite 锁在挂载卷上挂死等环境相关原因),
|
||||
# 整个后台作业就永久冻结(用户侧表现为「执行完第一个席位后一直不动」)。
|
||||
#
|
||||
# ⚠️ 关键:这里**不是总时长超时**,而是 **idle 超时**——只要这轮 run 还在持续产出
|
||||
# (流式 token / 工具调用 / 进度事件,全部经 StreamBridge 外发),就一直等下去,哪怕这轮
|
||||
# 真的要跑 20 分钟、半小时也绝不打断(慢但活着的生成不该被切)。只有当 run **连续
|
||||
# ``_SEAT_IDLE_TIMEOUT_S`` 秒完全没有任何输出**(真卡死)时才取消它、抛错 → 顶层把 job 标
|
||||
# error(而非永久卡死)+ 落一条 seat_turn_timeout 诊断。默认 1200s(20 分钟)——内网模型
|
||||
# 很不稳定时单次「首 token / 工具间隔」可能拖很久,给足容忍度,宁可多等也别误杀慢生成;
|
||||
# idle 只兜真正「连续 20 分钟啥都不发生」的死锁。
|
||||
_SEAT_IDLE_TIMEOUT_S = int(os.getenv("ROUNDTABLE_SEAT_IDLE_TIMEOUT_SECONDS", "1200") or "1200")
|
||||
# 可选的绝对总时长上限(秒):默认 0 = **关闭**(只靠 idle 超时,慢生成不受限)。
|
||||
# 仅当你确实想给单轮设一个天花板时再设它(如某些场景要硬兜底)。
|
||||
_SEAT_MAX_TOTAL_S = int(os.getenv("ROUNDTABLE_SEAT_MAX_TOTAL_SECONDS", "0") or "0")
|
||||
# idle 检测的心跳间隔:每隔这么久检查一次「距上次活动过了多久」。
|
||||
_IDLE_CHECK_INTERVAL_S = 15.0
|
||||
|
||||
_ROUNDTABLE_THREAD_METADATA: dict[str, Any] = {"thread_type": "roundtable", "system": True}
|
||||
_REPORT_FILE = "方案总览.html"
|
||||
_SUMMARY_FILE = "方案总结报告.md"
|
||||
@@ -183,6 +205,108 @@ def _tool_calls_since_last_human(messages: list[dict[str, Any]]) -> list[dict[st
|
||||
return out
|
||||
|
||||
|
||||
async def _await_run_with_idle_guard(
|
||||
app,
|
||||
record,
|
||||
*,
|
||||
idle_timeout: float,
|
||||
diag: DiagnosticsRecorder | None,
|
||||
stage: str,
|
||||
agent_name: str,
|
||||
cycle: int | None,
|
||||
max_total: float = 0.0,
|
||||
) -> None:
|
||||
"""等待一轮 run 完成,但用 **无活动(idle) 超时**而非总时长超时。
|
||||
|
||||
只要 run 仍在持续产出(StreamBridge 上有新事件:流式 token / 工具调用 / 进度),就一直
|
||||
等下去——慢但活着的生成绝不打断。只有当 run **连续 ``idle_timeout`` 秒完全没有任何事件**
|
||||
(真卡死)时才取消它并抛 ``RuntimeError``。``max_total`` > 0 时附加一个绝对总时长上限。
|
||||
|
||||
活动信号取自 StreamBridge(worker 每产一块都 ``bridge.publish``)。拿不到 bridge / run_id
|
||||
时退化为「总时长超时(若配置)或直接等待」。
|
||||
"""
|
||||
task = getattr(record, "task", None)
|
||||
if task is None:
|
||||
return
|
||||
run_id = getattr(record, "run_id", None)
|
||||
bridge = getattr(app.state, "stream_bridge", None)
|
||||
loop = asyncio.get_running_loop()
|
||||
start = loop.time()
|
||||
|
||||
# 没有 bridge 可观察活动 → 退化:有 max_total 用之,否则直接等待(保持旧的「不卡死则一直等」)。
|
||||
if bridge is None or run_id is None:
|
||||
if max_total and max_total > 0:
|
||||
await asyncio.wait_for(asyncio.shield(task), timeout=max_total)
|
||||
else:
|
||||
await task
|
||||
return
|
||||
|
||||
from deerflow.runtime.stream_bridge.base import END_SENTINEL, HEARTBEAT_SENTINEL
|
||||
|
||||
last_activity = loop.time()
|
||||
stuck = asyncio.Event()
|
||||
|
||||
async def _watch() -> None:
|
||||
nonlocal last_activity
|
||||
async for ev in bridge.subscribe(run_id, heartbeat_interval=_IDLE_CHECK_INTERVAL_S):
|
||||
if ev is END_SENTINEL:
|
||||
return # run 已收尾,正常退出(不算卡死)
|
||||
now = loop.time()
|
||||
if ev is HEARTBEAT_SENTINEL:
|
||||
if now - last_activity >= idle_timeout:
|
||||
stuck.set()
|
||||
return
|
||||
if max_total and max_total > 0 and now - start >= max_total:
|
||||
stuck.set()
|
||||
return
|
||||
else:
|
||||
last_activity = now # 任何真实事件 = 还活着
|
||||
|
||||
watch = asyncio.create_task(_watch())
|
||||
try:
|
||||
done, _pending = await asyncio.wait({task, watch}, return_when=asyncio.FIRST_COMPLETED)
|
||||
if task in done:
|
||||
return # run 正常结束(run_agent 内部已吞掉异常并落终态)
|
||||
# watch 先完成:要么判定卡死,要么因 END 正常退出。
|
||||
if not stuck.is_set():
|
||||
await task # END 已出现,run 即将收尾,等它
|
||||
return
|
||||
# —— 判定卡死:取消该 run,落诊断,抛错 ——
|
||||
idle_elapsed = loop.time() - last_activity
|
||||
total = loop.time() - start
|
||||
logger.error(
|
||||
"roundtable %s turn '%s' stalled: no output for %.0fs (total %.0fs) on run %s",
|
||||
stage, agent_name, idle_elapsed, total, run_id,
|
||||
)
|
||||
run_manager = getattr(app.state, "run_manager", None)
|
||||
if run_manager is not None:
|
||||
try:
|
||||
await run_manager.cancel(run_id, action="interrupt")
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("cancel stalled run %s failed", run_id, exc_info=True)
|
||||
if diag is not None:
|
||||
await diag.record(
|
||||
stage=stage, level="error", event="seat_turn_timeout",
|
||||
message=(
|
||||
f"「{agent_name}」连续 {idle_elapsed:.0f}s 无任何输出、判定为卡死并已取消"
|
||||
f"(idle 上限 {idle_timeout:.0f}s;本轮已运行 {total:.0f}s)。常见原因:内网模型端点连接"
|
||||
"被打满 / sqlite checkpoint 在挂载卷上锁挂死。注意:慢但持续产出的生成不会被打断。"
|
||||
),
|
||||
detail={"idle_seconds": round(idle_elapsed, 1), "total_seconds": round(total, 1), "idle_timeout": idle_timeout},
|
||||
agent_name=agent_name, cycle=cycle,
|
||||
)
|
||||
raise RuntimeError(
|
||||
f"roundtable seat turn '{agent_name}' stalled (no output for {idle_timeout:.0f}s)"
|
||||
) from None
|
||||
finally:
|
||||
if not watch.done():
|
||||
watch.cancel()
|
||||
try:
|
||||
await watch
|
||||
except BaseException: # noqa: BLE001 — 取消 watch 的异常无关紧要
|
||||
pass
|
||||
|
||||
|
||||
async def _run_agent_turn(
|
||||
app,
|
||||
*,
|
||||
@@ -196,6 +320,9 @@ async def _run_agent_turn(
|
||||
thinking_enabled: bool,
|
||||
reasoning_effort: str,
|
||||
subagent_enabled: bool = False,
|
||||
diag: DiagnosticsRecorder | None = None,
|
||||
stage: str = "step2_seat",
|
||||
cycle: int | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""进程内驱动 agent_name 在 thread_id 上跑一轮(发 message),阻塞到完成,返回最终 messages。
|
||||
|
||||
@@ -239,9 +366,25 @@ async def _run_agent_turn(
|
||||
# 取该用户的后台额度后再开跑:start_run + await task 是最重的部分(建 agent、调模型、
|
||||
# 落 checkpoint)。额度满时本轮在此 await 排队,等同用户某个在跑轮次释放额度后再开。
|
||||
async with _user_background_semaphore(user_id):
|
||||
record = await start_run(body, thread_id, request) # type: ignore[arg-type]
|
||||
if getattr(record, "task", None) is not None:
|
||||
await record.task # 阻塞到这轮 agent 跑完,checkpoint 已落盘
|
||||
try:
|
||||
record = await start_run(body, thread_id, request) # type: ignore[arg-type]
|
||||
except Exception as exc: # noqa: BLE001 — start_run 报错(409 抢占 / DB 抖动等)也要落诊断
|
||||
if diag is not None:
|
||||
await diag.record(
|
||||
stage=stage, level="error", event="start_run_failed",
|
||||
message=f"启动 agent run 失败({agent_name}):{exc}",
|
||||
detail={"type": type(exc).__name__, "error": str(exc), "thread_id": thread_id},
|
||||
agent_name=agent_name, cycle=cycle,
|
||||
)
|
||||
raise
|
||||
# idle 超时守护:慢但持续产出的生成一直等;只有连续 _SEAT_IDLE_TIMEOUT_S 秒**毫无输出**
|
||||
# (真卡死)才取消并抛错。绝不因「这轮总耗时长」而打断(除非显式配了 _SEAT_MAX_TOTAL_S)。
|
||||
await _await_run_with_idle_guard(
|
||||
app, record,
|
||||
idle_timeout=_SEAT_IDLE_TIMEOUT_S,
|
||||
max_total=_SEAT_MAX_TOTAL_S,
|
||||
diag=diag, stage=stage, agent_name=agent_name, cycle=cycle,
|
||||
)
|
||||
return await _read_thread_messages(app, thread_id)
|
||||
|
||||
|
||||
@@ -382,11 +525,23 @@ class InProcessRoundtableGateway:
|
||||
seat_thinking_enabled: bool | None = None,
|
||||
seat_reasoning_effort: str | None = None,
|
||||
seat_subagent_enabled: bool = False,
|
||||
job_id: str | None = None,
|
||||
draft_id: str | None = None,
|
||||
task_id: str | None = None,
|
||||
) -> None:
|
||||
self._app = app
|
||||
self._user_id = user_id
|
||||
self._agents = list(agents)
|
||||
self._model = model
|
||||
# 诊断日志记录器(绑定本作业上下文)。用进程级默认 store(deps.py 启动时已登记)。
|
||||
self._diag = DiagnosticsRecorder(
|
||||
get_default_store(),
|
||||
scope="background",
|
||||
job_id=job_id,
|
||||
draft_id=draft_id,
|
||||
task_id=task_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
# 席位执行模式(沿用 Step2 选择):覆盖 seat policy 的 thinking/reasoning(None → policy 默认),
|
||||
# subagent 仅 ultra 档为真。只作用 special 席位;leader 派活不受影响。
|
||||
self._seat_thinking_enabled = seat_thinking_enabled
|
||||
@@ -426,6 +581,8 @@ class InProcessRoundtableGateway:
|
||||
skill_stop_names=policy.skill_stop_names,
|
||||
thinking_enabled=policy.thinking_enabled,
|
||||
reasoning_effort=policy.reasoning_effort,
|
||||
diag=self._diag,
|
||||
stage="step2_leader",
|
||||
)
|
||||
ai = _latest_ai_message(messages)
|
||||
# tool_calls 取最后一条 AI 消息(派活就在这条);正文取最后一条有文字的 AI 消息。
|
||||
@@ -456,13 +613,17 @@ class InProcessRoundtableGateway:
|
||||
|
||||
async def run_seat(self, *, thread_ids, agent_id, task, model) -> SeatTurn:
|
||||
policy = roundtable_run_policy("seat")
|
||||
# 弱模型补偿:把该席位配置的技能(名称 + SKILL.md 路径 + 必须先 read_file 再按其
|
||||
# 流程取信息的硬指令)显式拼进本轮任务,避免弱模型无视系统提示里的技能块(见
|
||||
# roundtable_seat_skills 模块说明)。无配置技能时原样返回 task。
|
||||
seat_task = append_seat_skill_directive(agent_id, task)
|
||||
# 席位模式覆盖 policy 的 thinking/reasoning(None → policy 默认),与前端 _special_run 一致。
|
||||
messages = await _run_agent_turn(
|
||||
self._app,
|
||||
user_id=self._user_id,
|
||||
thread_id=thread_ids[agent_id],
|
||||
agent_name=agent_id,
|
||||
message=task,
|
||||
message=seat_task,
|
||||
model=model or self._model,
|
||||
excluded_tools=policy.excluded_tools,
|
||||
skill_stop_names=policy.skill_stop_names,
|
||||
@@ -477,6 +638,8 @@ class InProcessRoundtableGateway:
|
||||
else self._seat_reasoning_effort
|
||||
),
|
||||
subagent_enabled=self._seat_subagent_enabled,
|
||||
diag=self._diag,
|
||||
stage="step2_seat",
|
||||
)
|
||||
return SeatTurn(final_text=_latest_ai_text(messages), tool_calls=_tool_calls_since_last_human(messages))
|
||||
|
||||
@@ -503,19 +666,32 @@ class InProcessRoundtableGateway:
|
||||
skill_stop_names=policy.skill_stop_names,
|
||||
thinking_enabled=policy.thinking_enabled,
|
||||
reasoning_effort=policy.reasoning_effort,
|
||||
diag=self._diag,
|
||||
stage="step3_report",
|
||||
)
|
||||
summary = _latest_ai_text(messages) or summary
|
||||
html = _read_report_html(self._app, report_thread_id, self._user_id)
|
||||
if _html_complete(html):
|
||||
break
|
||||
if attempt >= _ARTIFACT_RETRY_LIMIT:
|
||||
await self._diag.record(
|
||||
stage="step3_report", level="error", event="report_artifact_incomplete",
|
||||
message=f"结果绘制重试 {_ARTIFACT_RETRY_LIMIT} 次后 HTML 仍缺失/截断,回退为正文展示",
|
||||
detail={"thread_id": report_thread_id, "has_partial_html": bool(html and html.strip())},
|
||||
agent_name=REPORT_AGENT_ID,
|
||||
)
|
||||
break
|
||||
kind = "truncated" if (html and html.strip()) else "missing"
|
||||
message = _REPORT_RETRY_TRUNCATED if (html and html.strip()) else _REPORT_RETRY_MISSING
|
||||
logger.warning(
|
||||
"report html %s after attempt %d for thread %s; retrying with corrective prompt",
|
||||
"truncated" if (html and html.strip()) else "missing",
|
||||
attempt + 1,
|
||||
report_thread_id,
|
||||
kind, attempt + 1, report_thread_id,
|
||||
)
|
||||
await self._diag.record(
|
||||
stage="step3_report", level="warning", event=f"report_{kind}",
|
||||
message=f"结果绘制第 {attempt + 1} 次产物{('被截断' if kind == 'truncated' else '缺失')},已追加纠偏提示重试",
|
||||
detail={"thread_id": report_thread_id, "attempt": attempt + 1},
|
||||
agent_name=REPORT_AGENT_ID,
|
||||
)
|
||||
# 截断的 HTML 也比纯文字强(浏览器容忍未闭合标签),仅在完全没有文件时才回退正文。
|
||||
final_html = html if (html and html.strip()) else summary
|
||||
@@ -547,12 +723,20 @@ class InProcessRoundtableGateway:
|
||||
skill_stop_names=policy.skill_stop_names,
|
||||
thinking_enabled=policy.thinking_enabled,
|
||||
reasoning_effort=policy.reasoning_effort,
|
||||
diag=self._diag,
|
||||
stage="step3_summary",
|
||||
)
|
||||
closing = _latest_ai_text(messages) or closing
|
||||
md = _read_summary_md(self._app, summary_thread_id, self._user_id)
|
||||
if md and md.strip():
|
||||
break
|
||||
if attempt >= _ARTIFACT_RETRY_LIMIT:
|
||||
await self._diag.record(
|
||||
stage="step3_summary", level="error", event="summary_artifact_missing",
|
||||
message=f"方案总结报告重试 {_ARTIFACT_RETRY_LIMIT} 次后 .md 仍缺失,回退为对话正文",
|
||||
detail={"thread_id": summary_thread_id},
|
||||
agent_name=SUMMARY_AGENT_ID,
|
||||
)
|
||||
break
|
||||
message = _SUMMARY_RETRY_MISSING
|
||||
logger.warning(
|
||||
@@ -560,6 +744,12 @@ class InProcessRoundtableGateway:
|
||||
attempt + 1,
|
||||
summary_thread_id,
|
||||
)
|
||||
await self._diag.record(
|
||||
stage="step3_summary", level="warning", event="summary_missing",
|
||||
message=f"方案总结报告第 {attempt + 1} 次未生成 .md,已追加纠偏提示重试",
|
||||
detail={"thread_id": summary_thread_id, "attempt": attempt + 1},
|
||||
agent_name=SUMMARY_AGENT_ID,
|
||||
)
|
||||
final_md = md if (md and md.strip()) else closing
|
||||
return SummaryResult(md=final_md, closing=closing, model=model or self._model, thread_id=summary_thread_id)
|
||||
|
||||
@@ -589,11 +779,19 @@ class InProcessRoundtableGateway:
|
||||
skill_stop_names=policy.skill_stop_names,
|
||||
thinking_enabled=policy.thinking_enabled,
|
||||
reasoning_effort=policy.reasoning_effort,
|
||||
diag=self._diag,
|
||||
stage="step3_dashboard",
|
||||
)
|
||||
body = _extract_report_json(_latest_ai_text(messages))
|
||||
if body:
|
||||
return body
|
||||
if attempt >= _ARTIFACT_RETRY_LIMIT:
|
||||
await self._diag.record(
|
||||
stage="step3_dashboard", level="error", event="dashboard_json_invalid",
|
||||
message=f"大屏数据重试 {_ARTIFACT_RETRY_LIMIT} 次后仍抽取不到合法 report-json,放弃大屏产物",
|
||||
detail={"thread_id": thread_id},
|
||||
agent_name=DASHBOARD_AGENT_ID,
|
||||
)
|
||||
break
|
||||
message = _DASHBOARD_RETRY
|
||||
logger.warning(
|
||||
@@ -601,4 +799,10 @@ class InProcessRoundtableGateway:
|
||||
attempt + 1,
|
||||
thread_id,
|
||||
)
|
||||
await self._diag.record(
|
||||
stage="step3_dashboard", level="warning", event="dashboard_json_retry",
|
||||
message=f"大屏数据第 {attempt + 1} 次抽取失败(缺 report-json / JSON 非法),已追加纠偏提示重试",
|
||||
detail={"thread_id": thread_id, "attempt": attempt + 1},
|
||||
agent_name=DASHBOARD_AGENT_ID,
|
||||
)
|
||||
return None
|
||||
@@ -26,6 +26,7 @@ from deerflow.agents.roundtable_orchestrator import (
|
||||
SeatRef,
|
||||
run_orchestration,
|
||||
)
|
||||
from deerflow.persistence.roundtable_diagnostics import DiagnosticsRecorder, get_default_store
|
||||
from deerflow.runtime.user_context import reset_current_user, set_current_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -219,7 +220,20 @@ class RoundtableJobExecutor:
|
||||
|
||||
async def _run(self, params: StartParams) -> None:
|
||||
token = set_current_user(_JobUser(params.user_id))
|
||||
diag = DiagnosticsRecorder(
|
||||
get_default_store(),
|
||||
scope="background",
|
||||
job_id=params.job_id,
|
||||
draft_id=params.draft_id,
|
||||
task_id=params.task_id,
|
||||
user_id=params.user_id,
|
||||
)
|
||||
try:
|
||||
await diag.record(
|
||||
stage="job", level="info", event="job_started",
|
||||
message=f"后台挂起作业启动(mode={params.mode}{',续跑' if params.is_resume else ''})",
|
||||
detail={"mode": params.mode, "is_resume": params.is_resume, "agents": [a.agent_id for a in params.agents]},
|
||||
)
|
||||
gateway = self._gateway_factory(params)
|
||||
thread_ids = params.thread_ids or {}
|
||||
coordinator_name = params.coordinator_name
|
||||
@@ -248,6 +262,7 @@ class RoundtableJobExecutor:
|
||||
job_id=params.job_id,
|
||||
agents=params.agents,
|
||||
on_dialogue=_on_dialogue if (self._draft_store_for(params) and params.draft_id) else None,
|
||||
recorder=diag,
|
||||
)
|
||||
result = await run_orchestration(
|
||||
gateway=gateway,
|
||||
@@ -283,8 +298,13 @@ class RoundtableJobExecutor:
|
||||
# 路由的 cancel 端点(executor.cancel + store.update_progress)权威写入。
|
||||
logger.info("roundtable job %s cancelled", params.job_id)
|
||||
raise
|
||||
except Exception: # noqa: BLE001 — 顶层兜底:run_orchestration 内部已尽量标 error
|
||||
except Exception as exc: # noqa: BLE001 — 顶层兜底:run_orchestration 内部已尽量标 error
|
||||
logger.exception("roundtable job %s crashed", params.job_id)
|
||||
await diag.record(
|
||||
stage="job", level="error", event="job_crashed",
|
||||
message=f"后台挂起作业崩溃:{exc}",
|
||||
detail={"type": type(exc).__name__, "error": str(exc)},
|
||||
)
|
||||
finally:
|
||||
reset_current_user(token)
|
||||
|
||||
@@ -363,8 +383,16 @@ class RoundtableJobExecutor:
|
||||
update_kwargs["step3"] = effective_step3
|
||||
try:
|
||||
await draft_store.update_draft(params.draft_id, params.user_id, **update_kwargs)
|
||||
except Exception: # noqa: BLE001 — 写草稿失败不影响作业本身
|
||||
except Exception as exc: # noqa: BLE001 — 写草稿失败不影响作业本身
|
||||
logger.warning("write run to draft %s failed", params.draft_id, exc_info=True)
|
||||
from deerflow.persistence.roundtable_diagnostics import record_diagnostic
|
||||
|
||||
await record_diagnostic(
|
||||
scope="background", stage="job", level="error", event="draft_write_failed",
|
||||
message=f"研讨结果写回草稿失败(status={status}):{exc}",
|
||||
detail={"type": type(exc).__name__, "error": str(exc)},
|
||||
job_id=params.job_id, draft_id=params.draft_id, task_id=params.task_id, user_id=params.user_id,
|
||||
)
|
||||
|
||||
def cancel(self, job_id: str) -> bool:
|
||||
"""请求取消一个在跑的作业。返回是否命中在跑任务。"""
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
"""会商第二步席位「技能识别」强化(弱模型友好)。
|
||||
|
||||
痛点:席位是一次自定义 agent run,其配置技能(agent ``config.yaml → skills``)只通过
|
||||
**系统提示**里的 ``<skill_system>`` 块注入(列出 name/description + SKILL.md 路径,靠模型
|
||||
自己 ``read_file`` 并遵循)。强模型会照做;**弱模型(如 deepseek-chat)往往无视系统提示里
|
||||
的技能块,从不 read_file SKILL.md**,于是配置的技能形同虚设——只凭模型自身知识作答。
|
||||
|
||||
解法:在**派给席位的本轮任务消息(human turn)**里,显式把该席位配置的每个技能(名称 +
|
||||
描述 + SKILL.md 容器路径)列出来,并下达硬指令「先 read_file 对应 SKILL.md、按其说明用工具
|
||||
去取信息 / 走流程,再产出交付」。把指令放进 user turn(而非仅系统提示)对弱模型的牵引力
|
||||
强得多——这与本仓已有的弱模型补偿手法(``_SEAT_NO_ASK``、总控派活强化、AI 写作 SKILL.md
|
||||
注入,见 ``project-ai-writing-skill-agent``)一脉相承。
|
||||
|
||||
两条席位执行路径共用本 helper:
|
||||
- 前端手动:``multi_agent._special_run``(seat / seat_parallel 角色);
|
||||
- 后台挂起:``roundtable_inprocess_gateway.InProcessRoundtableGateway.run_seat``。
|
||||
|
||||
无配置技能的席位 → 返回空串(完全无副作用)。可用 ``ROUNDTABLE_SEAT_SKILL_DIRECTIVE=0``
|
||||
全局关闭(默认开)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_TRUTHY = frozenset({"1", "true", "yes", "on", "y"})
|
||||
|
||||
|
||||
def _directive_enabled() -> bool:
|
||||
"""读 ``ROUNDTABLE_SEAT_SKILL_DIRECTIVE``(默认开)。空 / 未设 = 开。"""
|
||||
return (os.getenv("ROUNDTABLE_SEAT_SKILL_DIRECTIVE") or "1").strip().lower() in _TRUTHY
|
||||
|
||||
|
||||
def build_seat_skill_directive(agent_id: str | None) -> str:
|
||||
"""为某席位 agent 构建「必须先读 SKILL.md 再用技能」的本轮任务追加指令。
|
||||
|
||||
返回 ``""`` 表示无需追加(开关关闭 / 无 agent_id / 该席位没有配置技能 / 解析失败),
|
||||
调用方原样使用任务文本,零副作用。
|
||||
"""
|
||||
if not _directive_enabled() or not agent_id:
|
||||
return ""
|
||||
try:
|
||||
from deerflow.config.agents_config import load_agent_config
|
||||
|
||||
cfg = load_agent_config(agent_id)
|
||||
configured = [name for name in (cfg.skills or []) if name] if cfg else []
|
||||
if not configured:
|
||||
return ""
|
||||
|
||||
from deerflow.skills.storage import get_or_new_skill_storage
|
||||
|
||||
try:
|
||||
from deerflow.config import get_app_config
|
||||
|
||||
container_base_path = get_app_config().skills.container_path
|
||||
except Exception: # noqa: BLE001 — 配置读不到时退回默认挂载路径
|
||||
container_base_path = "/mnt/skills"
|
||||
|
||||
by_name = {s.name: s for s in get_or_new_skill_storage().load_skills(enabled_only=True)}
|
||||
# 保留席位 config 里的配置顺序,剔除已删除 / 未启用的技能名。
|
||||
wanted = [by_name[name] for name in configured if name in by_name]
|
||||
if not wanted:
|
||||
return ""
|
||||
|
||||
lines: list[str] = [
|
||||
"【你已配置以下专属技能,必须优先使用——这是硬性要求,不要只凭自身知识作答】",
|
||||
"完成本轮交付前,请先判断哪些技能与任务相关,**用 read_file 工具读取对应技能的 SKILL.md 全文**,"
|
||||
"严格按其中的说明(检索方式 / 接口 / 流程 / 输出格式)去**获取真实信息或执行处理流程**"
|
||||
"(其中可能要求你调用 bash / write_file 等工具),再据此产出交付:",
|
||||
"",
|
||||
]
|
||||
for i, s in enumerate(wanted, 1):
|
||||
desc = (s.description or "").strip().replace("\n", " ")
|
||||
lines.append(f"{i}. 「{s.name}」{desc}".rstrip())
|
||||
lines.append(f" - 技能说明文件(先 read_file 它):{s.get_container_file_path(container_base_path)}")
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"注意:只要某个技能与本轮任务相关,**务必先 read_file 它的 SKILL.md 并按其指引调用工具**,"
|
||||
"不要跳过技能直接凭记忆回答;技能里写明的接口 / 命令要真正执行,不要假装调用。"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
except Exception: # noqa: BLE001 — 强化失败绝不能拖垮席位本身的执行
|
||||
logger.debug("build seat skill directive failed for agent %s", agent_id, exc_info=True)
|
||||
return ""
|
||||
|
||||
|
||||
def append_seat_skill_directive(agent_id: str | None, task: str) -> str:
|
||||
"""把技能识别指令追加到席位本轮任务文本后(无指令时原样返回)。"""
|
||||
directive = build_seat_skill_directive(agent_id)
|
||||
if not directive:
|
||||
return task
|
||||
return f"{task}\n\n---\n\n{directive}"
|
||||
@@ -61,6 +61,8 @@ import httpx
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app.gateway.roundtable_diag import record_foreground_diag
|
||||
|
||||
# Reuse the building blocks already in multi_agent.py — keeps both routers
|
||||
# identical in connection handling, payload shape, and error mapping.
|
||||
from app.gateway.routers._roundtable_seed import ensure_roundtable_functional_agents
|
||||
@@ -298,6 +300,11 @@ async def init_intent(request: Request, payload: dict = Body(...)) -> dict[str,
|
||||
# 在 !res.ok 分支里读 detail.hint / trace_tail 渲染诊断面板。
|
||||
diagnostic = _diagnose_exception(exc, context="intent_init")
|
||||
logger.error("intent init failed: %s", diagnostic, exc_info=exc)
|
||||
await record_foreground_diag(
|
||||
request, stage="step1_intent", level="error", event="intent_init_failed",
|
||||
message=f"第一步意图初始化失败:{diagnostic.get('message') or exc}",
|
||||
detail=diagnostic,
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=diagnostic) from exc
|
||||
return {
|
||||
"status": "success",
|
||||
@@ -561,6 +568,11 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
|
||||
"trace_tail": [],
|
||||
"context": "intent_stream",
|
||||
}
|
||||
await record_foreground_diag(
|
||||
request, stage="step1_intent", level="error", event="intent_http_error",
|
||||
message=f"第一步意图生成失败:{error_detail.get('message')}",
|
||||
detail=error_detail,
|
||||
)
|
||||
err = {
|
||||
"status": "error",
|
||||
"content": error_detail["message"],
|
||||
@@ -571,6 +583,11 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
|
||||
except Exception as exc:
|
||||
diagnostic = _diagnose_exception(exc, context="intent_stream")
|
||||
logger.error("intent stream failed: %s", diagnostic, exc_info=exc)
|
||||
await record_foreground_diag(
|
||||
request, stage="step1_intent", level="error", event="intent_unexpected",
|
||||
message=f"第一步意图生成未预期异常:{diagnostic.get('message') or exc}",
|
||||
detail=diagnostic,
|
||||
)
|
||||
err = {
|
||||
"status": "error",
|
||||
"content": diagnostic["message"],
|
||||
|
||||
@@ -62,7 +62,9 @@ from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from langgraph.checkpoint.base import empty_checkpoint
|
||||
|
||||
from app.gateway.roundtable_diag import record_foreground_diag
|
||||
from app.gateway.roundtable_run_policy import merge_skill_stop_names, roundtable_run_policy
|
||||
from app.gateway.roundtable_seat_skills import append_seat_skill_directive
|
||||
from app.gateway.routers._roundtable_seed import (
|
||||
COORDINATOR_AGENT_ID,
|
||||
DASHBOARD_AGENT_ID,
|
||||
@@ -80,6 +82,23 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/multi-agent", tags=["multi-agent"])
|
||||
|
||||
|
||||
def _stage_for_agent(agent_type: str, agent_name: str) -> str:
|
||||
"""把一次 /run/stream 的 (agent_type, agent_name) 映射成诊断日志的 stage。
|
||||
|
||||
Step3 的四个内置单例(report/summary/dashboard/structure)也走 special 路径,
|
||||
据 agent_name 归到对应 step3_* 阶段;其余 leader→step2_leader、special→step2_seat。
|
||||
"""
|
||||
if agent_name == REPORT_AGENT_ID:
|
||||
return "step3_report"
|
||||
if agent_name == SUMMARY_AGENT_ID:
|
||||
return "step3_summary"
|
||||
if agent_name == DASHBOARD_AGENT_ID:
|
||||
return "step3_dashboard"
|
||||
if agent_name == STRUCTURE_AGENT_ID:
|
||||
return "step3_structure"
|
||||
return "step2_leader" if agent_type == "leader" else "step2_seat"
|
||||
|
||||
|
||||
# 圆桌(intent / recommend / 席位 / 协调)创建的 thread 都是功能型内部线程,
|
||||
# 不是用户的普通对话。打上这个 metadata,前端「最近的对话」列表(recent-chat-list.tsx)
|
||||
# 据 `metadata.thread_type` 过滤掉,与 scheduler / bootstrap 系统会话同一套机制。
|
||||
@@ -892,9 +911,19 @@ async def init_multi_agent(request: Request, payload: dict = Body(...)) -> Strea
|
||||
})
|
||||
except HTTPException as exc:
|
||||
logger.warning("multi-agent init HTTPException: %s %s", exc.status_code, exc.detail)
|
||||
await record_foreground_diag(
|
||||
request, stage="step2_init", level="error", event="init_http_error",
|
||||
message=f"会商初始化失败 [{exc.status_code}]:{exc.detail}",
|
||||
detail={"status_code": exc.status_code, "detail": str(exc.detail)},
|
||||
)
|
||||
yield _frame({"event": "error", "detail": f"[{exc.status_code}] {exc.detail}"})
|
||||
except Exception as exc:
|
||||
logger.exception("multi-agent init unexpected failure")
|
||||
await record_foreground_diag(
|
||||
request, stage="step2_init", level="error", event="init_unexpected",
|
||||
message=f"会商初始化未预期异常:{exc!r}",
|
||||
detail={"type": type(exc).__name__, "error": str(exc)},
|
||||
)
|
||||
yield _frame({"event": "error", "detail": f"unexpected: {exc!r}"})
|
||||
|
||||
return StreamingResponse(
|
||||
@@ -922,8 +951,16 @@ async def init_report_thread(request: Request) -> dict[str, str]:
|
||||
``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)
|
||||
try:
|
||||
ensure_roundtable_functional_agents()
|
||||
thread_id = await _create_thread_direct(request, metadata=_ROUNDTABLE_THREAD_METADATA)
|
||||
except Exception as exc:
|
||||
await record_foreground_diag(
|
||||
request, stage="step3_report", level="error", event="thread_init_failed",
|
||||
message=f"创建结果绘制线程失败:{exc}", detail={"type": type(exc).__name__, "error": str(exc)},
|
||||
agent_id=REPORT_AGENT_ID,
|
||||
)
|
||||
raise
|
||||
return {"agent_name": REPORT_AGENT_ID, "thread_id": thread_id}
|
||||
|
||||
|
||||
@@ -942,8 +979,16 @@ async def init_summary_thread(request: Request) -> dict[str, str]:
|
||||
跑这一轮(后续问答/增量改**复用同一线程**,不重新 init),智能体把 Markdown 报告
|
||||
写进该线程的 ``outputs/方案总结报告.md``,再由沙箱预览。
|
||||
"""
|
||||
ensure_roundtable_functional_agents()
|
||||
thread_id = await _create_thread_direct(request, metadata=_ROUNDTABLE_THREAD_METADATA)
|
||||
try:
|
||||
ensure_roundtable_functional_agents()
|
||||
thread_id = await _create_thread_direct(request, metadata=_ROUNDTABLE_THREAD_METADATA)
|
||||
except Exception as exc:
|
||||
await record_foreground_diag(
|
||||
request, stage="step3_summary", level="error", event="thread_init_failed",
|
||||
message=f"创建总结报告线程失败:{exc}", detail={"type": type(exc).__name__, "error": str(exc)},
|
||||
agent_id=SUMMARY_AGENT_ID,
|
||||
)
|
||||
raise
|
||||
return {"agent_name": SUMMARY_AGENT_ID, "thread_id": thread_id}
|
||||
|
||||
|
||||
@@ -964,8 +1009,16 @@ async def init_dashboard_thread(request: Request) -> dict[str, str]:
|
||||
跑这一轮(后续问答/增量改**复用同一线程**,不重新 init),智能体在对话里输出
|
||||
```report-json,前端用 ``extractReportData`` 抽取后按当前主题渲染大屏。
|
||||
"""
|
||||
ensure_roundtable_functional_agents()
|
||||
thread_id = await _create_thread_direct(request, metadata=_ROUNDTABLE_THREAD_METADATA)
|
||||
try:
|
||||
ensure_roundtable_functional_agents()
|
||||
thread_id = await _create_thread_direct(request, metadata=_ROUNDTABLE_THREAD_METADATA)
|
||||
except Exception as exc:
|
||||
await record_foreground_diag(
|
||||
request, stage="step3_dashboard", level="error", event="thread_init_failed",
|
||||
message=f"创建大屏数据线程失败:{exc}", detail={"type": type(exc).__name__, "error": str(exc)},
|
||||
agent_id=DASHBOARD_AGENT_ID,
|
||||
)
|
||||
raise
|
||||
return {"agent_name": DASHBOARD_AGENT_ID, "thread_id": thread_id}
|
||||
|
||||
|
||||
@@ -986,8 +1039,16 @@ async def init_structure_thread(request: Request) -> dict[str, str]:
|
||||
跑这一轮,把 taskId + 总结报告 + 结构化流程图交给它,由它挑选合适的入库技能(名称
|
||||
可能含「六步法」/自行判断适用性)读 SKILL.md 并按其契约调用完成入库。
|
||||
"""
|
||||
ensure_roundtable_functional_agents()
|
||||
thread_id = await _create_thread_direct(request, metadata=_ROUNDTABLE_THREAD_METADATA)
|
||||
try:
|
||||
ensure_roundtable_functional_agents()
|
||||
thread_id = await _create_thread_direct(request, metadata=_ROUNDTABLE_THREAD_METADATA)
|
||||
except Exception as exc:
|
||||
await record_foreground_diag(
|
||||
request, stage="step3_structure", level="error", event="thread_init_failed",
|
||||
message=f"创建数据入库线程失败:{exc}", detail={"type": type(exc).__name__, "error": str(exc)},
|
||||
agent_id=STRUCTURE_AGENT_ID,
|
||||
)
|
||||
raise
|
||||
return {"agent_name": STRUCTURE_AGENT_ID, "thread_id": thread_id}
|
||||
|
||||
|
||||
@@ -995,6 +1056,16 @@ async def init_structure_thread(request: Request) -> dict[str, str]:
|
||||
# 上游 LangGraph SSE 透传(intent / recommend / leader / special 共用)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# 回环 /runs/stream 在**响应头阶段**返回这些状态码时,视为「单 worker + 单 sqlite 锁被
|
||||
# 并发打满」造成的瞬时失败,退避重试(详见 _stream_upstream 内注释):
|
||||
# 409 同 thread 抢占竞态;429 限流;500/503/504 高并发下 start_run 里 DB/checkpoint 写抖动。
|
||||
# 不含 400/401/403/404/422 等确定性错误——那些重试也没用,立即上报。
|
||||
_UPSTREAM_RETRYABLE_STATUSES = frozenset({409, 429, 500, 502, 503, 504})
|
||||
# 重试次数(不含首次)。总尝试 = 1 + _UPSTREAM_MAX_RETRIES。
|
||||
_UPSTREAM_MAX_RETRIES = 2
|
||||
# 指数退避基数(秒):0.4 → 0.8 → …,足够让在跑的席位 run 让出 sqlite 锁,又不至于明显拖慢。
|
||||
_UPSTREAM_RETRY_BASE_DELAY = 0.4
|
||||
|
||||
|
||||
async def _stream_upstream(
|
||||
client: httpx.AsyncClient,
|
||||
@@ -1017,36 +1088,74 @@ async def _stream_upstream(
|
||||
|
||||
url = f"{base}/api/threads/{thread_id}/runs/stream"
|
||||
captured: list[str] = []
|
||||
t_send = time.perf_counter()
|
||||
async with client.stream("POST", url, json=body, headers=headers) as resp:
|
||||
t_headers = time.perf_counter()
|
||||
if resp.status_code != 200:
|
||||
body_text = (await resp.aread()).decode("utf-8", "replace")[:1000]
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"upstream {url} returned HTTP {resp.status_code}: {body_text}",
|
||||
)
|
||||
agent_for_log = (body.get("context") or {}).get("agent_name") or thread_id
|
||||
logger.info(
|
||||
"[upstream-timing] %s headers_in=%.3fs",
|
||||
agent_for_log,
|
||||
t_headers - t_send,
|
||||
)
|
||||
first_line = True
|
||||
async for line in resp.aiter_lines():
|
||||
if not line:
|
||||
continue
|
||||
if first_line:
|
||||
first_line = False
|
||||
logger.info(
|
||||
"[upstream-timing] %s first_line=%.3fs (LangGraph runtime startup + LLM first byte)",
|
||||
agent_for_log,
|
||||
time.perf_counter() - t_headers,
|
||||
agent_for_log = (body.get("context") or {}).get("agent_name") or thread_id
|
||||
|
||||
# 头阶段非 200 的瞬时重试。
|
||||
# 背景:网关是**单 worker** + checkpointer 是**单连接 aiosqlite**(全进程一把
|
||||
# asyncio.Lock 串行 checkpoint 读写)。圆桌第三步进入时,前端把「方案总结/结果绘制/
|
||||
# 大屏」三个重 run **同一刻并行**打出来,叠加第二步 on_disconnect=continue 仍在后台
|
||||
# 跑的席位 run,瞬时把单锁/单 worker 打满 —— 此时回环子请求 /runs/stream 的 start_run
|
||||
# 里那串 DB / checkpoint 写有概率抛错(或同 thread 抢占竞态),返回非 200,被本函数原样
|
||||
# 包成 502 漏给前端(用户侧表现为"进第三步概率性 502")。
|
||||
# 关键安全前提:这个非 200 发生在**响应头阶段**,本函数**尚未 yield 任何 SSE 行**给下游,
|
||||
# 所以"换条连接重发"是幂等安全的(不会重复透传半截流)。对可恢复的瞬时状态码退避重试几次,
|
||||
# 把绝大多数并发尖峰造成的偶发失败吃掉;非可恢复状态码(如 400/401/403/404)立即按原逻辑
|
||||
# 报错,不浪费重试。一旦进入流式(已 yield 行)就绝不重试。
|
||||
last_status = 0
|
||||
last_body = ""
|
||||
for attempt in range(_UPSTREAM_MAX_RETRIES + 1):
|
||||
t_send = time.perf_counter()
|
||||
async with client.stream("POST", url, json=body, headers=headers) as resp:
|
||||
t_headers = time.perf_counter()
|
||||
if resp.status_code != 200:
|
||||
last_status = resp.status_code
|
||||
last_body = (await resp.aread()).decode("utf-8", "replace")[:1000]
|
||||
if resp.status_code in _UPSTREAM_RETRYABLE_STATUSES and attempt < _UPSTREAM_MAX_RETRIES:
|
||||
delay = _UPSTREAM_RETRY_BASE_DELAY * (2**attempt)
|
||||
logger.warning(
|
||||
"[upstream-retry] %s 回环 %s 返回 HTTP %s,%.2fs 后重试(第 %d/%d 次):%s",
|
||||
agent_for_log,
|
||||
url,
|
||||
resp.status_code,
|
||||
delay,
|
||||
attempt + 1,
|
||||
_UPSTREAM_MAX_RETRIES,
|
||||
last_body[:200],
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
continue # 重连:换条新连接重发同一 body(尚未 yield 任何行,幂等安全)
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"upstream {url} returned HTTP {resp.status_code}: {last_body}",
|
||||
)
|
||||
yield line, None
|
||||
if line.startswith("data:"):
|
||||
captured.append(line)
|
||||
yield None, captured
|
||||
logger.info(
|
||||
"[upstream-timing] %s headers_in=%.3fs%s",
|
||||
agent_for_log,
|
||||
t_headers - t_send,
|
||||
f" (retried x{attempt})" if attempt else "",
|
||||
)
|
||||
first_line = True
|
||||
async for line in resp.aiter_lines():
|
||||
if not line:
|
||||
continue
|
||||
if first_line:
|
||||
first_line = False
|
||||
logger.info(
|
||||
"[upstream-timing] %s first_line=%.3fs (LangGraph runtime startup + LLM first byte)",
|
||||
agent_for_log,
|
||||
time.perf_counter() - t_headers,
|
||||
)
|
||||
yield line, None
|
||||
if line.startswith("data:"):
|
||||
captured.append(line)
|
||||
yield None, captured
|
||||
return
|
||||
|
||||
# 理论上不可达(循环要么 return、要么 raise);留一条兜底防御。
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"upstream {url} returned HTTP {last_status}: {last_body}",
|
||||
)
|
||||
|
||||
|
||||
def _parse_last_messages(captured: list[str]) -> list[dict[str, Any]] | None:
|
||||
@@ -1677,11 +1786,18 @@ async def _special_run(
|
||||
if files:
|
||||
_read_tools = {"read_file", "ls", "grep", "view_image"}
|
||||
seat_excluded = [t for t in seat_excluded if t not in _read_tools]
|
||||
# 弱模型补偿:仅对真正的研讨席位(seat / seat_parallel;report/summary/dashboard/ingest
|
||||
# 等 Step3 单例不在此列)把其配置技能(名称 + SKILL.md 路径 + 必须先 read_file 再按其
|
||||
# 流程取信息的硬指令)显式拼进本轮任务,避免弱模型无视系统提示里的技能块(见
|
||||
# roundtable_seat_skills 模块说明)。无配置技能时原样返回。
|
||||
seat_message = new_message
|
||||
if role in ("seat", "seat_parallel"):
|
||||
seat_message = append_seat_skill_directive(agent_name, new_message)
|
||||
body = _run_payload(
|
||||
agent_name=agent_name,
|
||||
model_name=model_name,
|
||||
thread_id=thread_id,
|
||||
new_message=new_message,
|
||||
new_message=seat_message,
|
||||
excluded_tools=seat_excluded,
|
||||
skill_stop_names=merge_skill_stop_names(policy.skill_stop_names, skill_stop_names),
|
||||
# 席位执行模式覆盖 policy 的 thinking/reasoning(None → 回退 policy 默认);
|
||||
@@ -1899,6 +2015,13 @@ async def run_multi_agent_stream(request: Request, payload: dict = Body(...)) ->
|
||||
yield chunk if chunk.endswith("\n") else chunk + "\n"
|
||||
except HTTPException as exc:
|
||||
logger.warning("multi-agent stream HTTPException: %s %s", exc.status_code, exc.detail)
|
||||
await record_foreground_diag(
|
||||
request, stage=_stage_for_agent(agent_type, agent_name), level="error",
|
||||
event="run_http_error",
|
||||
message=f"「{agent_name}」运行失败 [{exc.status_code}]:{exc.detail}",
|
||||
detail={"status_code": exc.status_code, "detail": str(exc.detail), "agent_type": agent_type},
|
||||
agent_id=agent_name, agent_name=display_name or agent_name,
|
||||
)
|
||||
err_frame = {
|
||||
"status": "error",
|
||||
"content": f"[{exc.status_code}] {exc.detail}",
|
||||
@@ -1907,6 +2030,13 @@ async def run_multi_agent_stream(request: Request, payload: dict = Body(...)) ->
|
||||
yield f"data: {json.dumps(err_frame, ensure_ascii=False)}\n\n"
|
||||
except Exception as exc:
|
||||
logger.exception("multi-agent stream unexpected failure")
|
||||
await record_foreground_diag(
|
||||
request, stage=_stage_for_agent(agent_type, agent_name), level="error",
|
||||
event="run_unexpected",
|
||||
message=f"「{agent_name}」运行未预期异常:{exc!r}",
|
||||
detail={"type": type(exc).__name__, "error": str(exc), "agent_type": agent_type},
|
||||
agent_id=agent_name, agent_name=display_name or agent_name,
|
||||
)
|
||||
err_frame = {
|
||||
"status": "error",
|
||||
"content": f"unexpected: {exc!r}",
|
||||
|
||||
@@ -47,6 +47,7 @@ import httpx
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app.gateway.roundtable_diag import record_foreground_diag
|
||||
from app.gateway.routers._roundtable_seed import ensure_roundtable_functional_agents
|
||||
from app.gateway.routers.multi_agent import (
|
||||
_ROUNDTABLE_THREAD_METADATA,
|
||||
@@ -320,10 +321,20 @@ async def stream_recommend(request: Request, payload: dict = Body(...)) -> Strea
|
||||
|
||||
except HTTPException as exc:
|
||||
logger.warning("recommend stream HTTPException: %s %s", exc.status_code, exc.detail)
|
||||
await record_foreground_diag(
|
||||
request, stage="step2_recommend", level="error", event="recommend_http_error",
|
||||
message=f"智能体推荐失败 [{exc.status_code}]:{exc.detail}",
|
||||
detail={"status_code": exc.status_code, "detail": str(exc.detail)},
|
||||
)
|
||||
err = {"status": "error", "content": f"[{exc.status_code}] {exc.detail}", "picks": None}
|
||||
yield f"data: {json.dumps(err, ensure_ascii=False)}\n\n"
|
||||
except Exception as exc:
|
||||
logger.exception("recommend stream unexpected failure")
|
||||
await record_foreground_diag(
|
||||
request, stage="step2_recommend", level="error", event="recommend_unexpected",
|
||||
message=f"智能体推荐未预期异常:{exc!r}",
|
||||
detail={"type": type(exc).__name__, "error": str(exc)},
|
||||
)
|
||||
err = {"status": "error", "content": f"unexpected: {exc!r}", "picks": None}
|
||||
yield f"data: {json.dumps(err, ensure_ascii=False)}\n\n"
|
||||
|
||||
|
||||
@@ -43,6 +43,11 @@ class ChainSeatSchema(BaseModel):
|
||||
agent_id: str = Field(min_length=1, max_length=128)
|
||||
name: str = ""
|
||||
description: str = ""
|
||||
# 可选「席位模型覆盖」:该席位作为子智能体执行时用的 LLM model name(须是 config
|
||||
# models[] 里的一个 name)。None/"" = 不覆盖,运行时回退到全局所选模型。整条 seats
|
||||
# 数组以 JSON 透传持久化(见 persistence/roundtable_chains),故新增此字段无需迁移;
|
||||
# 仅需在此 schema 显式声明,否则 model_dump() 会把未知字段剥掉。
|
||||
model: str | None = Field(default=None, max_length=128)
|
||||
|
||||
|
||||
class ChainResponse(BaseModel):
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
"""圆桌会商诊断日志查询 API(管理员)。
|
||||
|
||||
会商全流程(第一/二/三步 + 后台执行器 + 进程内网关)各处的报错与关键事件落在
|
||||
``roundtable_diagnostics`` 表,本路由暴露**管理员**筛选查询 + facets + 清理。
|
||||
|
||||
Routes(prefix ``/api/roundtable-diagnostics``,全部 admin-gated)::
|
||||
GET "" 筛选 + 分页列出(scope/stage/level/event/job_id/draft_id/task_id/user_id/q/since/until)
|
||||
GET "/facets" 各筛选维度的 distinct 取值(下拉用)
|
||||
POST "/admin/cleanup" 按保留天数清理旧日志
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from deerflow.runtime.user_context import get_effective_user_id
|
||||
|
||||
router = APIRouter(prefix="/api/roundtable-diagnostics", tags=["roundtable-diagnostics"])
|
||||
|
||||
|
||||
def _get_store(request: Request):
|
||||
store = getattr(request.app.state, "roundtable_diagnostic_store", None)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="Roundtable diagnostic store not available")
|
||||
return store
|
||||
|
||||
|
||||
def _resolve_user_id(request: Request) -> str | None:
|
||||
user = getattr(request.state, "user", None)
|
||||
if user is not None:
|
||||
return str(user.id)
|
||||
try:
|
||||
return get_effective_user_id()
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
|
||||
|
||||
async def _require_admin(request: Request) -> None:
|
||||
"""放行 admin / auth-disabled,其余 403(与 roundtable_jobs / task_buttons 同套语义)。"""
|
||||
from app.gateway.deps import get_optional_user_from_request
|
||||
|
||||
user = await get_optional_user_from_request(request)
|
||||
if user is None:
|
||||
return
|
||||
if getattr(user, "system_role", None) != "admin":
|
||||
raise HTTPException(status_code=403, detail="会商诊断日志仅限管理员查看")
|
||||
|
||||
|
||||
class DiagnosticItem(BaseModel):
|
||||
id: str
|
||||
scope: str
|
||||
stage: str
|
||||
level: str
|
||||
event: str
|
||||
message: str | None = None
|
||||
detail: Any = None
|
||||
jobId: str | None = None
|
||||
draftId: str | None = None
|
||||
taskId: str | None = None
|
||||
userId: str | None = None
|
||||
agentId: str | None = None
|
||||
agentName: str | None = None
|
||||
cycle: int | None = None
|
||||
createdAt: str | None = None
|
||||
userName: str | None = None
|
||||
|
||||
|
||||
class DiagnosticListResponse(BaseModel):
|
||||
items: list[DiagnosticItem]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
class FacetsResponse(BaseModel):
|
||||
scope: list[str] = Field(default_factory=list)
|
||||
stage: list[str] = Field(default_factory=list)
|
||||
level: list[str] = Field(default_factory=list)
|
||||
event: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CleanupRequest(BaseModel):
|
||||
retention_days: int = Field(default=30, ge=0)
|
||||
batch_size: int = Field(default=1000, ge=1, le=10000)
|
||||
|
||||
|
||||
class CleanupResponse(BaseModel):
|
||||
deleted: int
|
||||
elapsed_seconds: float
|
||||
|
||||
|
||||
class ReportRequest(BaseModel):
|
||||
"""前端上报一条会商调用报错(任意登录用户可调,**非 admin**)。"""
|
||||
|
||||
stage: str = "client"
|
||||
event: str = ""
|
||||
level: str = "error"
|
||||
message: str | None = None
|
||||
detail: Any = None
|
||||
jobId: str | None = None
|
||||
draftId: str | None = None
|
||||
taskId: str | None = None
|
||||
agentId: str | None = None
|
||||
agentName: str | None = None
|
||||
cycle: int | None = None
|
||||
|
||||
|
||||
def _to_item(row: dict[str, Any]) -> DiagnosticItem:
|
||||
return DiagnosticItem(
|
||||
id=row["id"],
|
||||
scope=row.get("scope") or "",
|
||||
stage=row.get("stage") or "",
|
||||
level=row.get("level") or "",
|
||||
event=row.get("event") or "",
|
||||
message=row.get("message"),
|
||||
detail=row.get("detail"),
|
||||
jobId=row.get("job_id"),
|
||||
draftId=row.get("draft_id"),
|
||||
taskId=row.get("task_id"),
|
||||
userId=row.get("user_id"),
|
||||
agentId=row.get("agent_id"),
|
||||
agentName=row.get("agent_name"),
|
||||
cycle=row.get("cycle"),
|
||||
createdAt=row.get("created_at"),
|
||||
userName=row.get("_user_name"),
|
||||
)
|
||||
|
||||
|
||||
def _username_from_email(email: str | None, uid: str | None) -> str | None:
|
||||
"""展示用用户名:邮箱 ``@`` 前缀(与个人页一致);无邮箱回退到短 uid。"""
|
||||
if email:
|
||||
return email.split("@", 1)[0] if "@" in email else email
|
||||
if uid:
|
||||
return uid[:8]
|
||||
return None
|
||||
|
||||
|
||||
async def _enrich_user_names(request: Request, items: list[dict[str, Any]]) -> None:
|
||||
"""把每条诊断的 user_id 解析成用户名(邮箱 @ 前缀),写进 dict 的 ``_user_name``。
|
||||
|
||||
Best-effort + 批量去重缓存:解析不到就回退短 uid,绝不因此让列表查询失败。
|
||||
"""
|
||||
try:
|
||||
from app.gateway.deps import get_local_provider
|
||||
|
||||
provider = get_local_provider()
|
||||
except Exception: # noqa: BLE001
|
||||
provider = None
|
||||
cache: dict[str, str | None] = {}
|
||||
for it in items:
|
||||
uid = it.get("user_id")
|
||||
if not uid:
|
||||
it["_user_name"] = None
|
||||
continue
|
||||
if uid not in cache:
|
||||
email: str | None = None
|
||||
if provider is not None:
|
||||
try:
|
||||
user = await provider.get_user(uid)
|
||||
email = getattr(user, "email", None) if user else None
|
||||
except Exception: # noqa: BLE001
|
||||
email = None
|
||||
cache[uid] = _username_from_email(email, uid)
|
||||
it["_user_name"] = cache[uid]
|
||||
|
||||
|
||||
def _parse_dt(raw: str | None) -> datetime | None:
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
# 容忍前端传的 ``...Z`` 后缀(datetime.fromisoformat 在 3.11+ 接受,旧版不接受)。
|
||||
return datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
@router.get("", response_model=DiagnosticListResponse)
|
||||
async def list_diagnostics(
|
||||
request: Request,
|
||||
scope: str | None = None,
|
||||
stage: str | None = None,
|
||||
level: str | None = None,
|
||||
event: str | None = None,
|
||||
job_id: str | None = None,
|
||||
draft_id: str | None = None,
|
||||
task_id: str | None = None,
|
||||
user_id: str | None = None,
|
||||
q: str | None = None,
|
||||
since: str | None = None,
|
||||
until: str | None = None,
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
) -> DiagnosticListResponse:
|
||||
await _require_admin(request)
|
||||
store = _get_store(request)
|
||||
# scope 支持逗号分隔多值(前端「前台调用」= foreground,client 两种 scope 合并查)。
|
||||
scope_param: str | list[str] | None = None
|
||||
if scope:
|
||||
parts = [s.strip() for s in scope.split(",") if s.strip()]
|
||||
scope_param = parts if len(parts) > 1 else (parts[0] if parts else None)
|
||||
items, total = await store.list_logs(
|
||||
scope=scope_param,
|
||||
stage=stage or None,
|
||||
level=level or None,
|
||||
event=event or None,
|
||||
job_id=job_id or None,
|
||||
draft_id=draft_id or None,
|
||||
task_id=task_id or None,
|
||||
user_id=user_id or None,
|
||||
q=q or None,
|
||||
since=_parse_dt(since),
|
||||
until=_parse_dt(until),
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
await _enrich_user_names(request, items)
|
||||
return DiagnosticListResponse(items=[_to_item(r) for r in items], total=total, limit=limit, offset=offset)
|
||||
|
||||
|
||||
@router.post("/report")
|
||||
async def report_client_error(request: Request, body: ReportRequest) -> dict[str, bool]:
|
||||
"""前端把一次会商调用的报错上报上来(HTTP 502/网络失败/SSE error 帧等用户实际遇到的)。
|
||||
|
||||
**不 admin-gated**——任何登录用户在遇到报错时都应能上报;写入 scope="client" 以区分
|
||||
「服务端观测到的(foreground)」与「前端上报的(client)」。best-effort,写失败也返回 ok。
|
||||
"""
|
||||
store = getattr(request.app.state, "roundtable_diagnostic_store", None)
|
||||
if store is None:
|
||||
return {"ok": False}
|
||||
try:
|
||||
await store.record(
|
||||
scope="client",
|
||||
stage=body.stage or "client",
|
||||
level=body.level or "error",
|
||||
event=body.event or "",
|
||||
message=body.message,
|
||||
detail=body.detail,
|
||||
job_id=body.jobId,
|
||||
draft_id=body.draftId,
|
||||
task_id=body.taskId,
|
||||
user_id=_resolve_user_id(request),
|
||||
agent_id=body.agentId,
|
||||
agent_name=body.agentName,
|
||||
cycle=body.cycle,
|
||||
)
|
||||
except Exception: # noqa: BLE001 — 上报失败不影响前端
|
||||
return {"ok": False}
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/facets", response_model=FacetsResponse)
|
||||
async def get_facets(request: Request) -> FacetsResponse:
|
||||
await _require_admin(request)
|
||||
store = _get_store(request)
|
||||
facets = await store.facets()
|
||||
return FacetsResponse(**facets)
|
||||
|
||||
|
||||
@router.post("/admin/cleanup", response_model=CleanupResponse)
|
||||
async def cleanup(request: Request, body: CleanupRequest) -> CleanupResponse:
|
||||
await _require_admin(request)
|
||||
store = _get_store(request)
|
||||
cutoff = datetime.now(UTC) - timedelta(days=body.retention_days)
|
||||
t0 = time.perf_counter()
|
||||
deleted = 0
|
||||
while True:
|
||||
ids = await store.list_older_than(cutoff, limit=body.batch_size)
|
||||
if not ids:
|
||||
break
|
||||
deleted += await store.delete_by_ids(ids)
|
||||
if len(ids) < body.batch_size:
|
||||
break
|
||||
return CleanupResponse(deleted=deleted, elapsed_seconds=round(time.perf_counter() - t0, 3))
|
||||
@@ -10,32 +10,32 @@ gateway:
|
||||
port: 8001
|
||||
|
||||
models:
|
||||
- name: deepseek-chat
|
||||
display_name: DeepSeek Chat
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: deepseek-chat
|
||||
api_key: sk-35345154983047aba9e12faa86c5f62c
|
||||
base_url: https://api.deepseek.com
|
||||
request_timeout: 600.0
|
||||
max_retries: 2
|
||||
max_tokens: 4096
|
||||
temperature: 1.0
|
||||
supports_vision: false
|
||||
supports_thinking: true
|
||||
|
||||
# - name: zai-org/GLM-5-FP8
|
||||
# display_name: zai-org/GLM-5-FP8
|
||||
# - name: deepseek-chat
|
||||
# display_name: DeepSeek Chat
|
||||
# use: langchain_openai:ChatOpenAI
|
||||
# model: zai-org/GLM-5-FP8
|
||||
# api_key: modalresearch_gWu46FDpVLYZWPVEA3b_2fq8Jw2UJiKMgEm_Rxmn1SA
|
||||
# base_url: https://api.us-west-2.modal.direct/v1
|
||||
# model: deepseek-chat
|
||||
# api_key: sk-35345154983047aba9e12faa86c5f62c
|
||||
# base_url: https://api.deepseek.com
|
||||
# request_timeout: 600.0
|
||||
# max_retries: 2
|
||||
# max_tokens: 4096
|
||||
# temperature: 1.0
|
||||
# supports_vision: true
|
||||
# supports_vision: false
|
||||
# supports_thinking: true
|
||||
|
||||
- name: zai-org/GLM-5-FP8
|
||||
display_name: zai-org/GLM-5-FP8
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: zai-org/GLM-5-FP8
|
||||
api_key: modalresearch_gWu46FDpVLYZWPVEA3b_2fq8Jw2UJiKMgEm_Rxmn1SA
|
||||
base_url: https://api.us-west-2.modal.direct/v1
|
||||
request_timeout: 600.0
|
||||
max_retries: 2
|
||||
max_tokens: 4096
|
||||
temperature: 1.0
|
||||
supports_vision: true
|
||||
supports_thinking: true
|
||||
|
||||
# - name: deepseek-v4-flash
|
||||
# display_name: DeepSeek V4 Flash
|
||||
# use: langchain_openai:ChatOpenAI
|
||||
|
||||
+57
@@ -101,6 +101,11 @@ async def run_orchestration(
|
||||
intent = (intent_text or seed_message or "").strip()
|
||||
try:
|
||||
await progress.begin()
|
||||
await progress.diag(
|
||||
stage="step2_orchestration", level="info", event="begin",
|
||||
message=f"会商编排启动(mode={mode},{len(agents)} 个席位)",
|
||||
detail={"mode": mode, "agents": [a.agent_id for a in agents]},
|
||||
)
|
||||
dag_plan = parse_dag_plan(plan) if mode == "dag" else None
|
||||
if mode == "dag" and dag_plan is not None:
|
||||
outcome = await _run_dag(gateway, progress, agents, thread_ids, coordinator_name, model, seed_message, intent, dag_plan)
|
||||
@@ -112,6 +117,11 @@ async def run_orchestration(
|
||||
|
||||
if outcome.kind == "clarification":
|
||||
question = outcome.question or "总控需要补充信息,请补充后继续。"
|
||||
await progress.diag(
|
||||
stage="step2_leader", level="info", event="clarification",
|
||||
message="总控发起澄清,会商暂停等待补充信息",
|
||||
detail={"question": question[:500]},
|
||||
)
|
||||
await progress.awaiting_input(question)
|
||||
return OrchestrationResult(
|
||||
status="awaiting_input",
|
||||
@@ -120,8 +130,17 @@ async def run_orchestration(
|
||||
dialogues=progress.dialogues(),
|
||||
)
|
||||
|
||||
if outcome.kind == "max_cycles":
|
||||
await progress.diag(
|
||||
stage="step2_orchestration", level="warning", event="max_cycles",
|
||||
message=f"达到最大研讨轮次({MAX_CYCLES}),强制收口——可能未真正达成共识",
|
||||
)
|
||||
# consensus 或 max_cycles:均收口。Step3(总结报告 + 结果绘制)按 enable_report 开关执行。
|
||||
await progress.consensus(100)
|
||||
await progress.diag(
|
||||
stage="step2_orchestration", level="info", event="consensus",
|
||||
message="研讨收口,进入第三步" if enable_report else "研讨收口(未启用第三步报告)",
|
||||
)
|
||||
step3 = None
|
||||
if enable_report:
|
||||
step3 = await _run_step3(
|
||||
@@ -144,6 +163,11 @@ async def run_orchestration(
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — 顶层兜底:标记 error 并返回
|
||||
logger.exception("roundtable orchestration failed")
|
||||
await progress.diag(
|
||||
stage="step2_orchestration", level="error", event="orchestration_crash",
|
||||
message=f"会商编排顶层异常:{exc}",
|
||||
detail={"type": type(exc).__name__, "error": str(exc)},
|
||||
)
|
||||
await progress.error(str(exc))
|
||||
return OrchestrationResult(status="error", error=str(exc), dialogues=progress.dialogues())
|
||||
|
||||
@@ -229,6 +253,12 @@ async def _run_recommend(
|
||||
seat_msg = _seat_message(intent_text, deliveries, name_by_id, task)
|
||||
seat = await gateway.run_seat(thread_ids=thread_ids, agent_id=agent_id, task=seat_msg, model=model)
|
||||
deliveries[agent_id] = seat.final_text
|
||||
if not (seat.final_text or "").strip():
|
||||
await progress.diag(
|
||||
stage="step2_seat", level="warning", event="seat_empty_delivery",
|
||||
message=f"席位「{name_by_id.get(agent_id, agent_id)}」本轮交付为空(可能模型未产出或被截断)",
|
||||
agent_id=agent_id, agent_name=name_by_id.get(agent_id, agent_id), cycle=cycle,
|
||||
)
|
||||
await progress.add_seat_dialogue(
|
||||
agent_id, name_by_id.get(agent_id, agent_id), seat.final_text, cycle,
|
||||
tool_calls=seat.tool_calls, thread_id=thread_ids.get(agent_id),
|
||||
@@ -298,6 +328,12 @@ async def _run_chain(
|
||||
seat_msg = _seat_message(intent_text, deliveries, name_by_id, task)
|
||||
seat_turn = await gateway.run_seat(thread_ids=thread_ids, agent_id=seat.agent_id, task=seat_msg, model=model)
|
||||
deliveries[seat.agent_id] = seat_turn.final_text
|
||||
if not (seat_turn.final_text or "").strip():
|
||||
await progress.diag(
|
||||
stage="step2_seat", level="warning", event="seat_empty_delivery",
|
||||
message=f"席位「{seat.name}」本轮交付为空(可能模型未产出或被截断)",
|
||||
agent_id=seat.agent_id, agent_name=seat.name, cycle=i + 1,
|
||||
)
|
||||
await progress.add_seat_dialogue(
|
||||
seat.agent_id, seat.name, seat_turn.final_text, i + 1,
|
||||
tool_calls=seat_turn.tool_calls, thread_id=thread_ids.get(seat.agent_id),
|
||||
@@ -398,6 +434,12 @@ async def _run_dag(
|
||||
seat_msg = _seat_message(intent_text, deliveries, name_by_id, task)
|
||||
seat_turn = await gateway.run_seat(thread_ids=thread_ids, agent_id=seat.agent_id, task=seat_msg, model=model)
|
||||
deliveries[seat.agent_id] = seat_turn.final_text
|
||||
if not (seat_turn.final_text or "").strip():
|
||||
await progress.diag(
|
||||
stage="step2_seat", level="warning", event="seat_empty_delivery",
|
||||
message=f"席位「{seat.name}」本轮交付为空(可能模型未产出或被截断)",
|
||||
agent_id=seat.agent_id, agent_name=seat.name, cycle=i + 1,
|
||||
)
|
||||
await progress.add_seat_dialogue(
|
||||
seat.agent_id, seat.name, seat_turn.final_text, i + 1,
|
||||
tool_calls=seat_turn.tool_calls, thread_id=thread_ids.get(seat.agent_id),
|
||||
@@ -477,6 +519,11 @@ async def _run_step3(
|
||||
}
|
||||
except Exception as exc: # noqa: BLE001 — 总结失败不阻塞结果绘制
|
||||
logger.exception("roundtable step3 summary failed")
|
||||
await progress.diag(
|
||||
stage="step3_summary", level="error", event="summary_failed",
|
||||
message=f"第三步「方案总结报告」生成失败:{exc}",
|
||||
detail={"type": type(exc).__name__, "error": str(exc)},
|
||||
)
|
||||
errors.append(exc)
|
||||
|
||||
async def _report_task() -> None:
|
||||
@@ -494,6 +541,11 @@ async def _run_step3(
|
||||
step3.update(report)
|
||||
except Exception as exc: # noqa: BLE001 — 绘制失败时保留已写出的总结报告
|
||||
logger.exception("roundtable step3 report failed")
|
||||
await progress.diag(
|
||||
stage="step3_report", level="error", event="report_failed",
|
||||
message=f"第三步「结果绘制」(方案总览.html) 生成失败:{exc}",
|
||||
detail={"type": type(exc).__name__, "error": str(exc)},
|
||||
)
|
||||
errors.append(exc)
|
||||
|
||||
async def _dashboard_task() -> None:
|
||||
@@ -510,6 +562,11 @@ async def _run_step3(
|
||||
step3["dashboardJson"] = data_json
|
||||
except Exception as exc: # noqa: BLE001 — 大屏失败不拖垮另两段
|
||||
logger.exception("roundtable step3 dashboard failed")
|
||||
await progress.diag(
|
||||
stage="step3_dashboard", level="error", event="dashboard_failed",
|
||||
message=f"第三步「大屏数据」(report-json) 生成失败:{exc}",
|
||||
detail={"type": type(exc).__name__, "error": str(exc)},
|
||||
)
|
||||
errors.append(exc)
|
||||
|
||||
await asyncio.gather(_summary_task(), _report_task(), _dashboard_task())
|
||||
|
||||
+33
@@ -53,11 +53,15 @@ class JobProgressWriter:
|
||||
agents: list[SeatRef],
|
||||
event_sink: EventSink = None,
|
||||
on_dialogue: Any | None = None,
|
||||
recorder: Any | None = None,
|
||||
) -> None:
|
||||
self._store = store
|
||||
self._job_id = job_id
|
||||
self._agents = list(agents)
|
||||
self._event_sink = event_sink
|
||||
# 诊断日志记录器(DiagnosticsRecorder,已绑定本作业上下文)。引擎经
|
||||
# ``await progress.diag(...)`` 在每个报错 / 关键转折处落一条事件。None = 不记录。
|
||||
self._recorder = recorder
|
||||
# 每轮 leader/seat 模型返回后回调(dialogues, consensus)—— 执行器据此把对话
|
||||
# 写进草稿(和手动跑同一存取路径)。
|
||||
self._on_dialogue = on_dialogue
|
||||
@@ -105,6 +109,35 @@ class JobProgressWriter:
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("roundtable job progress event sink failed", exc_info=True)
|
||||
|
||||
async def diag(
|
||||
self,
|
||||
*,
|
||||
stage: str,
|
||||
level: str,
|
||||
event: str,
|
||||
message: str | None = None,
|
||||
detail: Any = None,
|
||||
agent_id: str | None = None,
|
||||
agent_name: str | None = None,
|
||||
cycle: int | None = None,
|
||||
) -> None:
|
||||
"""落一条诊断日志(best-effort;无 recorder 时 no-op)。"""
|
||||
if self._recorder is None:
|
||||
return
|
||||
try:
|
||||
await self._recorder.record(
|
||||
stage=stage,
|
||||
level=level,
|
||||
event=event,
|
||||
message=message,
|
||||
detail=detail,
|
||||
agent_id=agent_id,
|
||||
agent_name=agent_name,
|
||||
cycle=cycle,
|
||||
)
|
||||
except Exception: # noqa: BLE001 — 诊断日志绝不打断编排
|
||||
logger.debug("roundtable diag record failed", exc_info=True)
|
||||
|
||||
# ── 语义方法(引擎调用)─────────────────────────────────────────────────
|
||||
|
||||
async def begin(self) -> None:
|
||||
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
"""Create roundtable_diagnostics table.
|
||||
|
||||
会商全流程诊断日志(第一/二/三步 + 后台执行器 + 进程内网关各处报错与关键事件),
|
||||
管理员诊断页的数据源。全局/共享,不按 user 分权。
|
||||
|
||||
On a brand-new DB the table is created by ``create_all``; this revision patches
|
||||
existing DBs (alembic auto-upgrade is disabled at runtime — see
|
||||
``persistence/engine.py`` — so operators run this / the column_additions SQL).
|
||||
|
||||
Revision ID: 20260619_01
|
||||
Revises: 20260618_01
|
||||
Create Date: 2026-06-19
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
from sqlalchemy.dialects.mysql import LONGTEXT
|
||||
|
||||
|
||||
revision = "20260619_01"
|
||||
down_revision = "20260618_01"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _has_table(table_name: str) -> bool:
|
||||
inspector = inspect(op.get_bind())
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if _has_table("roundtable_diagnostics"):
|
||||
return
|
||||
|
||||
long_text = LONGTEXT() if op.get_bind().dialect.name == "mysql" else sa.Text()
|
||||
|
||||
op.create_table(
|
||||
"roundtable_diagnostics",
|
||||
sa.Column("id", sa.String(64), primary_key=True),
|
||||
sa.Column("scope", sa.String(16), nullable=False, server_default="background"),
|
||||
sa.Column("stage", sa.String(32), nullable=False, server_default="job"),
|
||||
sa.Column("level", sa.String(16), nullable=False, server_default="info"),
|
||||
sa.Column("event", sa.String(64), nullable=False, server_default=""),
|
||||
sa.Column("message", sa.Text(), nullable=True),
|
||||
sa.Column("detail", long_text, nullable=True),
|
||||
sa.Column("job_id", sa.String(64), nullable=True),
|
||||
sa.Column("draft_id", sa.String(64), nullable=True),
|
||||
sa.Column("task_id", sa.String(128), nullable=True),
|
||||
sa.Column("user_id", sa.String(64), nullable=True),
|
||||
sa.Column("agent_id", sa.String(128), nullable=True),
|
||||
sa.Column("agent_name", sa.String(255), nullable=True),
|
||||
sa.Column("cycle", sa.Integer(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
)
|
||||
op.create_index("ix_roundtable_diagnostics_scope", "roundtable_diagnostics", ["scope"])
|
||||
op.create_index("ix_roundtable_diagnostics_stage", "roundtable_diagnostics", ["stage"])
|
||||
op.create_index("ix_roundtable_diagnostics_level", "roundtable_diagnostics", ["level"])
|
||||
op.create_index("ix_roundtable_diagnostics_job_id", "roundtable_diagnostics", ["job_id"])
|
||||
op.create_index("ix_roundtable_diagnostics_draft_id", "roundtable_diagnostics", ["draft_id"])
|
||||
op.create_index("ix_roundtable_diagnostics_task_id", "roundtable_diagnostics", ["task_id"])
|
||||
op.create_index("ix_roundtable_diagnostics_user_id", "roundtable_diagnostics", ["user_id"])
|
||||
op.create_index("ix_roundtable_diagnostics_created_at", "roundtable_diagnostics", ["created_at"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if _has_table("roundtable_diagnostics"):
|
||||
op.drop_table("roundtable_diagnostics")
|
||||
+2
@@ -54,6 +54,7 @@ from deerflow.persistence.roundtable_drafts.model import (
|
||||
RoundtableDraftRow,
|
||||
RoundtableRecommendHistoryRow,
|
||||
)
|
||||
from deerflow.persistence.roundtable_diagnostics.model import RoundtableDiagnosticRow
|
||||
from deerflow.persistence.roundtable_jobs.model import RoundtableJobRow
|
||||
from deerflow.persistence.roundtable_task_drafts.model import RoundtableTaskDraftRow
|
||||
from deerflow.persistence.run.model import RunRow
|
||||
@@ -108,6 +109,7 @@ __all__ = [
|
||||
"RoundtableChainRow",
|
||||
"RoundtableDraftRow",
|
||||
"RoundtableDraftShareRow",
|
||||
"RoundtableDiagnosticRow",
|
||||
"RoundtableJobRow",
|
||||
"RoundtableRecommendHistoryRow",
|
||||
"RoundtableTaskDraftRow",
|
||||
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
"""圆桌会商诊断日志持久化。
|
||||
|
||||
对外暴露:
|
||||
- ``make_roundtable_diagnostic_store(sf)`` —— 建仓储(deps.py 启动时调)。
|
||||
- ``register_default_store`` / ``get_default_store`` —— 进程级默认仓储(跨 app/harness 边界,
|
||||
与 ``roundtable_jobs`` 同套做法:harness 侧编排引擎拿不到 FastAPI ``app.state``)。
|
||||
- ``record_diagnostic(...)`` —— **best-effort** 写一条事件(store 为 None / 出错均静默吞掉,
|
||||
诊断日志本身绝不能反过来打断会商)。
|
||||
- ``DiagnosticsRecorder`` —— 绑定一次作业上下文(scope/job_id/draft_id/task_id/user_id)的
|
||||
便捷记录器,引擎 / 网关 / 执行器逐处调用 ``await recorder.record(stage=..., level=..., ...)``。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from deerflow.persistence.roundtable_diagnostics.model import RoundtableDiagnosticRow
|
||||
from deerflow.persistence.roundtable_diagnostics.sql import RoundtableDiagnosticRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def make_roundtable_diagnostic_store(session_factory) -> RoundtableDiagnosticRepository | None:
|
||||
if session_factory is None:
|
||||
return None
|
||||
return RoundtableDiagnosticRepository(session_factory)
|
||||
|
||||
|
||||
# ── 进程级默认仓储(跨 app/harness 边界)──────────────────────────────────────────
|
||||
_default_store: RoundtableDiagnosticRepository | None = None
|
||||
|
||||
|
||||
def register_default_store(store: RoundtableDiagnosticRepository | None) -> None:
|
||||
global _default_store
|
||||
_default_store = store
|
||||
|
||||
|
||||
def get_default_store() -> RoundtableDiagnosticRepository | None:
|
||||
return _default_store
|
||||
|
||||
|
||||
async def record_diagnostic(store: RoundtableDiagnosticRepository | None = None, **fields: Any) -> None:
|
||||
"""Best-effort 写一条诊断事件。``store`` 缺省用进程级默认仓储。任何异常静默吞掉。"""
|
||||
repo = store or _default_store
|
||||
if repo is None:
|
||||
return
|
||||
try:
|
||||
await repo.record(**fields)
|
||||
except Exception: # noqa: BLE001 — 诊断日志写失败绝不打断会商
|
||||
logger.debug("roundtable diagnostic record failed", exc_info=True)
|
||||
|
||||
|
||||
class DiagnosticsRecorder:
|
||||
"""绑定一次会商上下文的记录器。所有 ``record`` 均 best-effort。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
store: RoundtableDiagnosticRepository | None = None,
|
||||
*,
|
||||
scope: str = "background",
|
||||
job_id: str | None = None,
|
||||
draft_id: str | None = None,
|
||||
task_id: str | None = None,
|
||||
user_id: str | None = None,
|
||||
) -> None:
|
||||
self._store = store
|
||||
self._scope = scope
|
||||
self._job_id = job_id
|
||||
self._draft_id = draft_id
|
||||
self._task_id = task_id
|
||||
self._user_id = user_id
|
||||
|
||||
async def record(
|
||||
self,
|
||||
*,
|
||||
stage: str,
|
||||
level: str,
|
||||
event: str,
|
||||
message: str | None = None,
|
||||
detail: Any = None,
|
||||
agent_id: str | None = None,
|
||||
agent_name: str | None = None,
|
||||
cycle: int | None = None,
|
||||
) -> None:
|
||||
await record_diagnostic(
|
||||
self._store,
|
||||
scope=self._scope,
|
||||
stage=stage,
|
||||
level=level,
|
||||
event=event,
|
||||
message=message,
|
||||
detail=detail,
|
||||
job_id=self._job_id,
|
||||
draft_id=self._draft_id,
|
||||
task_id=self._task_id,
|
||||
user_id=self._user_id,
|
||||
agent_id=agent_id,
|
||||
agent_name=agent_name,
|
||||
cycle=cycle,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"RoundtableDiagnosticRow",
|
||||
"RoundtableDiagnosticRepository",
|
||||
"DiagnosticsRecorder",
|
||||
"make_roundtable_diagnostic_store",
|
||||
"register_default_store",
|
||||
"get_default_store",
|
||||
"record_diagnostic",
|
||||
]
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
"""圆桌会商「诊断日志」ORM 模型。
|
||||
|
||||
记录会商全流程(第一步意图 / 第二步研讨 / 第三步报告 + 后台挂起执行器 / 进程内网关)
|
||||
各处可能发生的报错与关键事件,供管理员在前端诊断页筛选排查。
|
||||
|
||||
设计要点:
|
||||
- **全局/共享**:不按 user 分权(``user_id`` 仅审计),管理员读全量;写入开放(best-effort)。
|
||||
- 一行 = 一条事件。``detail`` 用 ``PortableLongText`` 存结构化补充信息(堆栈尾、上下文)。
|
||||
- 按 ``created_at`` / ``stage`` / ``level`` / ``job_id`` / ``task_id`` 建索引,支撑筛选。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from deerflow.persistence.base import Base
|
||||
from deerflow.persistence.types import BeijingDateTime, PortableLongText
|
||||
|
||||
|
||||
class RoundtableDiagnosticRow(Base):
|
||||
__tablename__ = "roundtable_diagnostics"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
# background(后台挂起)| foreground(前台实时会商)。
|
||||
scope: Mapped[str] = mapped_column(String(16), nullable=False, default="background", index=True)
|
||||
# 阶段:step1_intent / step2_init / step2_leader / step2_seat / step2_orchestration /
|
||||
# step3_summary / step3_report / step3_dashboard / step3_structure / upstream / job。
|
||||
stage: Mapped[str] = mapped_column(String(32), nullable=False, default="job", index=True)
|
||||
# 级别:info | warning | error。
|
||||
level: Mapped[str] = mapped_column(String(16), nullable=False, default="info", index=True)
|
||||
# 短事件码(如 seat_turn_timeout / model_error / report_truncated / dispatch_invalid)。
|
||||
event: Mapped[str] = mapped_column(String(64), nullable=False, default="")
|
||||
# 人类可读的报错说明(中文)。
|
||||
message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# 结构化补充:堆栈尾、上下文、原始 body 片段等。
|
||||
detail: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
|
||||
|
||||
# 关联上下文(均可空)。
|
||||
job_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
draft_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
task_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
agent_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
agent_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
cycle: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC), index=True
|
||||
)
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
"""SQLAlchemy-backed 圆桌会商诊断日志 store。
|
||||
|
||||
写入(``record``)best-effort、全局共享;读取(``list_logs`` / ``count`` / ``facets``)
|
||||
带多维筛选,供管理员诊断页用。``detail`` 以 JSON 序列化进 ``PortableLongText``。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from deerflow.persistence.roundtable_diagnostics.model import RoundtableDiagnosticRow
|
||||
|
||||
|
||||
def _dumps(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
try:
|
||||
return json.dumps(value, ensure_ascii=False, default=str)
|
||||
except Exception:
|
||||
return str(value)
|
||||
|
||||
|
||||
def _loads(value: Any) -> Any:
|
||||
if not isinstance(value, str) or not value:
|
||||
return value or None
|
||||
try:
|
||||
return json.loads(value)
|
||||
except Exception:
|
||||
return value # 非 JSON 文本(如纯字符串 detail)原样返回
|
||||
|
||||
|
||||
class RoundtableDiagnosticRepository:
|
||||
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||
self._sf = session_factory
|
||||
|
||||
@staticmethod
|
||||
def _to_dict(row: RoundtableDiagnosticRow) -> dict[str, Any]:
|
||||
return {
|
||||
"id": row.id,
|
||||
"scope": row.scope,
|
||||
"stage": row.stage,
|
||||
"level": row.level,
|
||||
"event": row.event,
|
||||
"message": row.message,
|
||||
"detail": _loads(row.detail),
|
||||
"job_id": row.job_id,
|
||||
"draft_id": row.draft_id,
|
||||
"task_id": row.task_id,
|
||||
"user_id": row.user_id,
|
||||
"agent_id": row.agent_id,
|
||||
"agent_name": row.agent_name,
|
||||
"cycle": row.cycle,
|
||||
"created_at": row.created_at.isoformat() if isinstance(row.created_at, datetime) else row.created_at,
|
||||
}
|
||||
|
||||
async def record(
|
||||
self,
|
||||
*,
|
||||
scope: str = "background",
|
||||
stage: str = "job",
|
||||
level: str = "info",
|
||||
event: str = "",
|
||||
message: str | None = None,
|
||||
detail: Any = None,
|
||||
job_id: str | None = None,
|
||||
draft_id: str | None = None,
|
||||
task_id: str | None = None,
|
||||
user_id: str | None = None,
|
||||
agent_id: str | None = None,
|
||||
agent_name: str | None = None,
|
||||
cycle: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""插入一条诊断事件,返回序列化 dict。"""
|
||||
row = RoundtableDiagnosticRow(
|
||||
id=uuid4().hex,
|
||||
scope=(scope or "background")[:16],
|
||||
stage=(stage or "job")[:32],
|
||||
level=(level or "info")[:16],
|
||||
event=(event or "")[:64],
|
||||
message=message,
|
||||
detail=_dumps(detail),
|
||||
job_id=job_id,
|
||||
draft_id=draft_id,
|
||||
task_id=task_id,
|
||||
user_id=user_id,
|
||||
agent_id=(agent_id or None) if agent_id is None else str(agent_id)[:128],
|
||||
agent_name=(agent_name or None) if agent_name is None else str(agent_name)[:255],
|
||||
cycle=cycle,
|
||||
)
|
||||
async with self._sf() as session:
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return self._to_dict(row)
|
||||
|
||||
def _apply_filters(
|
||||
self,
|
||||
stmt,
|
||||
*,
|
||||
scope: str | list[str] | None,
|
||||
stage: str | None,
|
||||
level: str | None,
|
||||
event: str | None,
|
||||
job_id: str | None,
|
||||
draft_id: str | None,
|
||||
task_id: str | None,
|
||||
user_id: str | None,
|
||||
q: str | None,
|
||||
since: datetime | None,
|
||||
until: datetime | None,
|
||||
):
|
||||
R = RoundtableDiagnosticRow
|
||||
if scope:
|
||||
if isinstance(scope, (list, tuple, set)):
|
||||
vals = [s for s in scope if s]
|
||||
if vals:
|
||||
stmt = stmt.where(R.scope.in_(vals))
|
||||
else:
|
||||
stmt = stmt.where(R.scope == scope)
|
||||
if stage:
|
||||
stmt = stmt.where(R.stage == stage)
|
||||
if level:
|
||||
stmt = stmt.where(R.level == level)
|
||||
if event:
|
||||
stmt = stmt.where(R.event == event)
|
||||
if job_id:
|
||||
stmt = stmt.where(R.job_id == job_id)
|
||||
if draft_id:
|
||||
stmt = stmt.where(R.draft_id == draft_id)
|
||||
if task_id:
|
||||
stmt = stmt.where(R.task_id == task_id)
|
||||
if user_id:
|
||||
stmt = stmt.where(R.user_id == user_id)
|
||||
if q:
|
||||
like = f"%{q}%"
|
||||
stmt = stmt.where(R.message.ilike(like))
|
||||
if since is not None:
|
||||
stmt = stmt.where(R.created_at >= since)
|
||||
if until is not None:
|
||||
stmt = stmt.where(R.created_at <= until)
|
||||
return stmt
|
||||
|
||||
async def list_logs(
|
||||
self,
|
||||
*,
|
||||
scope: str | list[str] | None = None,
|
||||
stage: str | None = None,
|
||||
level: str | None = None,
|
||||
event: str | None = None,
|
||||
job_id: str | None = None,
|
||||
draft_id: str | None = None,
|
||||
task_id: str | None = None,
|
||||
user_id: str | None = None,
|
||||
q: str | None = None,
|
||||
since: datetime | None = None,
|
||||
until: datetime | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> tuple[list[dict[str, Any]], int]:
|
||||
"""筛选 + 分页(newest-first)。返回 ``(items, total)``。"""
|
||||
R = RoundtableDiagnosticRow
|
||||
base = self._apply_filters(
|
||||
select(R),
|
||||
scope=scope, stage=stage, level=level, event=event,
|
||||
job_id=job_id, draft_id=draft_id, task_id=task_id, user_id=user_id,
|
||||
q=q, since=since, until=until,
|
||||
)
|
||||
count_stmt = self._apply_filters(
|
||||
select(func.count()).select_from(R),
|
||||
scope=scope, stage=stage, level=level, event=event,
|
||||
job_id=job_id, draft_id=draft_id, task_id=task_id, user_id=user_id,
|
||||
q=q, since=since, until=until,
|
||||
)
|
||||
page_stmt = base.order_by(R.created_at.desc(), R.id.desc()).limit(limit).offset(offset)
|
||||
async with self._sf() as session:
|
||||
total = int((await session.execute(count_stmt)).scalar() or 0)
|
||||
rows = (await session.execute(page_stmt)).scalars().all()
|
||||
return [self._to_dict(r) for r in rows], total
|
||||
|
||||
async def facets(self) -> dict[str, list[str]]:
|
||||
"""返回各筛选维度的 distinct 取值(供前端下拉),供筛选 UI 用。"""
|
||||
R = RoundtableDiagnosticRow
|
||||
out: dict[str, list[str]] = {}
|
||||
async with self._sf() as session:
|
||||
for key, col in (("scope", R.scope), ("stage", R.stage), ("level", R.level), ("event", R.event)):
|
||||
rows = (await session.execute(select(col).distinct())).scalars().all()
|
||||
out[key] = sorted([str(v) for v in rows if v])
|
||||
return out
|
||||
|
||||
async def list_older_than(self, cutoff: datetime, *, limit: int = 1000) -> list[str]:
|
||||
R = RoundtableDiagnosticRow
|
||||
stmt = select(R.id).where(R.created_at < cutoff).limit(limit)
|
||||
async with self._sf() as session:
|
||||
return [str(v) for v in (await session.execute(stmt)).scalars().all()]
|
||||
|
||||
async def delete_by_ids(self, ids: list[str]) -> int:
|
||||
if not ids:
|
||||
return 0
|
||||
R = RoundtableDiagnosticRow
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(delete(R).where(R.id.in_(ids)))
|
||||
await session.commit()
|
||||
return int(result.rowcount or 0)
|
||||
+7
-2
@@ -97,8 +97,13 @@ class ThreadMetaRepository(ThreadMetaStore):
|
||||
row = await session.get(ThreadMetaRow, thread_id)
|
||||
if row is None:
|
||||
return not require_existing
|
||||
# 任务深链对话(metadata.taskId)全局共享、不区分用户:任何登录用户都可读写/续聊。
|
||||
if (row.metadata_json or {}).get("taskId"):
|
||||
md = row.metadata_json or {}
|
||||
# 全局共享、不区分用户(任何登录用户都可读写/续聊):
|
||||
# ① 任务深链对话(metadata.taskId);
|
||||
# ② 圆桌会商系统线程(metadata.thread_type=="roundtable")——其 Step2/Step3
|
||||
# 沙箱产物(md/流程图/报告)需让历史草稿或其他账号打开同一会商时也能查看,
|
||||
# 与任务深链对话一致(线程 id 仅存在于草稿中,不会被无关账号发现)。
|
||||
if md.get("taskId") or md.get("thread_type") == "roundtable":
|
||||
return True
|
||||
if row.user_id is None:
|
||||
return True
|
||||
|
||||
@@ -78,11 +78,34 @@ async def _fetch_task_owner(thread_id: str) -> str | None:
|
||||
return None
|
||||
metadata = row.get("metadata") or {}
|
||||
owner = row.get("user_id")
|
||||
if metadata.get("taskId") and owner:
|
||||
if owner and _is_creator_shared_thread(metadata):
|
||||
return str(owner)
|
||||
return None
|
||||
|
||||
|
||||
def _is_creator_shared_thread(metadata: dict) -> bool:
|
||||
"""True iff a thread's files should resolve to its **creator** (shared bucket).
|
||||
|
||||
Two kinds of threads are shared to one stable directory rather than the
|
||||
current login:
|
||||
|
||||
- **Task-deeplink conversations** (``metadata.taskId``) — rwfx / 3qfx / xdfx,
|
||||
explicitly shared across all users;
|
||||
- **Roundtable 会商 system threads** (``metadata.thread_type == "roundtable"``)
|
||||
— the Step 2 seats / coordinator and Step 3 report / summary / dashboard /
|
||||
structure threads. Their sandbox artifacts (md / 流程图 / 报告) live under the
|
||||
creator's directory, but a历史草稿 reopened later — or a taskId会商 draft opened
|
||||
by **another account** — must still be able to view them. Resolving to the
|
||||
creator keeps the *file* layer consistent with the *draft-sharing* layer.
|
||||
|
||||
Roundtable thread ids are random UUIDs surfaced only inside the owner's draft
|
||||
(personal drafts are owner-scoped; taskId drafts are already shared), so this
|
||||
does not let an unrelated account *discover* another user's 会商 files — it only
|
||||
lets a caller that already holds the thread id read the creator's bucket.
|
||||
"""
|
||||
return bool(metadata.get("taskId")) or metadata.get("thread_type") == "roundtable"
|
||||
|
||||
|
||||
def _run_coro_blocking(coro):
|
||||
"""Run an async coroutine to completion from sync code.
|
||||
|
||||
|
||||
Reference in new issue
Block a user