diff --git a/frontend-web/src/components/knowledge-graph/GraphCanvas.tsx b/frontend-web/src/components/knowledge-graph/GraphCanvas.tsx index 8b3b3bc7..84987919 100644 --- a/frontend-web/src/components/knowledge-graph/GraphCanvas.tsx +++ b/frontend-web/src/components/knowledge-graph/GraphCanvas.tsx @@ -245,9 +245,10 @@ export const GraphCanvas = forwardRef( // first paint is already roughly laid out instead of an exploding // cloud that drifts out of the viewport. if (props.layoutType === "force") { - const deadline = performance.now() + 120; + const budget = simNodes.length > 350 ? 280 : 120; + const deadline = performance.now() + budget; let guard = 0; - while (sim.alpha > 0.25 && guard < 300 && performance.now() < deadline) { + while (sim.alpha > 0.25 && guard < 600 && performance.now() < deadline) { sim.tick(); guard += 1; } @@ -295,9 +296,15 @@ export const GraphCanvas = forwardRef( const clampZoom = (k: number) => Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, k)); + // Notify React of zoom changes only when k actually moved — a state + // update per animation frame re-renders the whole page while settling. + const lastNotifiedZoomRef = useRef(0); const setTransform = useCallback((t: Transform) => { transformRef.current = t; - propsRef.current.onZoomChange(t.k); + if (Math.abs(t.k - lastNotifiedZoomRef.current) > 0.004) { + lastNotifiedZoomRef.current = t.k; + propsRef.current.onZoomChange(t.k); + } needsDrawRef.current = true; }, []); @@ -326,32 +333,39 @@ export const GraphCanvas = forwardRef( [setTransform], ); + const computeFitTransform = useCallback((): Transform | null => { + const sim = simRef.current; + const { w, h } = sizeRef.current; + if (!sim || !sim.nodes.length || !w || !h) return null; + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const n of sim.nodes) { + minX = Math.min(minX, n.x - n.radius); + minY = Math.min(minY, n.y - n.radius); + maxX = Math.max(maxX, n.x + n.radius); + maxY = Math.max(maxY, n.y + n.radius); + } + // A single bad coordinate must not poison the viewport transform. + if (!Number.isFinite(minX + minY + maxX + maxY)) return null; + const pad = 70; + const bw = Math.max(maxX - minX, 1); + const bh = Math.max(maxY - minY, 1); + const k = clampZoom(Math.min((w - pad * 2) / bw, (h - pad * 2) / bh, 2.2)); + const cx = (minX + maxX) / 2; + const cy = (minY + maxY) / 2; + return { x: w / 2 - cx * k, y: h / 2 - cy * k, k }; + }, []); + const fitView = useCallback( (animated = true) => { - const sim = simRef.current; - const { w, h } = sizeRef.current; - if (!sim || !sim.nodes.length || !w || !h) return; - let minX = Infinity; - let minY = Infinity; - let maxX = -Infinity; - let maxY = -Infinity; - for (const n of sim.nodes) { - minX = Math.min(minX, n.x - n.radius); - minY = Math.min(minY, n.y - n.radius); - maxX = Math.max(maxX, n.x + n.radius); - maxY = Math.max(maxY, n.y + n.radius); - } - const pad = 70; - const bw = Math.max(maxX - minX, 1); - const bh = Math.max(maxY - minY, 1); - const k = clampZoom(Math.min((w - pad * 2) / bw, (h - pad * 2) / bh, 2.2)); - const cx = (minX + maxX) / 2; - const cy = (minY + maxY) / 2; - const target = { x: w / 2 - cx * k, y: h / 2 - cy * k, k }; + const target = computeFitTransform(); + if (!target) return; if (animated) animateTo(target); else setTransform(target); }, - [animateTo, setTransform], + [animateTo, computeFitTransform, setTransform], ); const focusNode = useCallback( @@ -527,11 +541,13 @@ export const GraphCanvas = forwardRef( const r = baseRadius * (n.id === hover?.id ? 1.15 : 1); ctx.globalAlpha = alpha; - // Glow for hubs and the active node. - if ( - alpha > 0.5 && - (isActive || isSelected || isExploreRoot || isExpanded || n.degree >= labelThreshold) - ) { + // Glow for hubs and the active node. Canvas shadows are expensive, + // so on large graphs only the focused nodes keep theirs. + const glowEligible = + sim.nodes.length <= 450 + ? isActive || isSelected || isExploreRoot || isExpanded || n.degree >= labelThreshold + : isActive || isSelected || isExploreRoot; + if (alpha > 0.5 && glowEligible) { ctx.shadowColor = fillColor; ctx.shadowBlur = isActive || isSelected || isExploreRoot ? 24 : 12; } else { @@ -692,6 +708,7 @@ export const GraphCanvas = forwardRef( }; if (progress >= 1) { animRef.current = null; + lastNotifiedZoomRef.current = transformRef.current.k; propsRef.current.onZoomChange(transformRef.current.k); } active = true; @@ -699,10 +716,19 @@ export const GraphCanvas = forwardRef( if (sim && layoutRef.current === "force" && sim.tick()) { active = true; - // Until the user pans/zooms/drags, keep the settling layout fully in - // view every frame so nodes never drift out of the viewport. + // Until the user pans/zooms/drags, gently track the settling layout + // so it stays in view without the viewport snapping every frame. if (!interactedRef.current && sizeRef.current.w) { - fitView(false); + const target = computeFitTransform(); + if (target) { + const t = transformRef.current; + const ease = t.k === 0 ? 1 : 0.16; + setTransform({ + x: t.x + (target.x - t.x) * ease, + y: t.y + (target.y - t.y) * ease, + k: t.k + (target.k - t.k) * ease, + }); + } autoFitRef.current = true; } else if (!autoFitRef.current && sim.alpha < 0.3) { autoFitRef.current = true; @@ -721,7 +747,7 @@ export const GraphCanvas = forwardRef( }; rafRef.current = requestAnimationFrame(loop); return () => cancelAnimationFrame(rafRef.current); - }, [draw, fitView]); + }, [computeFitTransform, draw, fitView, setTransform]); // --------------------------------------------------------------- resize @@ -801,7 +827,6 @@ export const GraphCanvas = forwardRef( const handlePointerDown = (e: React.PointerEvent) => { if (e.button !== 0) return; - interactedRef.current = true; const { sx, sy } = localPoint(e); const { x: wx, y: wy } = screenToWorld(sx, sy); const node = hitTest(wx, wy); @@ -828,7 +853,12 @@ export const GraphCanvas = forwardRef( if (drag && drag.pointerId === e.pointerId) { const dx = sx - drag.startX; const dy = sy - drag.startY; - if (Math.abs(dx) + Math.abs(dy) > 3) drag.moved = true; + if (Math.abs(dx) + Math.abs(dy) > 3) { + drag.moved = true; + // Only a real pan/drag takes over the camera; a plain click keeps + // the auto-follow so the settling graph can't drift out of view. + interactedRef.current = true; + } if (drag.node) { const world = screenToWorld(sx, sy); drag.node.fx = world.x; diff --git a/frontend-web/src/components/knowledge-graph/graphUtils.ts b/frontend-web/src/components/knowledge-graph/graphUtils.ts index b5331630..35e9cab4 100644 --- a/frontend-web/src/components/knowledge-graph/graphUtils.ts +++ b/frontend-web/src/components/knowledge-graph/graphUtils.ts @@ -239,15 +239,41 @@ export function applyCircularLayout(nodes: SimNode[]): void { export class ForceSimulation { alpha = 1; readonly alphaMin = 0.004; - private readonly alphaDecay = 0.0228; + private readonly alphaDecay: number; private readonly velocityDecay = 0.38; - private readonly chargeStrength = -420; - private readonly chargeCutoff2 = 600 * 600; + private readonly chargeStrength: number; + private readonly chargeCutoff: number; + private readonly chargeCutoff2: number; + /** Hard cap on per-tick movement so dense graphs can't oscillate or fly off. */ + private readonly maxSpeed = 30; constructor( public nodes: SimNode[], public edges: SimEdge[], - ) {} + ) { + // Scale forces down and cool faster as the graph grows — with the + // small-graph constants a few hundred nodes keep shoving each other + // around for minutes and the layout never settles. + const n = nodes.length; + if (n > 600) { + this.chargeStrength = -160; + this.chargeCutoff = 260; + this.alphaDecay = 0.05; + } else if (n > 300) { + this.chargeStrength = -240; + this.chargeCutoff = 340; + this.alphaDecay = 0.038; + } else if (n > 150) { + this.chargeStrength = -320; + this.chargeCutoff = 440; + this.alphaDecay = 0.03; + } else { + this.chargeStrength = -420; + this.chargeCutoff = 600; + this.alphaDecay = 0.0228; + } + this.chargeCutoff2 = this.chargeCutoff * this.chargeCutoff; + } reheat(alpha = 0.9): void { this.alpha = Math.max(this.alpha, alpha); @@ -276,31 +302,8 @@ export class ForceSimulation { s.vy += dy * 0.5; } - // Many-body repulsion with a distance cutoff. - for (let i = 0; i < nodes.length; i++) { - const ni = nodes[i]; - for (let j = i + 1; j < nodes.length; j++) { - const nj = nodes[j]; - let dx = nj.x - ni.x; - let dy = nj.y - ni.y; - let d2 = dx * dx + dy * dy; - if (d2 > this.chargeCutoff2) continue; - if (d2 < 1) { - // Deterministic nudge for coincident nodes. - dx = ((i % 3) - 1) * 0.5 || 0.5; - dy = ((j % 3) - 1) * 0.5 || -0.5; - d2 = dx * dx + dy * dy; - } - const d = Math.sqrt(d2); - const f = (this.chargeStrength * a) / d2; - const fx = (dx / d) * f; - const fy = (dy / d) * f; - ni.vx += fx; - ni.vy += fy; - nj.vx -= fx; - nj.vy -= fy; - } - } + // Repulsion + collision over nearby pairs only (uniform grid). + this.applyPairForces(a); // Gentle pull toward the origin keeps disconnected clusters on screen. for (const n of nodes) { @@ -308,33 +311,9 @@ export class ForceSimulation { n.vy += (0 - n.y) * 0.05 * a; } - // Positional collision resolution (prevents node overlap). - for (let i = 0; i < nodes.length; i++) { - const ni = nodes[i]; - for (let j = i + 1; j < nodes.length; j++) { - const nj = nodes[j]; - const minDist = ni.radius + nj.radius + 8; - let dx = nj.x - ni.x; - let dy = nj.y - ni.y; - const d2 = dx * dx + dy * dy; - if (d2 >= minDist * minDist || d2 === 0) continue; - const d = Math.sqrt(d2); - const push = ((minDist - d) / d) * 0.5; - dx *= push; - dy *= push; - if (ni.fx == null) { - ni.x -= dx; - ni.y -= dy; - } - if (nj.fx == null) { - nj.x += dx; - nj.y += dy; - } - } - } - // Integrate velocities. - for (const n of nodes) { + for (let i = 0; i < nodes.length; i++) { + const n = nodes[i]; if (n.fx != null && n.fy != null) { n.x = n.fx; n.y = n.fy; @@ -343,10 +322,113 @@ export class ForceSimulation { } else { n.vx *= 1 - this.velocityDecay; n.vy *= 1 - this.velocityDecay; + const speed = Math.sqrt(n.vx * n.vx + n.vy * n.vy); + if (speed > this.maxSpeed) { + const s = this.maxSpeed / speed; + n.vx *= s; + n.vy *= s; + } n.x += n.vx; n.y += n.vy; } + if (!Number.isFinite(n.x) || !Number.isFinite(n.y)) { + // A NaN/Infinity coordinate would blank the whole canvas (the + // bounding box and fit transform become NaN); park the node instead. + const r = 40 + 18 * Math.sqrt(i + 1); + n.x = r * Math.cos(i * GOLDEN_ANGLE); + n.y = r * Math.sin(i * GOLDEN_ANGLE); + n.vx = 0; + n.vy = 0; + } } return true; } + + /** + * Many-body repulsion + collision resolution, restricted to pairs within + * one grid cell (cell size = charge cutoff). Two nodes farther apart than + * the cutoff never interact, so binning by cell keeps the scan close to + * O(n) for spread-out layouts instead of O(n²) over all pairs. + */ + private applyPairForces(a: number): void { + const cellSize = this.chargeCutoff; + const grid = new Map(); + for (const n of this.nodes) { + const cx = Math.floor(n.x / cellSize); + const cy = Math.floor(n.y / cellSize); + const key = `${cx},${cy}`; + let bucket = grid.get(key); + if (!bucket) grid.set(key, (bucket = { cx, cy, items: [] })); + bucket.items.push(n); + } + // Half of the 8-neighborhood so every cell pair is visited exactly once. + const offsets = [ + [0, 0], + [1, 0], + [0, 1], + [1, 1], + [1, -1], + ] as const; + for (const bucket of grid.values()) { + for (const [ox, oy] of offsets) { + const other = + ox === 0 && oy === 0 + ? bucket + : grid.get(`${bucket.cx + ox},${bucket.cy + oy}`); + if (!other) continue; + const left = bucket.items; + const right = other.items; + if (other === bucket) { + for (let i = 0; i < left.length; i++) { + for (let j = i + 1; j < left.length; j++) { + this.pairForce(left[i], left[j], i, j, a); + } + } + } else { + for (let i = 0; i < left.length; i++) { + for (let j = 0; j < right.length; j++) { + this.pairForce(left[i], right[j], i, j, a); + } + } + } + } + } + } + + private pairForce(ni: SimNode, nj: SimNode, i: number, j: number, a: number): void { + let dx = nj.x - ni.x; + let dy = nj.y - ni.y; + let d2 = dx * dx + dy * dy; + if (d2 > this.chargeCutoff2) return; + if (d2 < 1) { + // Deterministic nudge for coincident nodes. + dx = ((i % 3) - 1) * 0.5 || 0.5; + dy = ((j % 3) - 1) * 0.5 || -0.5; + d2 = dx * dx + dy * dy; + } + const d = Math.sqrt(d2); + const f = (this.chargeStrength * a) / d2; + const fx = (dx / d) * f; + const fy = (dy / d) * f; + ni.vx += fx; + ni.vy += fy; + nj.vx -= fx; + nj.vy -= fy; + + // Positional collision resolution (prevents node overlap). + const minDist = ni.radius + nj.radius + 8; + if (d < minDist) { + const push = ((minDist - d) / d) * 0.5; + const px = dx * push; + const py = dy * push; + if (ni.fx == null) { + ni.x -= px; + ni.y -= py; + } + if (nj.fx == null) { + nj.x += px; + nj.y += py; + } + } + } } diff --git a/frontend-web/src/components/workspace/agents/agent-gallery.tsx b/frontend-web/src/components/workspace/agents/agent-gallery.tsx index d1f592b3..f1c11ddb 100644 --- a/frontend-web/src/components/workspace/agents/agent-gallery.tsx +++ b/frontend-web/src/components/workspace/agents/agent-gallery.tsx @@ -112,7 +112,7 @@ export function AgentGallery() { search, tagIds, sort, - page: pages.private, + page: pages.private ?? 1, pageSize: PAGE_SIZE, enabled: activeTab === "private", owner: publisher?.id, diff --git a/frontend-web/src/roundtable-planning/components/Step3Panel.tsx b/frontend-web/src/roundtable-planning/components/Step3Panel.tsx index d3e94e98..293fe2a1 100644 --- a/frontend-web/src/roundtable-planning/components/Step3Panel.tsx +++ b/frontend-web/src/roundtable-planning/components/Step3Panel.tsx @@ -16,6 +16,7 @@ import type { ModelOption } from "@/roundtable-planning/components/Step3ReportMo import type { Step2Dialogue } from "@/roundtable-planning/hooks/useStep2Orchestration"; import type { ReportIntent } from "@/roundtable-planning/hooks/useStep3Report"; import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent"; +import { stripReasoning } from "@/roundtable-planning/lib/reasoning"; export interface Step3PanelProps { threadIds: ThreadIdMap | null; @@ -76,7 +77,9 @@ export function Step3Panel(props: Step3PanelProps) { .map((d) => ({ sender: String(d.sender ?? ""), avatarType: String(d.avatarType ?? "blue"), - content: String(d.content ?? ""), + // 交付内容只保留模型生成的主体输出,剥离 `...` 推理过程 + // —— 既用于渲染各席位交付卡片,也会喂给总结/报告模型,避免推理噪声污染。 + content: stripReasoning(String(d.content ?? "")), })); const seatNameMap = Object.fromEntries( diff --git a/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts b/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts index 72089437..032fa03d 100644 --- a/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts +++ b/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts @@ -1201,8 +1201,9 @@ export function useStep2Orchestration( if (speakerId !== null && item.id === speakerId) { return { ...item, status: "研讨中" }; } + // 发言结束(不再研讨中)的席位标记为「已完成」,而非回退到「在线」。 if (item.status === "研讨中") { - return { ...item, status: "在线" }; + return { ...item, status: "已完成" }; } return item; }), @@ -1215,7 +1216,7 @@ export function useStep2Orchestration( (speakerId: number) => { // 编排循环已经改成**顺序**派 special(详见 §5.4 / 文件顶部注释), // 任意时刻只有一个子智能体在跑。因此这里既要把当前发言席位置为"研讨中", - // 又要把上一轮 / 历史残留下来的"研讨中"统一清成"在线",保证右侧"参与角色" + // 又要把上一轮 / 历史残留下来的"研讨中"统一标记成"已完成",保证右侧"参与角色" // 面板任何时刻**只有一个** sub agent 显示研讨中,与对话区实际在 stream 的 // 那一个一致。activeSpeakerSet 沿用 Set 形态(虽然实际只放 1 个)以兼容外部 // 消费方,真正的"高亮排他"靠 roles 的 status reset。 @@ -1223,7 +1224,8 @@ export function useStep2Orchestration( setRoles((prev) => prev.map((item) => { if (item.id === speakerId) return { ...item, status: "研讨中" }; - if (item.status === "研讨中") return { ...item, status: "在线" }; + // 上一轮发言已结束的席位标记为「已完成」,而非回退到「在线」。 + if (item.status === "研讨中") return { ...item, status: "已完成" }; return item; }), ); @@ -1241,8 +1243,9 @@ export function useStep2Orchestration( }); setRoles((prev) => prev.map((item) => + // 该席位本轮输出完成 —— 标记为「已完成」(带勾选样式),而非回退到「在线」。 item.id === speakerId && item.status === "研讨中" - ? { ...item, status: "在线" } + ? { ...item, status: "已完成" } : item, ), ); @@ -1254,14 +1257,15 @@ export function useStep2Orchestration( setActiveSpeakerSet(new Set()); setRoles((prev) => prev.map((item) => - item.status === "研讨中" ? { ...item, status: "在线" } : item, + // 研讨收尾:仍在研讨中的席位置为「已完成」,而非回退到「在线」。 + item.status === "研讨中" ? { ...item, status: "已完成" } : item, ), ); }, [setRoles]); /** * DAG 并行批次:把**一整批**席位同时标为「研讨中」(与 markSpeakerStreaming - * 的"排他单发"不同 —— 并行就是要多个 sub 同时高亮)。其余历史残留的"研讨中"清成"在线"。 + * 的"排他单发"不同 —— 并行就是要多个 sub 同时高亮)。其余历史残留的"研讨中"标记成"已完成"。 * activeSpeakerSet 天然是 Set,这里放整批 speakerId。 */ const markParallelSpeakers = useCallback( @@ -1271,7 +1275,8 @@ export function useStep2Orchestration( setRoles((prev) => prev.map((item) => { if (wanted.has(item.id)) return { ...item, status: "研讨中" }; - if (item.status === "研讨中") return { ...item, status: "在线" }; + // 上一并行批次发言已结束的席位标记为「已完成」,而非回退到「在线」。 + if (item.status === "研讨中") return { ...item, status: "已完成" }; return item; }), ); diff --git a/frontend-web/src/roundtable-planning/lib/reasoning.test.ts b/frontend-web/src/roundtable-planning/lib/reasoning.test.ts new file mode 100644 index 00000000..454865f1 --- /dev/null +++ b/frontend-web/src/roundtable-planning/lib/reasoning.test.ts @@ -0,0 +1,61 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { splitInlineReasoning, stripReasoning } from "./reasoning"; + +test("空输入返回空正文与 null 推理", () => { + assert.deepEqual(splitInlineReasoning(""), { content: "", reasoning: null }); +}); + +test("无 think 标签时原样返回正文", () => { + assert.deepEqual(splitInlineReasoning("纯正文,无推理"), { + content: "纯正文,无推理", + reasoning: null, + }); +}); + +test("形态 1:完整 … 提取推理,保留正文", () => { + const { content, reasoning } = splitInlineReasoning("先想一下这是答案"); + assert.equal(content, "这是答案"); + assert.equal(reasoning, "先想一下"); +}); + +test("形态 2:多段 think 合并推理", () => { + const { content, reasoning } = splitInlineReasoning( + "第一段正文A第二段正文B", + ); + assert.equal(content, "正文A正文B"); + assert.equal(reasoning, "第一段\n\n第二段"); +}); + +test("形态 3:流式未闭合 尾段算推理", () => { + const { content, reasoning } = splitInlineReasoning("正文还在想…"); + assert.equal(content, "正文"); + assert.equal(reasoning, "还在想…"); +}); + +test("形态 4:模板注入式只吐闭合 ,前导内容算推理", () => { + const { content, reasoning } = splitInlineReasoning( + "我需要分析一下需求这是最终方案", + ); + assert.equal(content, "这是最终方案"); + assert.equal(reasoning, "我需要分析一下需求"); +}); + +test("形态 4 流式:尚未吐出 时全部当正文(后续闭合再归位)", () => { + // 未出现任何标签 → 无从区分,先按正文渲染。 + const { content, reasoning } = splitInlineReasoning("正在推理中,还没收到闭合标签"); + assert.equal(content, "正在推理中,还没收到闭合标签"); + assert.equal(reasoning, null); +}); + +test("安全兜底:正文里绝不残留任何 think 标签字面量", () => { + const { content } = splitInlineReasoning("答案前答案中"); + assert.ok(!content.includes("")); + assert.ok(!content.includes("")); +}); + +test("stripReasoning 只返回正文", () => { + assert.equal(stripReasoning("推理交付正文"), "交付正文"); + assert.equal(stripReasoning("前导推理交付正文"), "交付正文"); +}); diff --git a/frontend-web/src/roundtable-planning/lib/reasoning.ts b/frontend-web/src/roundtable-planning/lib/reasoning.ts index 2567e51d..ecfbe5c1 100644 --- a/frontend-web/src/roundtable-planning/lib/reasoning.ts +++ b/frontend-web/src/roundtable-planning/lib/reasoning.ts @@ -1,15 +1,18 @@ /** * 把模型流式文本里的 `...` 块拆出来,作为「思考过程」单独展示。 * - * 兼容三种形态: + * 兼容四种形态: * 1) **完整** `foobar` —— 提取 `foo` 作 reasoning,正文为 `bar` * 2) **多段** `axby` —— reasoning 合并为 `a\n\nb`,正文为 `xy` * 3) **流式未闭合** `barpartial...` —— reasoning 为 `partial...`(尾段),正文为 `bar` + * 4) **模板注入式(只吐闭合)** `foobar` —— 开标签 `` 被 chat template + * 注入到 prompt 里、模型只生成闭合标签(DeepSeek-R1 / QwQ 等推理模型的典型行为)。 + * 此时首个 `` 之前没有配对的 ``,把它之前的内容整体算 reasoning,正文为 `bar`。 * * 实现思路与主聊天 `src/core/messages/utils.ts::splitInlineReasoning` 对齐,但 - * 这里**额外处理了未闭合的 ``** —— 流式渲染时如果模型还没吐出 ``, - * 主聊天那份实现会把 `` 字面量留在正文里,我们用 lastIndexOf 兜底,把尾段 - * 也算作 reasoning。 + * 这里**额外处理了未闭合的 ``(形态 3)和只吐闭合的 ``(形态 4)** —— + * 主聊天靠后端 `additional_kwargs.reasoning_content` 单列推理,而圆桌多智能体流式只取 + * `content` 文本,推理标签会内联在正文里,故这里需要把这两种内联形态都兜住。 * * Inline `foo` 在文本中间出现(无尾闭合)被认为是流式未闭合的开头: * 假设之后会闭合,把它之后所有内容算 reasoning。这与 DeepSeek-R1 / QwQ 等 @@ -18,6 +21,9 @@ const THINK_TAG_RE = /\s*([\s\S]*?)\s*<\/think>/g; const OPEN_TAG = ""; +const CLOSE_TAG = ""; +// 兜底:正文里绝不应残留任何 think 标签字面量,清掉任何孤立的 `` / ``。 +const ANY_THINK_TAG_RE = /<\/?think>/gi; export interface SplitReasoningResult { /** 正文(去掉所有 `...` 块以及尾段未闭合 `` 之后内容)。 */ @@ -30,14 +36,27 @@ export function splitInlineReasoning(text: string): SplitReasoningResult { if (!text) return { content: "", reasoning: null }; const reasoningParts: string[] = []; - // 先处理所有完整 `...` 闭合块。 - const closed = text.replace(THINK_TAG_RE, (_, reasoning: string) => { + + // 形态 4(模板注入式,只吐闭合):首个 `` 出现在任何 `` 之前(或根本 + // 没有 ``),说明开标签被 chat template 注入、模型只生成了闭合标签。把第一个 + // `` 之前的内容整体算作 reasoning,正文从闭合标签之后继续解析。 + let working = text; + const firstClose = working.indexOf(CLOSE_TAG); + const firstOpen = working.indexOf(OPEN_TAG); + if (firstClose >= 0 && (firstOpen < 0 || firstClose < firstOpen)) { + const lead = working.slice(0, firstClose).trim(); + if (lead) reasoningParts.push(lead); + working = working.slice(firstClose + CLOSE_TAG.length); + } + + // 形态 1/2:处理所有完整 `...` 闭合块。 + const closed = working.replace(THINK_TAG_RE, (_, reasoning: string) => { const normalized = reasoning.trim(); if (normalized) reasoningParts.push(normalized); return ""; }); - // 处理尾段未闭合 `` —— 流式情况。 + // 形态 3:尾段未闭合 `` —— 流式情况。 // 如果 closed 里还残留 `` 字面量,说明这一段还没收到 ``, // 把它之后的所有内容视为 reasoning-in-progress,正文截到 `` 之前。 let finalContent = closed; @@ -48,8 +67,16 @@ export function splitInlineReasoning(text: string): SplitReasoningResult { finalContent = closed.slice(0, openIdx); } + // 安全兜底:无论上面是否完全配对,正文里都不该再出现 think 标签字面量。 + finalContent = finalContent.replace(ANY_THINK_TAG_RE, ""); + return { content: finalContent.trim(), reasoning: reasoningParts.length > 0 ? reasoningParts.join("\n\n").trim() : null, }; } + +/** 只要正文、丢弃推理过程的便捷封装(用于「交付内容」类只需主体输出的场景)。 */ +export function stripReasoning(text: string): string { + return splitInlineReasoning(text).content; +} diff --git a/frontend-web/src/roundtable-planning/lib/roundtable-deliverables.test.ts b/frontend-web/src/roundtable-planning/lib/roundtable-deliverables.test.ts index b8beb631..da613cc5 100644 --- a/frontend-web/src/roundtable-planning/lib/roundtable-deliverables.test.ts +++ b/frontend-web/src/roundtable-planning/lib/roundtable-deliverables.test.ts @@ -79,3 +79,15 @@ test("coordinatorName 为 null 时不剔除任何席位", () => { const result = collectRoundtableDeliverables(dialogues, null); assert.equal(result.length, 2); }); + +test("交付正文剥离 … 推理过程", () => { + const dialogues = [ + bubble({ sender: "方案设计", content: "盘算一下方案正文" }), + bubble({ sender: "风险审查", content: "前导推理风险正文" }), + ]; + const result = collectRoundtableDeliverables(dialogues, null); + assert.deepEqual(result, [ + { agentName: "方案设计", content: "方案正文", threadId: null }, + { agentName: "风险审查", content: "风险正文", threadId: null }, + ]); +}); diff --git a/frontend-web/src/roundtable-planning/lib/roundtable-deliverables.ts b/frontend-web/src/roundtable-planning/lib/roundtable-deliverables.ts index 5219e7ec..8878fae9 100644 --- a/frontend-web/src/roundtable-planning/lib/roundtable-deliverables.ts +++ b/frontend-web/src/roundtable-planning/lib/roundtable-deliverables.ts @@ -1,4 +1,5 @@ import type { Step2Dialogue } from "../hooks/useStep2Orchestration"; +import { stripReasoning } from "./reasoning"; /** * 回传父页面(无界)的单个智能体交付。 @@ -29,7 +30,8 @@ export function collectRoundtableDeliverables( } return Array.from(lastBySeat.values()).map((d) => ({ agentName: d.sender, - content: d.content, + // 回传宿主的交付正文剥离 `...` 推理过程,只留主体输出。 + content: stripReasoning(d.content), threadId: d.agentThreadId ?? null, })); } diff --git a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx index bcf6cd2b..c382ceba 100644 --- a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx @@ -2422,6 +2422,7 @@ function RoleAvatarBadge({ onSelect?: () => void; }) { const isOngoing = role.status === "研讨中"; + const isDone = role.status === "已完成"; const animate = showSpeakingAnimation && isOngoing; const clickable = Boolean(onSelect); @@ -2439,6 +2440,12 @@ function RoleAvatarBadge({
{renderRoleAvatar(role, agentNameToRole, size)}
+ {/* 收起态:输出完成的席位在头像右下角叠加「已完成」勾选角标。 */} + {isDone && ( + + + + )} ); diff --git a/offline-backend-20260512/backend/app/gateway/app.py b/offline-backend-20260512/backend/app/gateway/app.py index 0752db2d..fab956cf 100644 --- a/offline-backend-20260512/backend/app/gateway/app.py +++ b/offline-backend-20260512/backend/app/gateway/app.py @@ -40,6 +40,7 @@ from app.gateway.routers import ( html_page_favorites, intent, knowledge, + knowledge_ingest, light_apps, llm_metrics, mcp, @@ -1359,6 +1360,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an # Product knowledge base API is mounted at /api/knowledge app.include_router(knowledge.router) + # External knowledge ingest (publish md artifacts) at /api/knowledge-ingest + app.include_router(knowledge_ingest.router) + # System settings API is mounted at /api/system-settings app.include_router(system_settings.router) diff --git a/offline-backend-20260512/backend/app/gateway/roundtable_inprocess_gateway.py b/offline-backend-20260512/backend/app/gateway/roundtable_inprocess_gateway.py index a9502170..b8cd96d6 100644 --- a/offline-backend-20260512/backend/app/gateway/roundtable_inprocess_gateway.py +++ b/offline-backend-20260512/backend/app/gateway/roundtable_inprocess_gateway.py @@ -3,7 +3,7 @@ 实现 ``RoundtableGateway`` 协议(定义在 harness 的 roundtable_orchestrator.types), 但**进程内**跑真模型:复用 ``services.start_run``(scheduled task 同款)驱动一轮 agent, 跑完从 checkpointer 读最终 messages,用 ``multi_agent`` 的纯解析函数解析派活 / 澄清。 -不走 HTTP、不需要 Request(用 ``SimpleNamespace(app=app)`` 当虚拟 request, +不走 HTTP、不需要 Request(用 ``SimpleNamespace(app=app, state=...)`` 当虚拟 request, ``set_current_user`` 由执行器设好)。 本网关位于 app 层(依赖 app.state + multi_agent helpers),实现 harness 定义的协议—— @@ -192,7 +192,9 @@ async def _run_agent_turn( on_disconnect="continue", multitask_strategy="reject", ) - request = SimpleNamespace(app=app) + # 虚拟 request:带上空 ``state``(auth 中间件在真请求上会写 state.user / state.auth; + # 进程内没有这层,下游凡是读 request.state.X 的代码必须能拿到 None 而不是 AttributeError)。 + request = SimpleNamespace(app=app, state=SimpleNamespace(user=None, auth=None)) record = await start_run(body, thread_id, request) # type: ignore[arg-type] if getattr(record, "task", None) is not None: await record.task # 阻塞到这轮 agent 跑完,checkpoint 已落盘 diff --git a/offline-backend-20260512/backend/app/gateway/services.py b/offline-backend-20260512/backend/app/gateway/services.py index 359dba8b..12c8781b 100644 --- a/offline-backend-20260512/backend/app/gateway/services.py +++ b/offline-backend-20260512/backend/app/gateway/services.py @@ -493,7 +493,11 @@ async def _enforce_agent_access(body: Any, request: Request) -> None: user_id = get_effective_user_id() # Admins see every agent in the admin console listing (?admin=true), so # they may also start a run against any of them. - is_admin = getattr(getattr(request.state, "user", None), "system_role", None) == "admin" + # ``request`` may be an in-process virtual request (SimpleNamespace) from + # the roundtable job gateway / scheduled-task runtime — those carry no + # ``state`` attribute, so the whole chain must be getattr-guarded + # (a bare ``request.state`` raises AttributeError and kills the run). + is_admin = getattr(getattr(getattr(request, "state", None), "user", None), "system_role", None) == "admin" for agent_id in agent_ids: try: normalized = validate_agent_id(agent_id) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/service.py b/offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/service.py index aef85a5c..fdb6fc59 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/service.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/service.py @@ -1408,7 +1408,9 @@ class ScheduledTaskService: token = set_current_user(_RuntimeUser(id=task["user_id"])) try: - request = SimpleNamespace(app=self.app) + # 虚拟 request:带空 state(真请求由 auth 中间件写 state.user/state.auth; + # 进程内调用没有这层,下游读 request.state.X 必须拿到 None 而非 AttributeError)。 + request = SimpleNamespace(app=self.app, state=SimpleNamespace(user=None, auth=None)) execution_context = dict(task.get("execution_context") or {}) stored_agent_ref = task.get("execution_agent_name") if stored_agent_ref: