Merge branch 'dhk-1' into 'master'

feat(layout): 简洁模式右上角加换肤 + 界面模式下拉切换

See merge request knowledge-base-bj/deerflow-server!142
This commit is contained in:
董宏凯 committed 2026-06-22 11:17:40 +00:00
commit 57ffe1a706
28 files changed
+1004 -267

No files matched your search

+1 -1
View File
@@ -8,7 +8,7 @@ VITE_USE_REAL_DATA=true
VITE_MOCK_COP_TASK_DETAIL=true
# 研判情报报告(大屏绘制智能体深链 selectSqReport)数据源:true 走本地样例假数据(真实接口未就绪时调试用);联调通过后置 false。
VITE_MOCK_SQ_REPORT=true
VITE_MOCK_SQ_REPORT=false
# 深链任务详情(cop-task-detail:任务名称/描述)数据源:true 走本地假数据(真实接口未就绪时调试用);联调通过后置 false。
VITE_MOCK_COP_TASK_DETAIL=true
@@ -0,0 +1,186 @@
"use client";
import { Check, LayoutIcon, PanelLeftIcon, Shirt } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useTheme, type ThemeType } from "@/contexts/ThemeContext";
import { useLayoutMode } from "@/core/system-settings/hooks";
import { cn } from "@/lib/utils";
/**
* 换肤 / 界面模式 顶栏控件。深色顶栏(Header)与简洁模式页面级顶栏(ChatPage 等)共用,
* 通过 `tone` 适配不同底色:
* - "banner-dark" / "banner-light":Header 落在主题 banner 上(白字 / 浅色 hover)。
* - "content":简洁模式聊天页浅底顶栏,走 foreground / muted-foreground。
*/
export type AppearanceControlTone = "banner-dark" | "banner-light" | "content";
// 换肤色板:红色=红白主题(light),蓝色=浅蓝主题(lightblue),深蓝色=深色主题(dark)
export const THEME_SWATCHES: { id: ThemeType; label: string; color: string }[] = [
{ id: "light", label: "红色", color: "#cf1322" },
{ id: "lightblue", label: "蓝色", color: "#0062d9" },
{ id: "dark", label: "深蓝色", color: "#002466" },
];
const triggerToneClass = (tone: AppearanceControlTone) =>
tone === "banner-dark"
? "text-slate-400 hover:bg-slate-800/50 hover:text-slate-400"
: tone === "banner-light"
? "text-white hover:bg-[var(--brand-950)] hover:text-white active:bg-[#8a0618]"
: "text-muted-foreground hover:bg-muted hover:text-foreground";
export function ThemeSwitcher({ tone }: { tone: AppearanceControlTone }) {
const { theme, setTheme } = useTheme();
const [open, setOpen] = useState(false);
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const cancelClose = () => {
if (closeTimer.current) {
clearTimeout(closeTimer.current);
closeTimer.current = null;
}
};
const scheduleClose = () => {
cancelClose();
closeTimer.current = setTimeout(() => setOpen(false), 120);
};
useEffect(() => () => cancelClose(), []);
return (
<div
className="relative"
onMouseEnter={() => {
cancelClose();
setOpen(true);
}}
onMouseLeave={scheduleClose}
>
<Button
type="button"
variant="ghost"
size="icon-sm"
className={cn("size-8 shrink-0 rounded-sm", triggerToneClass(tone))}
title="换肤"
aria-haspopup="true"
aria-expanded={open}
>
<Shirt className="size-4" />
</Button>
{open ? (
<div className="absolute top-full left-1/2 z-50 -translate-x-1/2 pt-2">
<div className="relative flex items-center gap-2 rounded-lg border border-black/5 bg-white px-2.5 py-1.5 shadow-lg">
<div className="absolute -top-1 left-1/2 size-2 -translate-x-1/2 rotate-45 rounded-[1px] border-t border-l border-black/5 bg-white" />
{THEME_SWATCHES.map((swatch) => {
const active = theme === swatch.id;
return (
<button
key={swatch.id}
type="button"
title={swatch.label}
aria-label={swatch.label}
aria-pressed={active}
onClick={() => {
setTheme(swatch.id);
setOpen(false);
}}
className="size-5 rounded-full transition-transform hover:scale-110"
style={{
backgroundColor: swatch.color,
boxShadow: active
? `0 0 0 1.5px #ffffff, 0 0 0 3px ${swatch.color}`
: undefined,
}}
/>
);
})}
</div>
</div>
) : null}
</div>
);
}
const LAYOUT_OPTIONS = [
{
id: "full" as const,
label: "全量模式",
description: "展示侧边栏与顶栏导航",
icon: PanelLeftIcon,
},
{
id: "compact" as const,
label: "简洁模式",
description: "隐藏侧边栏与顶栏,获得更大工作区",
icon: LayoutIcon,
},
];
/**
* 界面模式(全量 / 简洁)下拉切换。换肤旁并排,图标 + 下拉:
* - 简洁模式 ChatPage 顶栏(全局顶栏被隐藏)。
* - 全量模式 Header 右上角。
* 用户未显式设置过界面模式时(mode === undefined),当前态按调用方所在的实际界面
* 兜底——`fallback` 指明这一回退值(Header 传 "full",ChatPage 传 "compact")。
*/
export function LayoutModeSwitcher({
tone,
fallback = "compact",
}: {
tone: AppearanceControlTone;
fallback?: "full" | "compact";
}) {
const [mode, setLayoutMode] = useLayoutMode();
const current = mode ?? fallback;
const TriggerIcon = current === "full" ? PanelLeftIcon : LayoutIcon;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
className={cn("size-8 shrink-0 rounded-sm", triggerToneClass(tone))}
title="界面模式"
aria-label="界面模式"
>
<TriggerIcon className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={8} className="min-w-56 rounded-lg">
<DropdownMenuLabel>界面模式</DropdownMenuLabel>
<DropdownMenuSeparator />
{LAYOUT_OPTIONS.map((option) => {
const Icon = option.icon;
const active = current === option.id;
return (
<DropdownMenuItem
key={option.id}
onClick={() => setLayoutMode(option.id)}
className="gap-2"
>
<Icon className="size-4 shrink-0" />
<div className="flex min-w-0 flex-col">
<span className="text-sm leading-tight">{option.label}</span>
<span className="text-muted-foreground text-xs leading-tight">
{option.description}
</span>
</div>
{active && <Check className="ml-auto size-4 shrink-0" />}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -71,9 +71,10 @@ function StatusBadge({ draft }: { draft: DraftMeta }) {
if (draft.status === "error") {
return <span className="text-[10px] font-medium text-rose-500">出错</span>;
}
// 非进行中/待澄清/出错时,展示本次会商所用业务链(无链条 → 自由模式)。
return (
<span className="text-[10px] font-medium text-muted-foreground">
第 {draft.furthestStep} 步
{draft.chainTitle || "自由模式"}
</span>
);
}
@@ -192,7 +192,7 @@ export function AIWritingForm({
</span>
</div>
<Textarea
placeholder="描述你想写的文章,例如:写一篇分析今年 AI 大模型发展趋势的文章,重点关注国内企业的布局..."
placeholder="描述你想写的文章"
value={intent}
onChange={(e) => setIntent(e.target.value)}
disabled={disabled}
@@ -91,7 +91,7 @@ export function AIWritingPanel({
const { state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset } = useAIWriting();
const { historySession, isHistoryMode, openHistory, closeHistory } = useAIWritingHistory();
const writingForm = useWritingFormOptional();
const { sessions, loading: sessionsLoading, refresh, rename, remove } = useAIWritingSessions();
const { sessions, loading: sessionsLoading, refresh, rename, remove, markBackground, clearBackground } = useAIWritingSessions();
const { articleTypes, refresh: refreshArticleTypes } = useArticleTypes();
useEffect(() => { refreshArticleTypes(); }, [refreshArticleTypes]);
@@ -207,24 +207,31 @@ export function AIWritingPanel({
}, [closeHistory, reset]);
// ── 后台挂起:把当前写作转后台,后端自动驱动到定稿(离页/刷新/切号不打断) ──
const handleBackgroundSuspend = useCallback(async () => {
// 与「多智能体会商」一致:点击后**立刻**回到初始页面,挂起请求 fire-and-forget;
// 历史下拉靠乐观标记马上显示「挂起中」,不等后端确认。
const handleBackgroundSuspend = useCallback(() => {
const sid = state.sessionId;
if (!sid) {
toast.error('当前没有可挂起的写作会话');
return;
}
try {
await suspendAIWritingToBackground(sid);
} catch (e) {
toast.error(e instanceof Error ? e.message : '后台挂起失败');
return;
}
toast.success('已转入后台自动完成,稍后可在「历史记录」查看进度与结果');
// reset() 会 stop 客户端 SSE 订阅并回到 idle —— 后端驱动器接管续跑,不冲突。
// 1) 立刻:乐观标记 + 重置 UI 回到 idle(reset() 会 stop 客户端 SSE 订阅,
// 后端驱动器接管续跑,不冲突)。
markBackground(sid);
reset();
// 刷新历史列表,让「挂起中」徽标立刻出现。
refresh();
}, [state.sessionId, reset, refresh]);
toast.success('已转入后台自动完成,稍后可在「历史记录」查看进度与结果');
// 2) 后台:fire-and-forget 通知后端拉起驱动;完成后刷新真实状态并清除乐观标记。
void (async () => {
try {
await suspendAIWritingToBackground(sid);
} catch (e) {
toast.error(e instanceof Error ? e.message : '后台挂起失败');
} finally {
await refresh();
clearBackground(sid);
}
})();
}, [state.sessionId, reset, refresh, markBackground, clearBackground]);
// 离页 / 刷新 / 关标签页时,若写作仍在进行中则自动转后台(与圆桌「自动挂起」一致)。
// 用 ref 持有最新的「是否在跑 + 会话 id」,让卸载/卸载前回调读到即时值。
@@ -19,6 +19,7 @@ const DISPLAYED_TYPES = new Set([
'await_user',
'resumed',
'notice',
'model_switch',
'done',
'error',
]);
@@ -73,7 +73,7 @@ export function MaterialsToolbar({
value={userQuery}
onChange={handleInput}
onKeyDown={handleKeyDown}
placeholder={placeholder ?? '例如:检索新能源汽车销量数据。按 Enter 检索并追加…'}
placeholder={placeholder ?? '按 Enter 检索并追加…'}
disabled={disabled}
className="w-full resize-none rounded-md border border-input bg-background px-2.5 py-1.5 text-xs leading-5 placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50 min-h-[28px] max-h-24"
/>
@@ -13,6 +13,7 @@ interface TimelineStepProps {
function statusOf(event: ProgressEvent, isLast: boolean): StepStatus {
if (event.type === 'error') return 'error';
if (event.type === 'notice') return 'warning';
if (event.type === 'model_switch') return 'warning';
if (event.type === 'await_user') return 'paused';
// step_start 只有在列表末尾(后续事件还未到达)时才显示转圈,否则已有后续事件说明该步骤已完成
if (event.type === 'step_start') return isLast ? 'running' : 'done';
@@ -30,8 +31,11 @@ const AGENT_COLOR: Record<string, string> = {
export function TimelineStep({ event, isLast, children }: TimelineStepProps) {
const status = statusOf(event, isLast ?? true);
// notice=非致命告警(如 ES 波动、模型兜底),error=致命错误,两者都展示「标题 + 真实原因」
const isAlert = event.type === 'error' || event.type === 'notice';
// notice=非致命告警(如 ES 波动、模型兜底),error=致命错误,model_switch=模型自动容错切换(amber 同 notice)
const isAlert =
event.type === 'error' ||
event.type === 'notice' ||
event.type === 'model_switch';
const isError = event.type === 'error';
return (
@@ -44,7 +44,7 @@ export function WritingWelcome() {
<p className="mt-2 max-w-xl text-sm leading-relaxed text-slate-500 dark:text-white/60">
这是一个专业的自动化写作系统。你只需在下方对话框中
<strong className="font-semibold text-slate-700 dark:text-white/80">
输入你的本意(例如:「帮我写一篇 1500 字的新能源汽车报告」)
输入你的本意(例如:「帮我写一篇 1500 字的周报」)
</strong>
,系统即会自动为你在右侧填充所需的字数、体裁等格式进行对齐;你也可在右侧进行纯人工的参数深度定制,并一键开启 AI 深度协作创作。
</p>
@@ -1,4 +1,4 @@
import { useCallback, useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import {
listAIWritingSessions,
@@ -10,6 +10,27 @@ import type { AIWritingSessionSummary } from '../api/ai-writing-sessions';
export function useAIWritingSessions() {
const [sessions, setSessions] = useState<AIWritingSessionSummary[]>([]);
const [loading, setLoading] = useState(false);
// 「后台挂起」乐观标记:点击挂起后立刻让历史下拉显示「挂起中」,不必等后端确认。
// 后端状态(status='background')刷新到位后由调用方 clearBackground 清除。
const [pendingBackground, setPendingBackground] = useState<Set<string>>(() => new Set());
const markBackground = useCallback((id: string) => {
setPendingBackground((prev) => {
if (prev.has(id)) return prev;
const next = new Set(prev);
next.add(id);
return next;
});
}, []);
const clearBackground = useCallback((id: string) => {
setPendingBackground((prev) => {
if (!prev.has(id)) return prev;
const next = new Set(prev);
next.delete(id);
return next;
});
}, []);
const refresh = useCallback(async () => {
setLoading(true);
@@ -41,5 +62,18 @@ export function useAIWritingSessions() {
}
}, []);
return { sessions, loading, refresh, rename, remove };
// 把乐观「挂起中」标记叠加到真实列表上:已在列表里的会话立刻显示 background。
const displaySessions = useMemo(
() =>
pendingBackground.size === 0
? sessions
: sessions.map((s) =>
pendingBackground.has(s.id) && s.status !== 'done'
? { ...s, status: 'background' }
: s,
),
[sessions, pendingBackground],
);
return { sessions: displaySessions, loading, refresh, rename, remove, markBackground, clearBackground };
}
@@ -388,6 +388,37 @@ function handleEvent(prev: AIWritingState, event: ProgressEvent): AIWritingState
case 'notice':
return base;
case 'model_switch': {
// 模型自动容错:丢弃当前阶段已累积的流式缓冲,避免上一个失败模型的半截输出
// 与下一个模型的输出粘在一起;时间线展示侧由 TimelineStep 按 amber 渲染(同 notice)。
const stage = event.modelSwitch?.stage;
const sectionIndex = event.modelSwitch?.sectionIndex;
const resetByStage: Partial<AIWritingState> = (() => {
switch (stage) {
case 'intent':
return { intentStreamingText: '', intentThinkingText: '' };
case 'outline':
return { outlineStreamingText: '', outlineThinkingText: '' };
case 'draft': {
// 只清掉失败那一节的流式片段,已写好的章节保持不动
if (typeof sectionIndex === 'number') {
return {
streamingDraftSections: prev.streamingDraftSections.filter(
(s) => s.sectionIndex !== sectionIndex,
),
};
}
return { streamingDraftSections: [], streamingDraftTitle: '' };
}
case 'review':
return { streamingReviewItems: [] };
default:
return {};
}
})();
return { ...base, ...resetByStage };
}
default:
return base;
}
@@ -506,6 +537,22 @@ export function normalizeProgressDict(parsed: Record<string, unknown>, sessionId
out.reviewItemIssues = (out.issues as Record<string, unknown>[]).map(normalizeReviewIssue);
}
// model_switch(updates 通道里的 progress_events 也可能带):折成 modelSwitch 子对象
if (out.type === 'model_switch' && !out.modelSwitch) {
out.modelSwitch = {
stage: (out.stage as 'intent' | 'outline' | 'draft' | 'review') ?? 'intent',
failedModel: String(out.failed_model ?? out.failedModel ?? ''),
nextModel: String(out.next_model ?? out.nextModel ?? ''),
reason: String(out.reason ?? ''),
sectionIndex: typeof out.section_index === 'number'
? (out.section_index as number)
: (typeof out.sectionIndex === 'number' ? (out.sectionIndex as number) : undefined),
sectionTitle: typeof out.section_title === 'string'
? (out.section_title as string)
: (typeof out.sectionTitle === 'string' ? (out.sectionTitle as string) : undefined),
};
}
if (!out.message) out.message = '';
return out as unknown as ProgressEvent;
}
@@ -522,6 +569,7 @@ export function customToProgressEvent(raw: unknown, sessionId: string): Progress
'research_progress', 'outline_chunk', 'draft_chunk',
'review_chunk', 'review_item_done',
'skill_search_step', 'skill_select_chunk',
'model_switch',
];
if (!allowed.includes(type as ProgressEventType)) return null;
@@ -536,10 +584,30 @@ export function customToProgressEvent(raw: unknown, sessionId: string): Progress
if (Array.isArray(merged.issues)) {
merged.reviewItemIssues = (merged.issues as Record<string, unknown>[]).map(normalizeReviewIssue);
}
if ('agent_name' in merged && !merged.agentName) merged.agentName = merged.agent_name;
// skill_search_step:把字段折成 skillStep 子对象,方便 reducer / UI 取用
if (type === 'skill_search_step') {
merged.skillStep = normalizeSkillStep(o);
}
// model_switch:把 snake_case 字段折成 modelSwitch 子对象(reducer + TimelineStep 取用)
if (type === 'model_switch') {
merged.modelSwitch = {
stage: (o.stage as 'intent' | 'outline' | 'draft' | 'review') ?? 'intent',
failedModel: String(o.failed_model ?? (o as Record<string, unknown>).failedModel ?? ''),
nextModel: String(o.next_model ?? (o as Record<string, unknown>).nextModel ?? ''),
reason: String(o.reason ?? ''),
sectionIndex: typeof o.section_index === 'number'
? (o.section_index as number)
: (typeof (o as Record<string, unknown>).sectionIndex === 'number'
? ((o as Record<string, unknown>).sectionIndex as number)
: undefined),
sectionTitle: typeof o.section_title === 'string'
? (o.section_title as string)
: (typeof (o as Record<string, unknown>).sectionTitle === 'string'
? ((o as Record<string, unknown>).sectionTitle as string)
: undefined),
};
}
return merged as unknown as ProgressEvent;
}
@@ -172,6 +172,7 @@ export type ProgressEventType =
| "await_user"
| "resumed"
| "notice"
| "model_switch"
| "skill_search_step"
| "skill_select_chunk"
| "done"
@@ -215,6 +216,16 @@ export interface ProgressEvent {
blockedSections?: BlockedSection[];
// skill_search_step:技能检索步骤事件(list_skills / select_skills / invoke_skill)
skillStep?: SkillSearchStep;
// model_switch:模型自动容错切换(某模型挂掉,换下一个模型继续)
// stage ∈ intent / outline / draft / review,前端 reducer 据此清掉对应阶段的流式缓冲。
modelSwitch?: {
stage: "intent" | "outline" | "draft" | "review";
failedModel: string;
nextModel: string;
reason: string;
sectionIndex?: number;
sectionTitle?: string;
};
}
// ── 用户干预提交体 ────────────────────────────────────────────────────────────
+8
View File
@@ -26,6 +26,10 @@ import { ThreadContext } from "@/components/workspace/messages/context";
import { QuestionNavigator } from "@/components/workspace/question-navigator";
import { ThreadTitle } from "@/components/workspace/thread-title";
import { TodoList } from "@/components/workspace/todo-list";
import {
LayoutModeSwitcher,
ThemeSwitcher,
} from "@/components/workspace/appearance-controls";
import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator";
import { UserIdentityBadge } from "@/components/workspace/user-identity-badge";
import { Welcome } from "@/components/workspace/welcome";
@@ -484,8 +488,12 @@ export default function ChatPage() {
<ArtifactTrigger />
{/* 不带全局顶栏(紧凑模式)时,名称+岗位回落到聊天页顶栏右侧,
与全量模式 Header 右上角保持一致。全量模式已有顶栏则不重复展示。 */}
{/* 简洁模式无全局顶栏:把换肤 + 界面模式切换搬到页面级顶栏右上角,
与全量模式 Header 右上角保持一致(content 配色贴浅底顶栏)。 */}
{!compactChrome && (
<>
<ThemeSwitcher tone="content" />
<LayoutModeSwitcher tone="content" />
<div
className="bg-border/70 mx-1 h-6 w-px shrink-0"
aria-hidden
@@ -225,9 +225,9 @@ export default function RoundtableDiagnosticsPage() {
返回
</Button>
<div>
<h1 className="text-base font-semibold text-slate-800 dark:text-foreground">会商诊断日志</h1>
<h1 className="text-base font-semibold text-slate-800 dark:text-foreground">会商报错日志</h1>
<p className="text-xs text-slate-400 dark:text-muted-foreground">
多智能体会商全流程报错与关键事件(含后台挂起卡死、超时、产物缺失等)
多智能体会商全流程报错日志(席位失败 / 空交付 / 超时 / 模型容错 / 产物缺失 / 崩溃等;运行进度类 info 不再收集)
</p>
</div>
<Button
@@ -188,6 +188,11 @@ export interface DraftMeta {
/** Auto-derived task summary (objective → first user message → timestamp). */
title: string;
furthestStep: 1 | 2 | 3;
/**
* 本次会商所用业务链名(历史卡片展示)。无业务链(自由/recommend 模式)→ null/undefined,
* 由前端展示为「自由模式」。后端从 step2 快照里抽取(见 roundtable_drafts/sql.py)。
*/
chainTitle?: string | null;
createdAt: string;
updatedAt: string;
/**
@@ -250,6 +255,7 @@ export interface ServerDraftMeta {
task_id?: string | null;
title: string;
furthest_step: number;
chain_title?: string | null;
created_at: string | null;
updated_at: string | null;
status?: string | null;
@@ -269,6 +275,7 @@ export function toMeta(s: ServerDraftMeta): DraftMeta {
taskId: s.task_id ?? null,
title: s.title ?? "",
furthestStep: step,
chainTitle: s.chain_title ?? null,
createdAt: s.created_at ?? "",
updatedAt: s.updated_at ?? "",
status: (s.status as JobStatus | null | undefined) ?? null,
@@ -296,6 +296,10 @@ export default function RoundtablePlanningPage() {
const [taskRestoreResolved, setTaskRestoreResolved] = useState<boolean>(
!prefillRef.current?.taskId,
);
// 深链/预填自动会话:进入页面后要先建意图线程、(带 taskId 时)回显历史草稿,这段时间
// 页面只有一句欢迎语、问答还没真正开始,线程创建慢时像卡住。下面这个标志用来在超时后
// 强制撤掉「启动加载遮罩」,避免万一初始化卡死把界面永久锁住。
const [bootLoadingExpired, setBootLoadingExpired] = useState<boolean>(false);
const { models: availableModels, isLoading: modelsLoading } = useModels();
@@ -1143,6 +1147,35 @@ export default function RoundtablePlanningPage() {
};
}, []);
// ── 启动加载遮罩(深链/预填自动会话)──────────────────────────────
// 是否是「自动会话」:深链 -A、taskId、businessCode/chainId 任一存在即自动进入问答,
// 用户没有手动操作,进入页面到问答之间必须给出明确加载反馈。
const isAutoSession = Boolean(
prefillRef.current?.autoStart ||
prefillRef.current?.taskId ||
prefillRef.current?.businessCode ||
prefillRef.current?.chainId,
);
// 「问答已开始」:自动发出的首条用户消息已入列 / 正在流式 / 已得到意图结果,任一为真即认为
// 已经动起来,可以撤掉遮罩交给 Step1Panel 自身的流式占位。
const conversationStarted =
step1.isIntentSending ||
step1.intentReady != null ||
step1.interactiveMessages.some((m) => m.sender === "user");
// 遮罩展示条件:自动会话 + 仍在 Step1 + 问答还没开始 + 没有初始化报错 + 没到安全超时。
const showBootLoading =
isAutoSession &&
currentStep === 1 &&
!conversationStarted &&
!step1.intentInitError &&
!bootLoadingExpired;
// 安全超时:无论如何最多遮 30s,避免初始化卡死锁住界面(用户仍可手动操作底层页面)。
useEffect(() => {
if (!showBootLoading) return;
const timer = window.setTimeout(() => setBootLoadingExpired(true), 30_000);
return () => window.clearTimeout(timer);
}, [showBootLoading]);
/** 组装无界回传载荷:总控共识 + 各智能体最终交付(剔除总控席位)。 */
const buildWujieConsensusPayload = useCallback(
() => ({
@@ -1780,10 +1813,34 @@ export default function RoundtablePlanningPage() {
return (
<div
className={cn(
"roundtable-planning theme-transition flex h-full min-h-0 flex-col bg-[#fef6f6] font-sans text-slate-800 selection:bg-red-200",
"roundtable-planning theme-transition relative flex h-full min-h-0 flex-col bg-[#fef6f6] font-sans text-slate-800 selection:bg-red-200",
isDark && "roundtable-planning--dark",
)}
>
{/* 启动加载遮罩:深链/预填自动会话从进入页面到问答真正开始(建意图线程 / 回显历史草稿)
期间全屏覆盖,避免线程创建慢时页面像卡住。问答一旦开始或超时即撤掉。 */}
{showBootLoading && (
<div
className="absolute inset-0 z-50 flex items-center justify-center bg-white"
role="status"
aria-live="polite"
>
<div className="flex flex-col items-center gap-7 px-8">
<div className="flex flex-col items-center gap-2 text-center">
<p className="text-lg font-semibold tracking-wide text-slate-800">
正在准备多智能体会商
</p>
<p className="text-sm text-slate-400">正在初始化任务理解智能体,请稍候…</p>
</div>
<span className="inline-flex items-center gap-1.5">
<span className="h-2 w-2 animate-bounce rounded-full bg-red-400 [animation-delay:-0.3s]" />
<span className="h-2 w-2 animate-bounce rounded-full bg-red-400 [animation-delay:-0.15s]" />
<span className="h-2 w-2 animate-bounce rounded-full bg-red-400" />
</span>
</div>
</div>
)}
<RecommendAgentsDialog
open={isRecommendDialogOpen}
intent={step1.intentReady}
@@ -3111,13 +3168,16 @@ function RoundtableEntryRail({
{completed ? "已完成" : "未完成"}
</span>
</div>
{/* 下:时间(左下) + 步骤(右下) */}
<div className="mt-2 flex items-center justify-between">
<span className="font-mono text-[10px] text-neutral-400">
{/* 下:时间(左下) + 业务链(右下,无链条显示「自由模式」) */}
<div className="mt-2 flex items-center justify-between gap-2">
<span className="font-mono text-[10px] text-neutral-400 shrink-0">
{formatTime(rec.updatedAt)}
</span>
<span className="rounded-[5px] bg-neutral-100 px-1.5 py-0.5 text-[10px] font-medium text-neutral-500">
第 {rec.furthestStep} 步
<span
className="max-w-[60%] truncate rounded-[5px] bg-neutral-100 px-1.5 py-0.5 text-[10px] font-medium text-neutral-500"
title={rec.chainTitle || "自由模式"}
>
{rec.chainTitle || "自由模式"}
</span>
</div>
</button>
+10 -91
View File
@@ -1,9 +1,10 @@
import { Bell, ChevronDown, FileText, Shirt, User } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Bell, ChevronDown, FileText, User } from "lucide-react";
import { useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button";
import "@/components/page-sdebar.css";
import { LayoutModeSwitcher, ThemeSwitcher } from "@/components/workspace/appearance-controls";
import { NotificationDialog } from "@/components/workspace/notification-dialog";
import { getStoredAuth, getAccountDisplayName } from "@/core/auth";
import { isWujieEnv } from "@/core/embed/wujie";
@@ -18,7 +19,7 @@ import {
import { useSidebarMenuItems } from "@/core/page-layout/use-sidebar-menu";
import { cn } from "@/lib/utils";
import { useTheme, type ThemeType } from "../contexts/ThemeContext";
import { useTheme } from "../contexts/ThemeContext";
const iconButtonClass = (isDark: boolean) =>
cn(
@@ -32,93 +33,6 @@ const iconButtonClass = (isDark: boolean) =>
const LEFT_MENU_IDS = ["qa-center", "task-management", "light-app-center"];
const RIGHT_MENU_IDS = ["comprehensive-management", "resource-management"];
// 换肤色板:红色=红白主题(light),蓝色=浅蓝主题(lightblue),深蓝色=深色主题(dark)
const THEME_SWATCHES: { id: ThemeType; label: string; color: string }[] = [
{ id: "light", label: "红色", color: "#cf1322" },
{ id: "lightblue", label: "蓝色", color: "#0062d9" },
{ id: "dark", label: "深蓝色", color: "#002466" },
];
function ThemeSwitcher({ isDark }: { isDark: boolean }) {
const { theme, setTheme } = useTheme();
const [open, setOpen] = useState(false);
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const cancelClose = () => {
if (closeTimer.current) {
clearTimeout(closeTimer.current);
closeTimer.current = null;
}
};
const scheduleClose = () => {
cancelClose();
closeTimer.current = setTimeout(() => setOpen(false), 120);
};
useEffect(() => () => cancelClose(), []);
// 切换按钮:悬浮只改背景,不改图标颜色(显式锁定 hover 文字色等于常态色)
const triggerClass = cn(
"size-8 shrink-0 rounded-sm",
isDark
? "text-slate-400 hover:bg-slate-800/50 hover:text-slate-400"
: "text-white hover:bg-[var(--brand-950)] hover:text-white active:bg-[#8a0618]",
);
return (
<div
className="relative"
onMouseEnter={() => {
cancelClose();
setOpen(true);
}}
onMouseLeave={scheduleClose}
>
<Button
type="button"
variant="ghost"
size="icon-sm"
className={triggerClass}
title="换肤"
aria-haspopup="true"
aria-expanded={open}
>
<Shirt className="size-4" />
</Button>
{open ? (
<div className="absolute top-full left-1/2 z-50 -translate-x-1/2 pt-2">
<div className="relative flex items-center gap-2 rounded-lg border border-black/5 bg-white px-2.5 py-1.5 shadow-lg">
<div className="absolute -top-1 left-1/2 size-2 -translate-x-1/2 rotate-45 rounded-[1px] border-t border-l border-black/5 bg-white" />
{THEME_SWATCHES.map((swatch) => {
const active = theme === swatch.id;
return (
<button
key={swatch.id}
type="button"
title={swatch.label}
aria-label={swatch.label}
aria-pressed={active}
onClick={() => {
setTheme(swatch.id);
setOpen(false);
}}
className="size-5 rounded-full transition-transform hover:scale-110"
style={{
backgroundColor: swatch.color,
boxShadow: active
? `0 0 0 1.5px #ffffff, 0 0 0 3px ${swatch.color}`
: undefined,
}}
/>
);
})}
</div>
</div>
) : null}
</div>
);
}
type HeaderProps = {
collapsed?: boolean;
};
@@ -210,7 +124,12 @@ const Header = ({ collapsed }: HeaderProps) => {
// 右侧操作区(换肤/通知/用户)—— 深浅两套布局共用
const actionCluster = (
<>
<ThemeSwitcher isDark={isDark} />
<ThemeSwitcher tone={isDark ? "banner-dark" : "banner-light"} />
<LayoutModeSwitcher
tone={isDark ? "banner-dark" : "banner-light"}
fallback="full"
/>
<Button
type="button"
+3 -3
View File
@@ -245,9 +245,9 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA
| **Runs** (`/api/runs`) | `POST /stream` - stateless run + SSE; `POST /wait` - stateless run + block; `GET /{rid}/messages` - paginated messages by run_id `{data, has_more}` (cursor: `after_seq`/`before_seq`); `GET /{rid}/feedback` - list feedback by run_id |
| **Thread Shares** (`/api/thread-shares`) | Per-conversation sharing with two modes (`thread_shares.mode`). **`import`**: `POST /` `{thread_id, mode}` - create (or reuse) a revocable share code; `GET /{code}/preview` + `POST /{code}/import` - copy that conversation into the caller's account (fresh `thread_id` via `app/gateway/thread_copy.py` + new `threads_meta` row + `user-data` copy; dedup on `metadata.shared_origin`). **`view`**: a public, read-only static page ? see Public Shares below. Both modes: `GET /` - list my codes; `DELETE /{code}` - revoke. |
| **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, *, chain_enabled=False)` appends an explicit Chinese directive (each configured-and-enabled skill's name + description + SKILL.md container path + "先 read_file 再按其流程取信息/执行") toLine truncated
| **Roundtable Drafts** (`/api/roundtable-drafts`) | **Strictly per-user** persistence for the roundtable-planning page (replaces the old browser `localStorage`). `GET /` - list drafts (lightweight meta — incl. `chain_title`, the business-chain name used this session, cheaply regex-extracted from the in-memory `step2` blob in `_draft_to_meta`/`_extract_chain_title`; null = no chain / 自由模式; surfaced on the 第一步「分析记录」history card + sidebar list instead of the old「第 X 步」); `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` (each meta also carries `chain_title`, same `_extract_chain_title` regex as the personal store); `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 (warning/error — **`info` 级运行日志不再收集**:`record_diagnostic` 在写库前丢弃 `level=="info"`,只留报错;begin/consensus/gather/job_started 等纯进度事件即使仍调 `.diag(level="info")` 也成 no-op,故诊断页 = 报错日志页), 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.Line 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"`. |
@@ -47,6 +47,8 @@ class DraftMetaResponse(BaseModel):
task_id: str | None = None
title: str = ""
furthest_step: int = 1
# 本次会商所用业务链名(历史卡片展示);无链条(自由/recommend 模式)时为 None。
chain_title: str | None = None
created_at: datetime | str | None = None
updated_at: datetime | str | None = None
# 最近一次后台作业的状态(Phase 6 历史下拉「进行中」loading 用);无作业时为 None。
@@ -42,6 +42,8 @@ class TaskDraftMetaResponse(BaseModel):
task_id: str
title: str = ""
furthest_step: int = 1
# 本次会商所用业务链名(历史卡片展示);无链条(自由/recommend 模式)时为 None。
chain_title: str | None = None
created_at: datetime | str | None = None
updated_at: datetime | str | None = None
# Latest background-job status for this draft (history-dropdown loading badge).
@@ -0,0 +1,197 @@
"""AI 写作模型自动容错(按 ``config.yaml models[]`` 顺序换下一个模型继续)。
与多智能体会商 ``app/gateway/roundtable_model_fallback.py`` 同思路,但**只放在 harness 层**
避免 ``app→harness`` 单向依赖被反向打破:
- ``fallback_chain(current)``:候选模型链 ``[当前, …其余]``(按 config.yaml 顺序,去重保序);
- ``classify_exception(exc)``:把异常归到一个用户可读的失败原因码;
- ``reason_text(reason)``:原因码 → 简短中文(用于「『X』模型 <原因>,已切换『Y』继续」);
- ``emit_model_switch_event(writer, ...)``:把 ``model_switch`` 推到 LangGraph custom 通道
供前端时间线展示;
- ``build_switch_progress_event(...)``:节点 return 的 ``progress_events`` 里同样塞一份
(走 updates 通道,前端 reducer 据此刷新缓冲并落到时间线)。
总开关 / 上限沿用会商的 env:
- ``ROUNDTABLE_MODEL_FALLBACK``:默认开;``0/false/off/no`` 关闭(链只含当前模型);
- ``ROUNDTABLE_MODEL_FALLBACK_MAX``:候选数上限(含当前),0/未设 = 不限。
放在 ai_writing 节点目录下而非 harness/agents/middlewares:本模块不是中间件,
且仅服务 ai_writing 的几个流式节点;如果未来圆桌也想下沉合并,再统一拎出去。
"""
from __future__ import annotations
import logging
import os
from typing import Any, Callable
logger = logging.getLogger(__name__)
_REASON_TEXT: dict[str, str] = {
"quota": "额度不足/账单异常",
"auth": "鉴权失败/无访问权限",
"busy": "服务繁忙",
"transient": "网络/连接异常",
"upstream": "上游响应异常",
"config": "模型未配置或配置异常",
"exception": "调用异常",
"generic": "调用失败",
}
def reason_text(reason: str | None) -> str:
"""原因码 → 简短中文(未知码 → 「调用失败」)。"""
if not reason:
return "调用失败"
return _REASON_TEXT.get(reason, "调用失败")
_QUOTA_PATTERNS = ("insufficient_quota", "quota", "billing", "credit", "payment",
"余额不足", "超出限额", "额度不足", "欠费")
_AUTH_PATTERNS = ("authentication", "unauthorized", "invalid api key", "invalid_api_key",
"permission", "forbidden", "access denied", "无权", "未授权")
_BUSY_PATTERNS = ("server busy", "temporarily unavailable", "try again later", "please retry",
"please try again", "overloaded", "high demand", "rate limit",
"负载较高", "服务繁忙", "稍后重试", "请稍后重试")
_UPSTREAM_PATTERNS = ("bad_response_status_code", "bad response status",
"expecting ',' delimiter", "expecting value", "expecting property name",
"unexpected character", "unterminated string", "jsondecodeerror")
_TRANSIENT_EXC_NAMES = {"APITimeoutError", "APIConnectionError", "InternalServerError",
"ReadError", "RemoteProtocolError", "TimeoutError"}
def classify_exception(exc: BaseException) -> str:
"""异常 → 失败原因码(quota/auth/busy/transient/upstream/config/generic/exception)。
与 ``LLMErrorHandlingMiddleware._classify_error`` 同口径,简化版:不区分是否可重试
(ai_writing 的容错只换模型,不在同模型上重试)。``create_chat_model`` 抛
``ValueError("Model X not found in config")`` 这类配置缺失另归 ``config``。
"""
name = exc.__class__.__name__
detail = str(exc).lower()
if isinstance(exc, ValueError) and "not found in config" in detail:
return "config"
if any(p in detail for p in _QUOTA_PATTERNS):
return "quota"
if any(p in detail for p in _AUTH_PATTERNS):
return "auth"
if name in _TRANSIENT_EXC_NAMES:
return "transient"
if any(p in detail for p in _BUSY_PATTERNS):
return "busy"
if any(p in detail for p in _UPSTREAM_PATTERNS):
return "upstream"
return "exception"
def _fallback_enabled() -> bool:
raw = (os.getenv("ROUNDTABLE_MODEL_FALLBACK", "1") or "1").strip().lower()
return raw not in {"0", "false", "no", "off"}
def _max_attempts() -> int:
try:
return max(0, int(os.getenv("ROUNDTABLE_MODEL_FALLBACK_MAX", "0") or "0"))
except (TypeError, ValueError):
return 0
def _config_model_names() -> list[str]:
try:
from deerflow.config import get_app_config
cfg = get_app_config()
return [m.name for m in (cfg.models or []) if getattr(m, "name", None)]
except Exception: # noqa: BLE001 — 拿不到配置(测试桩)时退化为空
logger.debug("ai_writing fallback_chain: get_app_config().models unavailable", exc_info=True)
return []
def fallback_chain(current: str | None) -> list[str]:
"""``[当前模型, …config.yaml 其余模型]``(去重保序)。
- ``current`` 为空 → 链头落到 ``models[0]``;
- ``current`` 在配置内 → 排第一,余者按配置顺序补齐;
- ``current`` 不在配置内 → 仍排第一(让 ``create_chat_model`` 在第一轮按原行为抛,
之后再换模型),再补齐全部配置模型;
- 总开关关闭 → 只返回头一个;
- 拿不到配置 → ``[current]``(current 为空则空列表)。
"""
all_names = _config_model_names()
cur = (current or "").strip()
ordered: list[str] = []
if cur:
ordered.append(cur)
for n in all_names:
if n and n not in ordered:
ordered.append(n)
if not ordered:
return [cur] if cur else []
if not _fallback_enabled():
return ordered[:1]
cap = _max_attempts()
if cap > 0:
ordered = ordered[:cap]
return ordered
def build_switch_progress_event(
*,
agent_name: str,
stage: str,
failed_model: str,
next_model: str,
reason: str,
section_index: int | None = None,
section_title: str | None = None,
) -> dict:
"""构造一条 ``model_switch`` ``progress_event`` 字典(供节点 return 时塞进 ``progress_events`` 数组)。
``stage`` ∈ {intent, outline, draft, review}:前端 reducer 据此清掉对应阶段的流式缓冲,
避免半段输出被下一个模型的输出粘在一起。
"""
fm = (failed_model or "").strip() or "当前模型"
nm = (next_model or "").strip() or "下一个模型"
rt = reason_text(reason)
detail = f"「{agent_name}」所用模型「{fm}」{rt},已自动切换到「{nm}」继续。"
payload: dict[str, Any] = {
"type": "model_switch",
"agent_name": agent_name,
"title": "模型容错",
"detail": detail,
"message": detail,
"stage": stage,
"failed_model": fm,
"next_model": nm,
"reason": reason,
}
if section_index is not None:
payload["section_index"] = section_index
if section_title:
payload["section_title"] = section_title
return payload
def emit_model_switch_event(
writer: Callable[[dict], None] | None,
*,
agent_name: str,
stage: str,
failed_model: str,
next_model: str,
reason: str,
section_index: int | None = None,
section_title: str | None = None,
) -> None:
"""把 ``model_switch`` 事件推到 LangGraph custom 通道(前端 useStream onCustomEvent 收)。"""
if writer is None:
return
payload = build_switch_progress_event(
agent_name=agent_name, stage=stage,
failed_model=failed_model, next_model=next_model, reason=reason,
section_index=section_index, section_title=section_title,
)
try:
writer(payload)
except Exception: # noqa: BLE001 — writer 在无 runtime 时是 no-op,理论不抛
logger.debug("emit_model_switch_event: writer raised", exc_info=True)
@@ -21,7 +21,13 @@ from ._agent_runner import (
run_writing_agent,
use_builtin_agent,
)
from ._utils import get_ai_writing_config, get_writing_model, parse_json, _content_to_text, reasoning_delta, ThinkTagParser, safe_get_stream_writer
from ._model_fallback import (
build_switch_progress_event,
classify_exception,
emit_model_switch_event,
fallback_chain,
)
from ._utils import get_ai_writing_config, parse_json, resolve_writing_model_name, _content_to_text, reasoning_delta, ThinkTagParser, safe_get_stream_writer
logger = logging.getLogger(__name__)
@@ -154,22 +160,17 @@ async def editor_node(state: AIWritingState, config: RunnableConfig) -> dict:
"title": "模型错误", "detail": str(e), "message": "审核失败",
}],
}
# 旧路径下模型由 fallback_chain 在每维度审查前按候选顺序解析;模型挂掉自动换下一个继续。
editor_chain: list[str] = []
current_model_name: str | None = None
if not use_agent:
# 模型创建守卫:模型名配错时 create_chat_model 抛 ValueError,
# 不拦截会硬崩溃整个图;此处转成可恢复的 status:error。
try:
model = get_writing_model(config, state)
except Exception as e:
logger.error("editor model creation failed: %s", e, exc_info=True)
return {
"status": "error",
"progress_events": [{
"type": "error", "agent_name": "编辑",
"title": "模型错误", "detail": str(e), "message": "审核失败",
}],
}
initial_name, _ud = resolve_writing_model_name(config, state)
editor_chain = fallback_chain(initial_name)
current_model_name = editor_chain[0] if editor_chain else None
from deerflow.models import create_chat_model
results: dict[str, dict] = {}
review_errors: list[str] = [] # 分项审查的模型异常,供完成后非致命提示
switch_notices: list[dict] = [] # 模型容错切换记录,最终塞进 progress_events 走时间线
_CATEGORY_LABEL = {"fact": "事实性", "logic": "逻辑性", "language": "语言文字"}
@@ -192,7 +193,47 @@ async def editor_node(state: AIWritingState, config: RunnableConfig) -> dict:
if res.get("_llm_error"):
review_errors.append(f"「{_CATEGORY_LABEL.get(category, category)}审查」{res['_llm_error']}")
else:
full_text = await _stream_one_review(model, user_prompt, category, writer)
# 旧路径:按 fallback_chain 试候选模型,失败就换下一个继续本维度。
# 与 writer_draft 同思路:以「上一维度成功使用的模型」为头部。
dim_chain: list[str] = []
if current_model_name:
dim_chain.append(current_model_name)
for n in editor_chain:
if n and n not in dim_chain:
dim_chain.append(n)
if not dim_chain:
dim_chain = [None] # type: ignore[list-item]
full_text = ""
dim_succeeded = False
last_dim_exc: BaseException | None = None
for d_idx, candidate in enumerate(dim_chain):
try:
m = create_chat_model(name=candidate) if candidate else create_chat_model()
full_text = await _stream_one_review(m, user_prompt, category, writer)
dim_succeeded = True
current_model_name = candidate or current_model_name
break
except Exception as e:
last_dim_exc = e
reason = classify_exception(e)
logger.error(
"editor review [%s] failed on model=%s (reason=%s): %s",
category, candidate, reason, e, exc_info=True,
)
next_candidate = dim_chain[d_idx + 1] if d_idx + 1 < len(dim_chain) else None
if next_candidate:
emit_model_switch_event(
writer, agent_name=agent_label, stage="review",
failed_model=candidate or "", next_model=next_candidate, reason=reason,
)
switch_notices.append(build_switch_progress_event(
agent_name=agent_label, stage="review",
failed_model=candidate or "", next_model=next_candidate, reason=reason,
))
continue
if not dim_succeeded:
assert last_dim_exc is not None
raise last_dim_exc
res = parse_json(full_text, fallback)
except Exception as e:
logger.warning("editor review [%s] failed: %s", category, e)
@@ -252,6 +293,7 @@ async def editor_node(state: AIWritingState, config: RunnableConfig) -> dict:
"review_result": review,
"progress_events": [
{"type": "step_start", "agent_name": agent_label, "message": "开始审核"},
*switch_notices,
*notices,
{
"type": "review_ready",
@@ -9,10 +9,15 @@ from langchain_core.runnables import RunnableConfig
from langchain_core.messages import HumanMessage, SystemMessage
from ..state import AIWritingState
from ._model_fallback import (
build_switch_progress_event,
classify_exception,
emit_model_switch_event,
fallback_chain,
)
from ._utils import (
ThinkTagParser,
_content_to_text,
get_model,
parse_json,
reasoning_delta,
safe_get_stream_writer,
@@ -207,47 +212,74 @@ async def intent_parser_node(state: AIWritingState, config: RunnableConfig) -> d
# 流式调用:与 writer_outline / writer_draft 同套机制 —— LLM 逐字吐出 JSON,
# 通过 ``intent_chunk`` 推给前端打字机;完成后 ``parse_json`` 解析结构化结果,
# 再通过 ``intent_ready`` 推一份结构化字段给前端结果卡片。
#
# 模型自动容错:失败时按 fallback_chain 顺序换下一个模型重试本环节。每次切换
# 都通过 ``model_switch`` custom 事件 + ``progress_events`` 同步告诉前端,前端
# 清掉本环节已累积的 intent_chunk 缓冲,避免半段输出和下个模型的输出拼接错乱。
writer = safe_get_stream_writer()
llm_error: str | None = None
initial_model = (configurable.get("model_name") or "").strip() or None
chain = fallback_chain(initial_model)
progress_events: list[dict] = []
from deerflow.models import create_chat_model
full_text = ""
parser = ThinkTagParser()
try:
model = get_model(config)
async for chunk in model.astream(
[SystemMessage(content=system), HumanMessage(content=user)]
):
# 推理模型的思考走 reasoning_content(content 为空):作为思考实时推送
reasoning = reasoning_delta(chunk)
if reasoning:
writer({"type": "intent_chunk", "text": reasoning, "is_thinking": True})
delta = _content_to_text(chunk.content)
if not delta:
success_model: str | None = None
last_error: BaseException | None = None
for idx, candidate in enumerate(chain or [None]):
full_text = "" # 每次重试都从头累积,避免不同模型的输出粘在一起
parser = ThinkTagParser()
try:
model = create_chat_model(name=candidate) if candidate else create_chat_model()
async for chunk in model.astream(
[SystemMessage(content=system), HumanMessage(content=user)]
):
# 推理模型的思考走 reasoning_content(content 为空):作为思考实时推送
reasoning = reasoning_delta(chunk)
if reasoning:
writer({"type": "intent_chunk", "text": reasoning, "is_thinking": True})
delta = _content_to_text(chunk.content)
if not delta:
continue
for is_thinking, text in parser.feed(delta):
if not is_thinking:
full_text += text
writer({
"type": "intent_chunk",
"text": text,
"is_thinking": is_thinking,
})
success_model = candidate or ""
break
except Exception as exc:
last_error = exc
reason = classify_exception(exc)
logger.error(
"intent_parser streaming failed on model=%s (reason=%s): %s",
candidate, reason, exc, exc_info=True,
)
next_candidate = chain[idx + 1] if idx + 1 < len(chain) else None
if next_candidate:
emit_model_switch_event(
writer, agent_name="系统", stage="intent",
failed_model=candidate or "", next_model=next_candidate, reason=reason,
)
progress_events.append(build_switch_progress_event(
agent_name="系统", stage="intent",
failed_model=candidate or "", next_model=next_candidate, reason=reason,
))
continue
for is_thinking, text in parser.feed(delta):
if not is_thinking:
full_text += text
writer({
"type": "intent_chunk",
"text": text,
"is_thinking": is_thinking,
})
except Exception as exc:
# 模型 / 网络挂掉 —— 不阻断流程,用 fallback 继续。
logger.error("intent_parser streaming failed: %s", exc, exc_info=True)
llm_error = f"{type(exc).__name__}: {exc}"
# 链尾仍失败:保留 fallback 默认值,向用户报一条非致命 notice,流程继续。
progress_events.append({
"type": "notice",
"agent_name": "系统",
"title": "模型错误",
"detail": (
f"意图解析失败,已按默认配置兜底。原因:{type(exc).__name__}: {exc}"
),
})
result = parse_json(full_text, fallback_result) if full_text else fallback_result
# 意图解析模型调用失败时以非致命 notice 提示用户(fallback 已兜底,流程仍可继续)
progress_events: list[dict] = []
if llm_error:
progress_events.append({
"type": "notice",
"agent_name": "系统",
"title": "模型错误",
"detail": f"意图解析失败,已按默认配置兜底。原因:{llm_error}",
})
final_article_type, final_target_audience, final_word_count, final_refined_intent = _resolve_intent_fields(
state, result, fallback_result, valid_keys, default_key
)
@@ -267,9 +299,11 @@ async def intent_parser_node(state: AIWritingState, config: RunnableConfig) -> d
# 捕获用户表单选定的主模型进 state(仅本节点能可靠拿到 configurable.model_name —
# 它运行在首次 invocation;后续大纲/写作/审核在 resume 后跑,config 已不带它)。
# 大纲/写作/审核经 get_writing_model 严格读 state['model_name'],不回退默认。
selected_model = (configurable.get("model_name") or "").strip()
if selected_model:
updates["model_name"] = selected_model
# 容错切换后用实际成功的模型作为后续阶段起点,避免每个阶段都重复经历同一次失败。
persisted_model = (success_model or initial_model or "").strip()
if persisted_model:
updates["model_name"] = persisted_model
_ = last_error # 链尾失败已生成 notice,这里保留变量供调试时断点查看
updates["article_type"] = final_article_type
updates["target_audience"] = final_target_audience
updates["word_count_target"] = final_word_count
@@ -21,6 +21,12 @@ from ._agent_runner import (
stream_writing_agent,
use_builtin_agent,
)
from ._model_fallback import (
build_switch_progress_event,
classify_exception,
emit_model_switch_event,
fallback_chain,
)
from ._utils import resolve_writing_model_name, _content_to_text, count_words, reasoning_delta, ThinkTagParser, safe_get_stream_writer
logger = logging.getLogger(__name__)
@@ -234,31 +240,25 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
"title": "模型错误", "detail": str(e), "message": "草稿生成失败",
}],
}
# 旧路径的模型由 fallback_chain 在每章流式调用前按候选顺序解析(per-section),
# 不再在循环外预创建:模型名配错 / 模型挂掉时不再硬崩溃,按链顺序换下一个模型重写本章节。
draft_chain: list[str] = []
if not use_agent:
# 模型创建守卫:模型名配错时 create_chat_model 抛 ValueError,
# 不拦截会硬崩溃整个图;此处转成可恢复的 status:error。
try:
model_name, used_default = resolve_writing_model_name(config, state)
if used_default:
model_default_notice = {
"type": "notice", "agent_name": "作家",
"title": "模型提示", "detail": "未检测到所选写作模型,已使用默认模型继续写作。",
}
from deerflow.models import create_chat_model
model = create_chat_model(name=model_name)
except Exception as e:
logger.error("draft model creation failed: %s", e, exc_info=True)
return {
"status": "error",
"progress_events": [{
"type": "error", "agent_name": "作家",
"title": "模型错误", "detail": str(e), "message": "草稿生成失败",
}],
initial_name, used_default = resolve_writing_model_name(config, state)
if used_default:
model_default_notice = {
"type": "notice", "agent_name": "作家",
"title": "模型提示", "detail": "未检测到所选写作模型,已使用默认模型继续写作。",
}
draft_chain = fallback_chain(initial_name)
from deerflow.models import create_chat_model
section_titles: list[str] = []
section_bufs: list[str] = [] # 各章节正文(不含 ## 标题),供扩写兜底逐节替换
section_errors: list[str] = [] # 分节写作的模型异常,供完成后非致命提示
blocked_sections: list[dict] = [] # 严格模式下素材不足、无法写作的章节
switch_notices: list[dict] = [] # 模型容错切换记录,最终塞进 progress_events 走时间线
# 上一节成功使用的模型 —— 优先尝试,命中则零开销;与会商「当前模型先试」一致。
current_model_name: str | None = (draft_chain[0] if draft_chain else None)
for i, section in enumerate(sections):
sec_title = _section_title(i, section)
@@ -292,9 +292,12 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
)
section_buf = "" # 只累积正文(非思考)用于拼接 Markdown
parser = ThinkTagParser()
section_succeeded = False
try:
if use_agent:
parser = ThinkTagParser() # 内置 agent 走自有路径,本变量保留以避免类型告警
_ = parser
def _on_text(text: str, is_thinking: bool, _i: int = i, _t: str = sec_title) -> None:
writer({
"type": "draft_chunk",
@@ -312,36 +315,78 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
config=config,
on_text=_on_text,
)
section_succeeded = True
else:
async for chunk in model.astream([
SystemMessage(content=DRAFT_SECTION_SYSTEM),
HumanMessage(content=user_msg),
]):
# 推理模型的思考走 reasoning_content(content 为空):作为思考实时推送
reasoning = reasoning_delta(chunk)
if reasoning:
writer({
"type": "draft_chunk",
"section_index": i,
"section_title": sec_title,
"text": reasoning,
"is_thinking": True,
"is_section_done": False,
})
delta = _content_to_text(chunk.content)
if not delta:
continue
for is_thinking, text in parser.feed(delta):
if not is_thinking:
section_buf += text
writer({
"type": "draft_chunk",
"section_index": i,
"section_title": sec_title,
"text": text,
"is_thinking": is_thinking,
"is_section_done": False,
})
# 旧路径:按 fallback_chain 试候选模型,失败就换下一个继续本章节。
# 以「上一节成功使用的模型」为头部 —— 同 config.yaml 顺序的链尾再补全。
section_chain: list[str] = []
if current_model_name:
section_chain.append(current_model_name)
for n in draft_chain:
if n and n not in section_chain:
section_chain.append(n)
if not section_chain:
section_chain = [None] # type: ignore[list-item]
last_section_exc: BaseException | None = None
for s_idx, candidate in enumerate(section_chain):
section_buf = ""
parser = ThinkTagParser()
try:
m = create_chat_model(name=candidate) if candidate else create_chat_model()
async for chunk in m.astream([
SystemMessage(content=DRAFT_SECTION_SYSTEM),
HumanMessage(content=user_msg),
]):
reasoning = reasoning_delta(chunk)
if reasoning:
writer({
"type": "draft_chunk",
"section_index": i,
"section_title": sec_title,
"text": reasoning,
"is_thinking": True,
"is_section_done": False,
})
delta = _content_to_text(chunk.content)
if not delta:
continue
for is_thinking, text in parser.feed(delta):
if not is_thinking:
section_buf += text
writer({
"type": "draft_chunk",
"section_index": i,
"section_title": sec_title,
"text": text,
"is_thinking": is_thinking,
"is_section_done": False,
})
section_succeeded = True
current_model_name = candidate or current_model_name
break
except Exception as e:
last_section_exc = e
reason = classify_exception(e)
logger.error(
"draft section [%s] failed on model=%s (reason=%s): %s",
sec_title, candidate, reason, e, exc_info=True,
)
next_candidate = section_chain[s_idx + 1] if s_idx + 1 < len(section_chain) else None
if next_candidate:
emit_model_switch_event(
writer, agent_name=agent_label, stage="draft",
failed_model=candidate or "", next_model=next_candidate,
reason=reason, section_index=i, section_title=sec_title,
)
switch_notices.append(build_switch_progress_event(
agent_name=agent_label, stage="draft",
failed_model=candidate or "", next_model=next_candidate,
reason=reason, section_index=i, section_title=sec_title,
))
continue
if not section_succeeded:
assert last_section_exc is not None
raise last_section_exc
# 章节完成
writer({
@@ -387,6 +432,7 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
"status": "awaiting_section_help",
"progress_events": [
{"type": "step_start", "agent_name": agent_label, "message": f"开始写作(第{revision_count+1}稿)"},
*switch_notices,
{
"type": "notice", "agent_name": agent_label, "title": "素材不足",
"detail": f"有 {len(blocked_sections)} 个章节缺少足够素材支撑写作,需要你决定如何处理。",
@@ -432,7 +478,9 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
on_text=None,
)).strip()
else:
resp = await model.ainvoke([
# 扩写是兜底优化,不再做模型链兜底:拿当前已成功的模型试一次即可,失败就保留原文。
expand_model = create_chat_model(name=current_model_name) if current_model_name else create_chat_model()
resp = await expand_model.ainvoke([
SystemMessage(content=DRAFT_SECTION_SYSTEM),
HumanMessage(content=expand_msg),
])
@@ -479,6 +527,7 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
"status": "awaiting_draft_confirm",
"progress_events": [
{"type": "step_start", "agent_name": agent_label, "message": f"开始写作(第{revision_count+1}稿)"},
*switch_notices,
*notices,
{
"type": "draft_ready",
@@ -29,7 +29,13 @@ from ._agent_runner import (
stream_writing_agent,
use_builtin_agent,
)
from ._utils import ThinkTagParser, _content_to_text, get_writing_model, llm_json, parse_json, reasoning_delta, safe_get_stream_writer
from ._model_fallback import (
build_switch_progress_event,
classify_exception,
emit_model_switch_event,
fallback_chain,
)
from ._utils import ThinkTagParser, _content_to_text, llm_json, parse_json, reasoning_delta, resolve_writing_model_name, safe_get_stream_writer
logger = logging.getLogger(__name__)
@@ -292,10 +298,11 @@ async def writer_outline_node(state: AIWritingState, config: RunnableConfig) ->
writer = safe_get_stream_writer()
full_text = "" # 只累积正文(非思考)用于 JSON 解析
parser = ThinkTagParser()
extra_progress_events: list[dict] = []
try:
if use_agent:
if use_agent:
# 内置 agent 路径走 stream_writing_agent,不在本模块做模型链兜底(agent 自带模型解析)。
try:
def _on_text(text: str, is_thinking: bool) -> None:
writer({"type": "outline_chunk", "text": text, "is_thinking": is_thinking})
@@ -306,41 +313,81 @@ async def writer_outline_node(state: AIWritingState, config: RunnableConfig) ->
config=config,
on_text=_on_text,
)
else:
# get_writing_model 放进 try:用户模型缺失 / 模型名配错时抛 ValueError,
# 留在 try 外会硬崩溃;放进来则统一走下方的 status:error 兜底。
model = get_writing_model(config, state)
async for chunk in model.astream([
SystemMessage(content=system_prompt),
HumanMessage(content=user_prompt),
]):
# 推理模型的思考走 reasoning_content(content 为空):作为思考实时推送
reasoning = reasoning_delta(chunk)
if reasoning:
writer({"type": "outline_chunk", "text": reasoning, "is_thinking": True})
delta = _content_to_text(chunk.content)
if not delta:
except Exception as e:
logger.error("outline streaming failed: %s", e, exc_info=True)
return {
"status": "error",
"progress_events": [{
"type": "error",
"agent_name": agent_label,
"title": "模型错误",
"detail": str(e),
"message": "大纲生成失败",
}],
}
else:
# 旧路径:按 fallback_chain 顺序尝试,失败即换下一个模型重跑该环节。
initial_name, _used_default = resolve_writing_model_name(config, state)
chain = fallback_chain(initial_name)
from deerflow.models import create_chat_model
success = False
last_exc: BaseException | None = None
for idx, candidate in enumerate(chain or [None]):
full_text = ""
parser = ThinkTagParser()
try:
model = create_chat_model(name=candidate) if candidate else create_chat_model()
async for chunk in model.astream([
SystemMessage(content=system_prompt),
HumanMessage(content=user_prompt),
]):
reasoning = reasoning_delta(chunk)
if reasoning:
writer({"type": "outline_chunk", "text": reasoning, "is_thinking": True})
delta = _content_to_text(chunk.content)
if not delta:
continue
for is_thinking, text in parser.feed(delta):
if not is_thinking:
full_text += text
writer({
"type": "outline_chunk",
"text": text,
"is_thinking": is_thinking,
})
success = True
break
except Exception as e:
last_exc = e
reason = classify_exception(e)
logger.error(
"outline streaming failed on model=%s (reason=%s): %s",
candidate, reason, e, exc_info=True,
)
next_candidate = chain[idx + 1] if idx + 1 < len(chain) else None
if next_candidate:
emit_model_switch_event(
writer, agent_name=agent_label, stage="outline",
failed_model=candidate or "", next_model=next_candidate, reason=reason,
)
extra_progress_events.append(build_switch_progress_event(
agent_name=agent_label, stage="outline",
failed_model=candidate or "", next_model=next_candidate, reason=reason,
))
continue
for is_thinking, text in parser.feed(delta):
if not is_thinking:
full_text += text
writer({
"type": "outline_chunk",
"text": text,
"is_thinking": is_thinking,
})
except Exception as e:
logger.error("outline streaming failed: %s", e, exc_info=True)
return {
"status": "error",
"progress_events": [{
"type": "error",
"agent_name": agent_label,
"title": "模型错误",
"detail": str(e),
"message": "大纲生成失败",
}],
}
if not success:
assert last_exc is not None
return {
"status": "error",
"progress_events": extra_progress_events + [{
"type": "error",
"agent_name": agent_label,
"title": "模型错误",
"detail": str(last_exc),
"message": "大纲生成失败(所有候选模型均失败)",
}],
}
# 编辑模式解析失败时回退为「当前大纲」,避免把用户已有大纲弄丢
if is_revise:
@@ -387,6 +434,7 @@ async def writer_outline_node(state: AIWritingState, config: RunnableConfig) ->
"status": "awaiting_outline_confirm",
"progress_events": [
{"type": "step_start", "agent_name": agent_label, "message": "开始规划大纲"},
*extra_progress_events,
{"type": "outline_ready", "agent_name": agent_label, "message": f"大纲已生成,共 {section_count} 个章节"},
],
}
@@ -41,10 +41,17 @@ def get_default_store() -> RoundtableDiagnosticRepository | None:
async def record_diagnostic(store: RoundtableDiagnosticRepository | None = None, **fields: Any) -> None:
"""Best-effort 写一条诊断事件。``store`` 缺省用进程级默认仓储。任何异常静默吞掉。"""
"""Best-effort 写一条诊断事件。``store`` 缺省用进程级默认仓储。任何异常静默吞掉。
会商诊断**只收集报错日志**:``info`` 级是纯运行进度(begin/consensus/gather/job_started
等「运行日志」),对排障没价值、还会淹没真正的报错,直接不落库;只保留 ``warning`` /
``error``(席位失败、空交付、超时、模型容错、产物缺失、崩溃等)。
"""
repo = store or _default_store
if repo is None:
return
if str(fields.get("level") or "").strip().lower() == "info":
return
try:
await repo.record(**fields)
except Exception: # noqa: BLE001 — 诊断日志写失败绝不打断会商
@@ -11,6 +11,7 @@ All write/read paths are scoped by ``user_id`` for ownership isolation.
from __future__ import annotations
import json
import re
from datetime import UTC, datetime
from typing import Any
@@ -24,6 +25,29 @@ from deerflow.persistence.roundtable_drafts.model import (
_UNSET = object()
# 业务链名抽取:在已加载进内存的 step2 JSON 串里直接找 `"chain":{...,"title":"X"}`,
# 不整体 json.loads(step2 是 LONGTEXT 大块),只做一次廉价正则扫描。`"chain":null`
# (recommend/自由模式)不匹配 → 返回 None。`[^{}]*?` 限定在 chain 对象内({id,title}
# 无嵌套花括号)。
_CHAIN_TITLE_RE = re.compile(r'"chain"\s*:\s*\{[^{}]*?"title"\s*:\s*"((?:\\.|[^"\\])*)"')
def _extract_chain_title(step2_raw: Any) -> str | None:
"""从 step2 原始 JSON 串里抽取所用业务链名(无链条 → None,前端显示「自由模式」)。"""
if not isinstance(step2_raw, str) or not step2_raw:
return None
m = _CHAIN_TITLE_RE.search(step2_raw)
if not m:
return None
title = m.group(1)
try:
# 还原 JSON 字符串转义(\" \\ \n \uXXXX 等)。
title = json.loads(f'"{title}"')
except Exception:
pass
title = title.strip()
return title or None
def _loads(value: Any) -> Any:
if not isinstance(value, str):
@@ -56,6 +80,8 @@ class RoundtableDraftRepository:
"task_id": row.task_id,
"title": row.title,
"furthest_step": row.furthest_step,
# 所用业务链名(历史卡片展示用);无链条 → None → 前端显示「自由模式」。
"chain_title": _extract_chain_title(row.step2),
"created_at": row.created_at.isoformat() if isinstance(row.created_at, datetime) else row.created_at,
"updated_at": row.updated_at.isoformat() if isinstance(row.updated_at, datetime) else row.updated_at,
}
@@ -15,6 +15,7 @@ in ``PortableLongText`` and round-tripped with ``json.dumps`` / ``json.loads``.
from __future__ import annotations
import json
import re
from datetime import UTC, datetime
from typing import Any
@@ -25,6 +26,27 @@ from deerflow.persistence.roundtable_task_drafts.model import RoundtableTaskDraf
_UNSET = object()
# 业务链名抽取:在已加载进内存的 step2 JSON 串里直接找 `"chain":{...,"title":"X"}`,
# 不整体 json.loads(step2 是 LONGTEXT 大块)。`"chain":null`(自由模式)不匹配 → None。
# 与 roundtable_drafts/sql.py 保持一致。
_CHAIN_TITLE_RE = re.compile(r'"chain"\s*:\s*\{[^{}]*?"title"\s*:\s*"((?:\\.|[^"\\])*)"')
def _extract_chain_title(step2_raw: Any) -> str | None:
"""从 step2 原始 JSON 串里抽取所用业务链名(无链条 → None,前端显示「自由模式」)。"""
if not isinstance(step2_raw, str) or not step2_raw:
return None
m = _CHAIN_TITLE_RE.search(step2_raw)
if not m:
return None
title = m.group(1)
try:
title = json.loads(f'"{title}"')
except Exception:
pass
title = title.strip()
return title or None
def _loads(value: Any) -> Any:
if not isinstance(value, str):
@@ -55,6 +77,8 @@ class RoundtableTaskDraftRepository:
"task_id": row.task_id,
"title": row.title,
"furthest_step": row.furthest_step,
# 所用业务链名(历史卡片展示用);无链条 → None → 前端显示「自由模式」。
"chain_title": _extract_chain_title(row.step2),
"created_at": row.created_at.isoformat() if isinstance(row.created_at, datetime) else row.created_at,
"updated_at": row.updated_at.isoformat() if isinstance(row.updated_at, datetime) else row.updated_at,
}