feat(agent): support Responses steering and project-aware tools

Use the dedicated TJWATER_AGENT_URL and Responses stream protocol. Keep running
turns active while users steer or queue follow-ups and edit pending delivery.
Refresh the model catalog before generation and preserve approval mode selection.

Integrate scoped investigation messaging/presentation, geographic place selection,
current Agent composer styles and supporting workbench contracts.

Validation: 77 focused Vitest checks, 21 browser cases, lint, typecheck and
production build. Preserve direct Server/Agent routing and project authentication.
This commit is contained in:
2026-10-19 18:46:58 +08:00
parent 2c5bf15cce
commit ec23d44b02
131 changed files with 6663 additions and 1204 deletions
@@ -0,0 +1,129 @@
import { expect, test } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api";
test("调查图加载与尺寸变化保持阅读位置,刷新后仍可缩放和查看节点", async ({ page }, testInfo) => {
await page.route("http://127.0.0.1:8787/api/v1/**", (route) => route.fulfill({ json: { items: [] } }));
await mockAgentApi(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.setContent('<!doctype html><html lang="zh-CN"><head><link rel="stylesheet" href="/src/styles.css"></head><body><main id="reader" style="max-width:640px;margin:auto;padding:16px;height:100vh;overflow:auto"></main></body></html>');
await page.evaluate(async () => {
const entry = await (await fetch("/src/main.tsx")).text();
const reactUrl = entry.match(/from "([^"\n]*\/react\.js[^"\n]*)"/)?.[1];
const clientUrl = entry.match(/from "([^"\n]*\/react-dom_client\.js[^"\n]*)"/)?.[1];
if (!reactUrl || !clientUrl) throw new Error("Vite React module paths unavailable");
const reactModule = await import(reactUrl);
const React = reactModule.default ?? reactModule;
const clientModule = await import(clientUrl);
const { createRoot } = clientModule.default ?? clientModule;
const { InvestigationDiagramView } = await import("/src/features/workbench/components/investigation-diagram-view.tsx");
const { investigationDiagramPlugins } = await import("/src/features/workbench/utils/investigation-diagram-style.ts");
const renderer = investigationDiagramPlugins.mermaid.getMermaid.bind(investigationDiagramPlugins.mermaid);
let release: (() => void) | undefined;
let renderPending = false;
investigationDiagramPlugins.mermaid.getMermaid = (config: unknown) => {
const instance = renderer(config);
return { ...instance, async render(id: string, source: string) {
renderPending = true;
await new Promise<void>((resolve) => { release = resolve; });
renderPending = false;
return instance.render(id, source);
} };
};
const root = createRoot(document.getElementById("reader"));
const documentHeights: number[] = [];
new MutationObserver(() => {
documentHeights.push(document.documentElement.scrollHeight);
}).observe(document.body, { childList: true, subtree: true, attributes: true });
let source = "flowchart TD\nA[读取工单] --> B[核对监测]\nB --> C[现场采样]\nC --> D[关联证据]\nD --> E[发布报告]";
const render = () => root.render(React.createElement(React.Fragment, null,
React.createElement(InvestigationDiagramView, {
diagram: { title: "调查刷新检查", mermaid: source,
node_refs: [{ node_id: "B", label: "核对监测", evidence_refs: ["e1"], action_refs: [] }] },
renderDetails: () => React.createElement("p", null, "监测记录的引用依据")
}),
React.createElement("section", { id: "reading-anchor", style: { height: 1200 } }, "当前正在阅读的证据")
));
Object.assign(window, { investigationFixture: {
release: () => release?.(), pending: () => renderPending,
update: (next: string) => { source = next; render(); }
}, documentHeights });
render();
});
const fixture = (action: "release" | "pending") => page.evaluate((key) => {
const api = (window as unknown as { investigationFixture: { release: () => void; pending: () => boolean } }).investigationFixture;
return api[key]();
}, action);
const diagram = page.getByRole("region", { name: "调查脉络", exact: true });
const viewport = diagram.locator(".investigation-diagram");
await expect.poll(() => fixture("pending")).toBe(true);
const loadingHeight = (await viewport.boundingBox())!.height;
await fixture("release");
await expect(diagram.locator("svg .node")).toHaveCount(5);
const renderHeights = await page.evaluate(() => (window as unknown as { documentHeights: number[] }).documentHeights);
expect(Math.max(...renderHeights) - Math.min(...renderHeights)).toBeLessThanOrEqual(1);
const loadedHeight = (await viewport.boundingBox())!.height;
testInfo.annotations.push({ type: "geometry", description: `loading=${loadingHeight}, loaded=${loadedHeight}` });
expect(loadedHeight).toBeGreaterThan(loadingHeight);
// Read below the graph: native scroll anchoring must preserve that content,
// while the graph is free to take its real (different) height.
await page.locator("#reading-anchor").evaluate((element) => element.scrollIntoView({ block: "start" }));
const anchorTop = await page.locator("#reading-anchor").evaluate((element) => element.getBoundingClientRect().top);
await page.evaluate(() => (window as unknown as { investigationFixture: { update: (source: string) => void } }).investigationFixture
.update("flowchart LR\nA[读取工单] --> B[核对监测]"));
await expect.poll(() => fixture("pending")).toBe(true);
await expect(diagram.locator("svg .node")).toHaveCount(5);
await fixture("release");
await expect(diagram.locator("svg .node")).toHaveCount(2);
expect((await viewport.boundingBox())!.height).toBeLessThan(loadedHeight - 100);
expect(Math.abs(await page.locator("#reading-anchor").evaluate((element) => element.getBoundingClientRect().top) - anchorTop)).toBeLessThanOrEqual(1);
await page.locator("#reader").evaluate((element) => { element.scrollTop = 0; });
await diagram.locator(".node").filter({ hasText: "核对监测" }).click();
await expect(diagram.getByLabel("节点详情 核对监测")).toContainText("监测记录的引用依据");
await diagram.getByRole("button", { name: "Zoom in", exact: true }).click();
await expect(diagram.locator('[role="application"]')).toHaveCSS("transform", "matrix(1.1, 0, 0, 1.1, 0, 0)");
await diagram.getByRole("button", { name: "Reset zoom and pan", exact: true }).click();
const checkCollapse = async (keyboard: boolean) => {
const before = await page.locator("#reading-anchor").evaluate((element) => element.getBoundingClientRect().top);
await diagram.getByRole("button", { name: "放大查看", exact: true }).click();
const detail = page.getByRole("dialog", { name: "调查刷新检查", exact: true });
await expect(detail.locator("svg .node")).toHaveCount(2);
await page.evaluate(() => {
const reader = document.getElementById("reader")!;
const anchor = document.getElementById("reading-anchor")!;
const frames: { top: number; scroll: number; height: number }[] = [];
Object.assign(window, { collapseFrames: frames, collapseDone: false });
const start = performance.now();
const sample = () => {
frames.push({ top: anchor.getBoundingClientRect().top, scroll: reader.scrollTop, height: reader.scrollHeight });
if (performance.now() - start < 800) requestAnimationFrame(sample);
else Object.assign(window, { collapseDone: true });
};
sample();
});
if (keyboard) await page.keyboard.press("Escape");
else await detail.getByRole("button", { name: "关闭详情", exact: true }).click();
await expect(detail).toHaveCount(0);
await page.waitForFunction(() => (window as unknown as { collapseDone: boolean }).collapseDone);
const frames = await page.evaluate(() => (window as unknown as { collapseFrames: { top: number; scroll: number; height: number }[] }).collapseFrames);
const range = (key: "top" | "scroll" | "height") => Math.max(...frames.map((frame) => frame[key])) - Math.min(...frames.map((frame) => frame[key]));
testInfo.annotations.push({ type: "collapse-geometry", description: `keyboard=${keyboard}, anchorShift=${range("top")}, scrollShift=${range("scroll")}, heightShift=${range("height")}` });
expect(Math.abs(frames[0].top - before)).toBeLessThanOrEqual(1);
expect(range("top")).toBeLessThanOrEqual(1);
expect(range("scroll")).toBeLessThanOrEqual(1);
expect(range("height")).toBeLessThanOrEqual(1);
expect(await diagram.locator(":scope > .flow-root").evaluate((element) => (element as HTMLElement).style.height)).toBe("");
};
await checkCollapse(false);
const allRenderHeights = await page.evaluate(() => (window as unknown as { documentHeights: number[] }).documentHeights);
expect(Math.max(...allRenderHeights) - Math.min(...allRenderHeights)).toBeLessThanOrEqual(1);
testInfo.annotations.push({ type: "page-height", description: `min=${Math.min(...allRenderHeights)}, max=${Math.max(...allRenderHeights)}` });
await diagram.screenshot({ path: testInfo.outputPath("diagram-stable-desktop.png") });
await page.setViewportSize({ width: 375, height: 800 });
await expect(diagram.locator("svg .node")).toHaveCount(2);
await expect(diagram.getByRole("button", { name: "放大查看", exact: true })).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(375);
await diagram.screenshot({ path: testInfo.outputPath("diagram-stable-mobile.png") });
await checkCollapse(true);
await page.emulateMedia({ reducedMotion: "reduce" });
await expect(diagram.locator('[role="img"] > svg')).toHaveCSS("animation-name", "none");
});