Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1
This commit is contained in:
commit
ca098e821f
14 files changed
+352
-111
No files matched your search
@@ -245,9 +245,10 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
// 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<GraphCanvasHandle, GraphCanvasProps>(
|
||||
|
||||
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<GraphCanvasHandle, GraphCanvasProps>(
|
||||
[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<GraphCanvasHandle, GraphCanvasProps>(
|
||||
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<GraphCanvasHandle, GraphCanvasProps>(
|
||||
};
|
||||
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<GraphCanvasHandle, GraphCanvasProps>(
|
||||
|
||||
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<GraphCanvasHandle, GraphCanvasProps>(
|
||||
};
|
||||
rafRef.current = requestAnimationFrame(loop);
|
||||
return () => cancelAnimationFrame(rafRef.current);
|
||||
}, [draw, fitView]);
|
||||
}, [computeFitTransform, draw, fitView, setTransform]);
|
||||
|
||||
// --------------------------------------------------------------- resize
|
||||
|
||||
@@ -801,7 +827,6 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
|
||||
const handlePointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
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<GraphCanvasHandle, GraphCanvasProps>(
|
||||
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;
|
||||
|
||||
@@ -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<string, { cx: number; cy: number; items: SimNode[] }>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ?? ""),
|
||||
// 交付内容只保留模型生成的主体输出,剥离 `<think>...</think>` 推理过程
|
||||
// —— 既用于渲染各席位交付卡片,也会喂给总结/报告模型,避免推理噪声污染。
|
||||
content: stripReasoning(String(d.content ?? "")),
|
||||
}));
|
||||
|
||||
const seatNameMap = Object.fromEntries(
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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:完整 <think>…</think> 提取推理,保留正文", () => {
|
||||
const { content, reasoning } = splitInlineReasoning("<think>先想一下</think>这是答案");
|
||||
assert.equal(content, "这是答案");
|
||||
assert.equal(reasoning, "先想一下");
|
||||
});
|
||||
|
||||
test("形态 2:多段 think 合并推理", () => {
|
||||
const { content, reasoning } = splitInlineReasoning(
|
||||
"<think>第一段</think>正文A<think>第二段</think>正文B",
|
||||
);
|
||||
assert.equal(content, "正文A正文B");
|
||||
assert.equal(reasoning, "第一段\n\n第二段");
|
||||
});
|
||||
|
||||
test("形态 3:流式未闭合 <think> 尾段算推理", () => {
|
||||
const { content, reasoning } = splitInlineReasoning("正文<think>还在想…");
|
||||
assert.equal(content, "正文");
|
||||
assert.equal(reasoning, "还在想…");
|
||||
});
|
||||
|
||||
test("形态 4:模板注入式只吐闭合 </think>,前导内容算推理", () => {
|
||||
const { content, reasoning } = splitInlineReasoning(
|
||||
"我需要分析一下需求</think>这是最终方案",
|
||||
);
|
||||
assert.equal(content, "这是最终方案");
|
||||
assert.equal(reasoning, "我需要分析一下需求");
|
||||
});
|
||||
|
||||
test("形态 4 流式:尚未吐出 </think> 时全部当正文(后续闭合再归位)", () => {
|
||||
// 未出现任何标签 → 无从区分,先按正文渲染。
|
||||
const { content, reasoning } = splitInlineReasoning("正在推理中,还没收到闭合标签");
|
||||
assert.equal(content, "正在推理中,还没收到闭合标签");
|
||||
assert.equal(reasoning, null);
|
||||
});
|
||||
|
||||
test("安全兜底:正文里绝不残留任何 think 标签字面量", () => {
|
||||
const { content } = splitInlineReasoning("答案前</think>答案中<think>");
|
||||
assert.ok(!content.includes("<think>"));
|
||||
assert.ok(!content.includes("</think>"));
|
||||
});
|
||||
|
||||
test("stripReasoning 只返回正文", () => {
|
||||
assert.equal(stripReasoning("<think>推理</think>交付正文"), "交付正文");
|
||||
assert.equal(stripReasoning("前导推理</think>交付正文"), "交付正文");
|
||||
});
|
||||
@@ -1,15 +1,18 @@
|
||||
/**
|
||||
* 把模型流式文本里的 `<think>...</think>` 块拆出来,作为「思考过程」单独展示。
|
||||
*
|
||||
* 兼容三种形态:
|
||||
* 兼容四种形态:
|
||||
* 1) **完整** `<think>foo</think>bar` —— 提取 `foo` 作 reasoning,正文为 `bar`
|
||||
* 2) **多段** `<think>a</think>x<think>b</think>y` —— reasoning 合并为 `a\n\nb`,正文为 `xy`
|
||||
* 3) **流式未闭合** `bar<think>partial...` —— reasoning 为 `partial...`(尾段),正文为 `bar`
|
||||
* 4) **模板注入式(只吐闭合)** `foo</think>bar` —— 开标签 `<think>` 被 chat template
|
||||
* 注入到 prompt 里、模型只生成闭合标签(DeepSeek-R1 / QwQ 等推理模型的典型行为)。
|
||||
* 此时首个 `</think>` 之前没有配对的 `<think>`,把它之前的内容整体算 reasoning,正文为 `bar`。
|
||||
*
|
||||
* 实现思路与主聊天 `src/core/messages/utils.ts::splitInlineReasoning` 对齐,但
|
||||
* 这里**额外处理了未闭合的 `<think>`** —— 流式渲染时如果模型还没吐出 `</think>`,
|
||||
* 主聊天那份实现会把 `<think>` 字面量留在正文里,我们用 lastIndexOf 兜底,把尾段
|
||||
* 也算作 reasoning。
|
||||
* 这里**额外处理了未闭合的 `<think>`(形态 3)和只吐闭合的 `</think>`(形态 4)** ——
|
||||
* 主聊天靠后端 `additional_kwargs.reasoning_content` 单列推理,而圆桌多智能体流式只取
|
||||
* `content` 文本,推理标签会内联在正文里,故这里需要把这两种内联形态都兜住。
|
||||
*
|
||||
* Inline `<think>foo` 在文本中间出现(无尾闭合)被认为是流式未闭合的开头:
|
||||
* 假设之后会闭合,把它之后所有内容算 reasoning。这与 DeepSeek-R1 / QwQ 等
|
||||
@@ -18,6 +21,9 @@
|
||||
|
||||
const THINK_TAG_RE = /<think>\s*([\s\S]*?)\s*<\/think>/g;
|
||||
const OPEN_TAG = "<think>";
|
||||
const CLOSE_TAG = "</think>";
|
||||
// 兜底:正文里绝不应残留任何 think 标签字面量,清掉任何孤立的 `<think>` / `</think>`。
|
||||
const ANY_THINK_TAG_RE = /<\/?think>/gi;
|
||||
|
||||
export interface SplitReasoningResult {
|
||||
/** 正文(去掉所有 `<think>...</think>` 块以及尾段未闭合 `<think>` 之后内容)。 */
|
||||
@@ -30,14 +36,27 @@ export function splitInlineReasoning(text: string): SplitReasoningResult {
|
||||
if (!text) return { content: "", reasoning: null };
|
||||
|
||||
const reasoningParts: string[] = [];
|
||||
// 先处理所有完整 `<think>...</think>` 闭合块。
|
||||
const closed = text.replace(THINK_TAG_RE, (_, reasoning: string) => {
|
||||
|
||||
// 形态 4(模板注入式,只吐闭合):首个 `</think>` 出现在任何 `<think>` 之前(或根本
|
||||
// 没有 `<think>`),说明开标签被 chat template 注入、模型只生成了闭合标签。把第一个
|
||||
// `</think>` 之前的内容整体算作 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:处理所有完整 `<think>...</think>` 闭合块。
|
||||
const closed = working.replace(THINK_TAG_RE, (_, reasoning: string) => {
|
||||
const normalized = reasoning.trim();
|
||||
if (normalized) reasoningParts.push(normalized);
|
||||
return "";
|
||||
});
|
||||
|
||||
// 处理尾段未闭合 `<think>` —— 流式情况。
|
||||
// 形态 3:尾段未闭合 `<think>` —— 流式情况。
|
||||
// 如果 closed 里还残留 `<think>` 字面量,说明这一段还没收到 `</think>`,
|
||||
// 把它之后的所有内容视为 reasoning-in-progress,正文截到 `<think>` 之前。
|
||||
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;
|
||||
}
|
||||
@@ -79,3 +79,15 @@ test("coordinatorName 为 null 时不剔除任何席位", () => {
|
||||
const result = collectRoundtableDeliverables(dialogues, null);
|
||||
assert.equal(result.length, 2);
|
||||
});
|
||||
|
||||
test("交付正文剥离 <think>…</think> 推理过程", () => {
|
||||
const dialogues = [
|
||||
bubble({ sender: "方案设计", content: "<think>盘算一下</think>方案正文" }),
|
||||
bubble({ sender: "风险审查", content: "前导推理</think>风险正文" }),
|
||||
];
|
||||
const result = collectRoundtableDeliverables(dialogues, null);
|
||||
assert.deepEqual(result, [
|
||||
{ agentName: "方案设计", content: "方案正文", threadId: null },
|
||||
{ agentName: "风险审查", content: "风险正文", threadId: null },
|
||||
]);
|
||||
});
|
||||
@@ -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,
|
||||
// 回传宿主的交付正文剥离 `<think>...</think>` 推理过程,只留主体输出。
|
||||
content: stripReasoning(d.content),
|
||||
threadId: d.agentThreadId ?? null,
|
||||
}));
|
||||
}
|
||||
@@ -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({
|
||||
<div className={cn("relative rounded-full", animate && "animate-pulse")}>
|
||||
{renderRoleAvatar(role, agentNameToRole, size)}
|
||||
</div>
|
||||
{/* 收起态:输出完成的席位在头像右下角叠加「已完成」勾选角标。 */}
|
||||
{isDone && (
|
||||
<span className="pointer-events-none absolute -bottom-0.5 -right-0.5 flex h-3.5 w-3.5 items-center justify-center rounded-full bg-emerald-500 ring-2 ring-white">
|
||||
<Check className="h-2.5 w-2.5 text-white" strokeWidth={3} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 已落盘
|
||||
|
||||
@@ -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)
|
||||
|
||||
+3
-1
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user