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:
@@ -1,5 +1,73 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { styleInvestigationDiagram } from "./investigation-diagram-style";
|
||||
import { mermaid } from "@streamdown/mermaid";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { investigationDiagramPlugins, styleInvestigationDiagram } from "./investigation-diagram-style";
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe("investigation SVG post-processing", () => {
|
||||
const renderedSvg = (lineBreak: string) => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 100">
|
||||
<g class="clusters"><g class="cluster"><rect x="10" y="20" width="100" height="50"/>
|
||||
<g class="cluster-label"><text>证据核对</text></g></g></g>
|
||||
<g class="node" data-id="A"><foreignObject width="100" height="50">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml"><p>核查${lineBreak}SCADA</p></div>
|
||||
</foreignObject></g></svg>`;
|
||||
const mockRender = (svg: string) => {
|
||||
const result = { svg };
|
||||
vi.spyOn(mermaid, "getMermaid").mockReturnValue({ initialize: vi.fn(), render: vi.fn().mockResolvedValue(result) });
|
||||
return result;
|
||||
};
|
||||
|
||||
it.each([false, true])("isolates the measurement node and cleans up styles, failure=%s", async (fail) => {
|
||||
const stylesBefore = document.head.querySelectorAll("style").length;
|
||||
vi.spyOn(mermaid, "getMermaid").mockReturnValue({
|
||||
initialize: vi.fn(),
|
||||
render: vi.fn(async (id) => {
|
||||
const measurement = document.createElement("div");
|
||||
measurement.id = `d${id}`;
|
||||
document.body.append(measurement);
|
||||
try {
|
||||
expect(getComputedStyle(measurement).position).toBe("fixed");
|
||||
expect(getComputedStyle(measurement).visibility).toBe("hidden");
|
||||
if (fail) throw new Error("render failed");
|
||||
return { svg: renderedSvg("") };
|
||||
} finally {
|
||||
measurement.remove();
|
||||
}
|
||||
})
|
||||
});
|
||||
const render = investigationDiagramPlugins.mermaid.getMermaid().render("isolated-render", "flowchart TD");
|
||||
if (fail) await expect(render).rejects.toThrow("render failed");
|
||||
else await expect(render).resolves.toHaveProperty("svg");
|
||||
expect(document.head.querySelectorAll("style")).toHaveLength(stylesBefore);
|
||||
});
|
||||
|
||||
it.each(["", "<br>", "<br/>"])("preserves SVG, HTML labels and group captions with line break %j", async (lineBreak) => {
|
||||
mockRender(renderedSvg(lineBreak));
|
||||
const result = await investigationDiagramPlugins.mermaid.getMermaid().render("diagram", "flowchart TD");
|
||||
const parsed = new DOMParser().parseFromString(result.svg, "text/html");
|
||||
const svg = parsed.body.firstElementChild;
|
||||
expect(svg?.localName).toBe("svg");
|
||||
expect(svg?.namespaceURI).toBe("http://www.w3.org/2000/svg");
|
||||
expect(parsed.querySelector("parsererror")).toBeNull();
|
||||
expect(parsed.querySelector('.node[data-id="A"] p')?.textContent).toBe("核查SCADA");
|
||||
expect(parsed.querySelectorAll("br")).toHaveLength(lineBreak ? 1 : 0);
|
||||
expect(parsed.querySelector(".cluster-label")?.parentElement).toBe(svg);
|
||||
expect(parsed.querySelector(".cluster-label")?.getAttribute("transform")).toBe("translate(28, 32)");
|
||||
});
|
||||
|
||||
it.each(["<html><body>render error</body></html>", '<svg xmlns="http://www.w3.org/2000/svg"><parsererror>bad SVG</parsererror></svg>'])(
|
||||
"rejects an invalid renderer result instead of displaying an error document: %s", async (svg) => {
|
||||
mockRender(svg);
|
||||
await expect(investigationDiagramPlugins.mermaid.getMermaid().render("diagram", "flowchart TD")).rejects.toThrow();
|
||||
}
|
||||
);
|
||||
|
||||
it("retains the original graph if optional caption post-processing fails", async () => {
|
||||
const original = mockRender(renderedSvg("<br>"));
|
||||
vi.spyOn(Element.prototype, "outerHTML", "get").mockImplementation(() => { throw new Error("serialization failed"); });
|
||||
expect(await investigationDiagramPlugins.mermaid.getMermaid().render("diagram", "flowchart TD")).toBe(original);
|
||||
});
|
||||
});
|
||||
|
||||
describe("investigation diagram presentation", () => {
|
||||
it("retains source relationships and only styles declared business references", () => {
|
||||
@@ -17,4 +85,13 @@ describe("investigation diagram presentation", () => {
|
||||
expect(styled).not.toContain("class missing ");
|
||||
expect(styled).not.toMatch(/completed|running|succeeded/);
|
||||
});
|
||||
it("uses blue only for an explicitly running current diagram, and neutralizes historical running classes", () => {
|
||||
const diagram = { title: "当前核查", mermaid: "flowchart TD\nA[查询]:::tjAgent --> B[判断]:::tjDecision",
|
||||
node_refs: [{ node_id: "A", label: "查询", status: "running" as const, evidence_refs: ["e1"], action_refs: [] }] };
|
||||
expect(styleInvestigationDiagram(diagram, true)).toContain("class A tjRunning;");
|
||||
expect(styleInvestigationDiagram(diagram, true)).not.toContain("class A tjEvidence;");
|
||||
const historical = styleInvestigationDiagram(diagram, false);
|
||||
expect(historical).not.toContain("class A tjRunning;");
|
||||
expect(historical).not.toContain("#4263C7");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -59,24 +59,49 @@ export const investigationDiagramPlugins = {
|
||||
return {
|
||||
initialize: (nextConfig: MermaidConfig) => renderer.initialize(nextConfig),
|
||||
async render(id: string, source: string) {
|
||||
const result = await renderer.render(id, source);
|
||||
const svg = new DOMParser().parseFromString(result.svg, "image/svg+xml");
|
||||
svg.querySelectorAll(".cluster").forEach((cluster) => {
|
||||
const rect = Array.from(cluster.children).find((child) => child.tagName === "rect");
|
||||
const caption = cluster.querySelector(".cluster-label");
|
||||
if (!rect || !caption) return;
|
||||
const x = Number(rect.getAttribute("x"));
|
||||
const y = Number(rect.getAttribute("y"));
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) return;
|
||||
caption.setAttribute(
|
||||
"transform",
|
||||
`translate(${x + 18}, ${y + (config?.flowchart?.subGraphTitleMargin?.top ?? 12)})`
|
||||
);
|
||||
// Mermaid paints crossing edges after clusters; keep captions above those edges.
|
||||
const clusters = cluster.parentElement;
|
||||
if (clusters?.classList.contains("clusters")) clusters.parentElement?.append(caption);
|
||||
});
|
||||
return { ...result, svg: new XMLSerializer().serializeToString(svg.documentElement) };
|
||||
// Streamdown's two-argument renderer uses Mermaid's temporary #d{id}
|
||||
// under body. It must remain measurable, but never enter page flow.
|
||||
// Scope the rule to this render, including fullscreen and error paths.
|
||||
const measurementStyle = document.createElement("style");
|
||||
measurementStyle.textContent = `body > [id=${JSON.stringify(`d${id}`)}] {
|
||||
position: fixed !important; inset: 0 auto auto 0;
|
||||
width: 100%; visibility: hidden; pointer-events: none;
|
||||
contain: layout paint;
|
||||
}`;
|
||||
document.head.append(measurementStyle);
|
||||
const result = await renderer.render(id, source).finally(() => measurementStyle.remove());
|
||||
// Mermaid's strict renderer emits HTML labels inside foreignObject,
|
||||
// including HTML void tags such as <br>. Parse its sanitized output in
|
||||
// an inert HTML fragment, not as a standalone XML document.
|
||||
const template = document.createElement("template");
|
||||
template.innerHTML = result.svg;
|
||||
const svg = template.content.firstElementChild;
|
||||
if (template.content.childElementCount !== 1 || svg?.localName !== "svg"
|
||||
|| svg.namespaceURI !== "http://www.w3.org/2000/svg" || svg.querySelector("parsererror")) {
|
||||
throw new Error("Mermaid did not return a valid SVG diagram");
|
||||
}
|
||||
try {
|
||||
svg.querySelectorAll(".cluster").forEach((cluster) => {
|
||||
const rect = Array.from(cluster.children).find((child) => child.tagName === "rect");
|
||||
const caption = cluster.querySelector(".cluster-label");
|
||||
if (!rect || !caption) return;
|
||||
const x = Number(rect.getAttribute("x"));
|
||||
const y = Number(rect.getAttribute("y"));
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) return;
|
||||
caption.setAttribute(
|
||||
"transform",
|
||||
`translate(${x + 18}, ${y + (config?.flowchart?.subGraphTitleMargin?.top ?? 12)})`
|
||||
);
|
||||
// Mermaid paints crossing edges after clusters; keep captions above those edges.
|
||||
const clusters = cluster.parentElement;
|
||||
if (clusters?.classList.contains("clusters")) clusters.parentElement?.append(caption);
|
||||
});
|
||||
// Streamdown embeds this as HTML, just like Mermaid's original output.
|
||||
return { ...result, svg: svg.outerHTML };
|
||||
} catch {
|
||||
// Caption placement is cosmetic; never discard a usable original graph.
|
||||
return result;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -84,26 +109,29 @@ export const investigationDiagramPlugins = {
|
||||
};
|
||||
|
||||
const roleStyles = [
|
||||
"classDef tjAgent fill:#EEF3FF,stroke:#7A93CB,color:#284574,stroke-width:1.4px;",
|
||||
"classDef tjAgent fill:#F6F8FC,stroke:#A5B5CC,color:#26334D,stroke-width:1.4px;",
|
||||
"classDef tjHuman fill:#FFF6E8,stroke:#C4A168,color:#735018,stroke-width:1.4px;",
|
||||
"classDef tjDecision fill:#4263C7,stroke:#4263C7,color:#FFFFFF,stroke-width:1.5px;",
|
||||
"classDef tjDecision fill:#F6F8FC,stroke:#A5B5CC,color:#26334D,stroke-width:1.5px;",
|
||||
"classDef tjEvidence fill:#EDF7F4,stroke:#6B9E8B,color:#2D5E4E,stroke-width:1.6px;",
|
||||
"classDef tjAction fill:#FFF6E8,stroke:#C4A168,color:#735018,stroke-width:1.6px;"
|
||||
];
|
||||
|
||||
/** Style supplied relationships; never create nodes from a dangling display reference. */
|
||||
export function styleInvestigationDiagram(diagram: InvestigationDiagram) {
|
||||
export function styleInvestigationDiagram(diagram: InvestigationDiagram, running = false) {
|
||||
const assignments = diagram.node_refs.flatMap((node) => {
|
||||
const escaped = node.node_id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const inSource = new RegExp(`\\b${escaped}(?=\\s*(?:[\\[({@;]|--|==|-\\.|$))`, "m").test(
|
||||
diagram.mermaid
|
||||
);
|
||||
if (!inSource) return [];
|
||||
if (running && node.status === "running") return [`class ${node.node_id} tjRunning;`];
|
||||
return node.action_refs.length
|
||||
? [`class ${node.node_id} tjAction;`]
|
||||
: node.evidence_refs.length
|
||||
? [`class ${node.node_id} tjEvidence;`]
|
||||
: [];
|
||||
});
|
||||
return [diagram.mermaid, ...roleStyles, ...assignments].join("\n");
|
||||
return [diagram.mermaid, ...roleStyles,
|
||||
running ? "classDef tjRunning fill:#E8F0FF,stroke:#4263C7,color:#284574,stroke-width:2.5px;" : "classDef tjRunning fill:#F6F8FC,stroke:#A5B5CC,color:#26334D,stroke-width:1.4px;",
|
||||
...assignments].join("\n");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatInvestigationText } from "./investigation-text";
|
||||
|
||||
describe("investigation prose time display", () => {
|
||||
it("converts explicit instants and UTC windows from historical reports", () => {
|
||||
expect(formatInvestigationText("上报于2026-10-08T04:00:00Z;查询2026-10-08 04:00–05:00Z。"))
|
||||
.toBe("上报于2026/10/8 12:00:00(UTC+8);查询2026/10/8 12:00–13:00(UTC+8)。");
|
||||
expect(formatInvestigationText("同窗04:00–05:00Z,已知2026-10-08T12:00:00+08:00。"))
|
||||
.toBe("同窗12:00–13:00(UTC+8),已知2026/10/8 12:00:00(UTC+8)。");
|
||||
});
|
||||
|
||||
it("keeps day boundaries visible for instants and ranges", () => {
|
||||
expect(formatInvestigationText("2026-12-31T16:00:00Z;2026-12-31 15:30–17:00Z;15:30–17:00Z"))
|
||||
.toBe("2027/1/1 00:00:00(UTC+8);2026/12/31 23:30–2027/1/1 01:00(UTC+8);23:30–次日 01:00(UTC+8)");
|
||||
expect(formatInvestigationText("23:00–01:00Z")).toBe("07:00–09:00(UTC+8)");
|
||||
});
|
||||
|
||||
it("preserves unzoned or invalid values, identifiers, URLs and code", () => {
|
||||
const text = "2026-10-08 04:00;2026-02-30T04:00:00Z;2026-10-08T25:00:00Z;id_2026-10-08T04:00:00Z "
|
||||
+ "https://example.test/2026-10-08T04:00:00Z `2026-10-08T04:00:00Z`;27:00–28:00Z";
|
||||
expect(formatInvestigationText(text)).toBe(text);
|
||||
expect(formatInvestigationText(null)).toBe("");
|
||||
});
|
||||
|
||||
it("does not shift already displayed text on repeated rendering", () => {
|
||||
const displayed = formatInvestigationText("2026-10-08T04:00:00Z;04:00–05:00Z");
|
||||
expect(formatInvestigationText(displayed)).toBe(displayed);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { formatDateTime, formatTime } from "@/shared/utils/date-time";
|
||||
|
||||
const zonedTime = /(?<![\w/+:.-])(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}(?::\d{2}(?:\.\d{1,9})?)?)(Z|[+-]\d{2}:\d{2})(?![\w:+/-])/g;
|
||||
const utcRange = /(?<![\w/+:.-])(?:(\d{4}-\d{2}-\d{2})[T ])?(\d{2}:\d{2})(?:\s*[–—~至-]\s*)(\d{2}:\d{2})Z(?![\w:+/-])/g;
|
||||
|
||||
function parseTime(day: string, clock: string, zone = "Z"): number | null {
|
||||
const date = new Date(`${day}T00:00:00Z`);
|
||||
const [hours, minutes, seconds = "0"] = clock.split(":");
|
||||
if (!Number.isFinite(date.getTime()) || date.toISOString().slice(0, 10) !== day
|
||||
|| Number(hours) > 23 || Number(minutes) > 59 || Number(seconds) >= 60) return null;
|
||||
const value = Date.parse(`${day}T${clock}${zone}`);
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
/** Display historical report prose without changing stored facts, IDs, URLs or code. */
|
||||
export function formatInvestigationText(text: string | null | undefined): string {
|
||||
if (!text) return text ?? "";
|
||||
return text.split(/(https?:\/\/\S+|tjwater:\/\/\S+|`[^`]*`)/g).map((part, index) => {
|
||||
if (index % 2) return part;
|
||||
return part.replace(utcRange, (original, day: string | undefined, start: string, end: string) => {
|
||||
const startAt = parseTime(day ?? "2000-01-01", start);
|
||||
let endAt = parseTime(day ?? "2000-01-01", end);
|
||||
if (startAt === null || endAt === null) return original;
|
||||
if (endAt < startAt) endAt += 24 * 60 * 60_000;
|
||||
const dateOptions = { year: "numeric", month: "numeric", day: "numeric" } as const;
|
||||
const crossesDay = formatDateTime(startAt, dateOptions) !== formatDateTime(endAt, dateOptions);
|
||||
const startLabel = day ? formatDateTime(startAt, { ...dateOptions, hour: "2-digit", minute: "2-digit" }) : formatTime(startAt);
|
||||
const endLabel = crossesDay && day
|
||||
? formatDateTime(endAt, { ...dateOptions, hour: "2-digit", minute: "2-digit" })
|
||||
: `${crossesDay ? "次日 " : ""}${formatTime(endAt)}`;
|
||||
return `${startLabel}–${endLabel}(UTC+8)`;
|
||||
}).replace(zonedTime, (original, day: string, clock: string, zone: string) => {
|
||||
const value = parseTime(day, clock, zone);
|
||||
return value === null ? original : `${formatDateTime(value)}(UTC+8)`;
|
||||
});
|
||||
}).join("");
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getRunningWorkflowDefinition } from "../data/running-workflows";
|
||||
import { formatTime } from "@/shared/utils/date-time";
|
||||
import type {
|
||||
ScheduledConditionItem,
|
||||
ScheduledConditionItemStatus,
|
||||
@@ -55,7 +56,7 @@ export function createConditionConversationPrompt(
|
||||
return [
|
||||
"请继续分析这条工况任务,并基于当前上下文给出下一步调度建议。",
|
||||
"",
|
||||
`时间:${formatTime(condition.scheduledAt)}`,
|
||||
`时间(UTC+8):${formatTime(condition.scheduledAt)}`,
|
||||
"类型:历史工况",
|
||||
`来源:${backendCondition ? "后端持久化运行态" : "明确标注的演示数据"}`,
|
||||
workflow ? `任务工作流:${workflow.title}` : null,
|
||||
@@ -94,7 +95,7 @@ export function createAlertQueueConversationPrompt({
|
||||
const condition = findConditionForAlert(alert, conditions);
|
||||
const baseSummary = [
|
||||
`${index + 1}. ${alert.title}`,
|
||||
"状态:待复核",
|
||||
"状态:待处理",
|
||||
`时间:${alert.time}`,
|
||||
`描述:${alert.description}`
|
||||
];
|
||||
@@ -123,7 +124,7 @@ export function createAlertQueueConversationPrompt({
|
||||
});
|
||||
|
||||
return [
|
||||
"请作为供水管网调度 Agent,汇总当前待复核工况队列。",
|
||||
"请作为供水管网调度 Agent,汇总当前待办与告警。",
|
||||
"",
|
||||
`数据来源:${backendAlerts ? "后端持久化运行态" : "明确标注的演示数据"}`,
|
||||
backendAlerts
|
||||
@@ -131,14 +132,14 @@ export function createAlertQueueConversationPrompt({
|
||||
: "这些内容为演示数据,用于测试汇总流程。",
|
||||
"",
|
||||
"目标:",
|
||||
"1. 汇总这一组待复核工况之间的关联关系和处理顺序。",
|
||||
"1. 汇总这些待处理事项之间的关联关系和处理顺序。",
|
||||
"2. 列出支撑判断的关键证据和不确定性。",
|
||||
"3. 给出处置方案,包含建议动作、适用条件、影响范围和需要人工确认的事项。",
|
||||
"4. 不要自动执行阀门、泵站或工单动作,先给调度员确认。",
|
||||
"",
|
||||
`待复核工况数量:${alerts.length}`,
|
||||
`待处理事项数量:${alerts.length}`,
|
||||
"",
|
||||
"待复核工况上下文:",
|
||||
"待处理事项上下文:",
|
||||
alertSummaries.join("\n\n"),
|
||||
"",
|
||||
"请先给出总体判断,再按工况列出处置建议,最后列出需要补充核查的数据。"
|
||||
@@ -157,11 +158,6 @@ function findConditionForAlert(alert: WorkbenchAlert, conditions: ScheduledCondi
|
||||
return conditions.find((condition) => condition.id === conditionId) ?? null;
|
||||
}
|
||||
|
||||
function formatTime(value: string) {
|
||||
const match = value.match(/T(\d{2}:\d{2})/);
|
||||
return match?.[1] ?? value;
|
||||
}
|
||||
|
||||
function getRiskLabel(riskLevel: ScheduledConditionItem["riskLevel"]) {
|
||||
if (riskLevel === "critical") {
|
||||
return "需重点复核";
|
||||
|
||||
Reference in New Issue
Block a user