Merge branch 'dhk-1' into 'master'
feat(layout): 简洁模式右上角加换肤 + 界面模式下拉切换 See merge request knowledge-base-bj/deerflow-server!142
This commit is contained in:
commit
57ffe1a706
28 files changed
+1004
-267
No files matched your search
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
// ── 用户干预提交体 ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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).
|
||||
|
||||
+197
@@ -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)
|
||||
+57
-15
@@ -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",
|
||||
|
||||
+73
-39
@@ -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
|
||||
|
||||
+99
-50
@@ -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",
|
||||
|
||||
+86
-38
@@ -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} 个章节"},
|
||||
],
|
||||
}
|
||||
+8
-1
@@ -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 — 诊断日志写失败绝不打断会商
|
||||
|
||||
+26
@@ -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,
|
||||
}
|
||||
|
||||
+24
@@ -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,
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user