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
+1 -1
View File
@@ -6,7 +6,7 @@ TJWATER_TIANDITU_TOKEN=
TJWATER_MAP_URL=https://geoserver.waternetwork.cn/geoserver
TJWATER_GEOSERVER_WORKSPACE=tjwater_next
TJWATER_SERVER_API_BASE_URL=http://127.0.0.1:8000
TJWATER_AGENT_API_BASE_URL=http://127.0.0.1:8787
TJWATER_AGENT_URL=http://127.0.0.1:8787
TJWATER_WORKBENCH_RUNTIME_MODE=auto
TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL=
TJWATER_PERIPHERAL_SAMPLE_TIME_MODE=demo
+11 -2
View File
@@ -32,6 +32,11 @@ Agent 服务默认地址为 `http://127.0.0.1:8787`。需要完整对话能力
浏览器配置通过 `/runtime-config.js` 注入,不使用 `VITE_` 或 `NEXT_PUBLIC_` 前缀。可在 `.env.local` 中设置以下变量:
开发环境使用内网认证服务时,仅在被 Git 忽略的 `.env.development.local` 中覆盖
`TJWATER_KEYCLOAK_ISSUER`,保留 `.env.local` 的 HTTPS issuer。Vite 默认开发模式读取
此覆盖,正常 production 构建不读取。登录 issuer 必须与 Agent、Server MCP 及 worker
一致;更改后重新加载开发服务器并重新登录,不复用旧 issuer 的令牌。
| 变量 | 默认值 | 说明 |
| ---------------------------------------- | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `TJWATER_AUTH_MODE` | `required` | Keycloak 认证模式;本地测试可显式设为 `disabled` |
@@ -42,7 +47,7 @@ Agent 服务默认地址为 `http://127.0.0.1:8787`。需要完整对话能力
| `TJWATER_MAP_URL` | `https://geoserver.waternetwork.cn/geoserver` | GeoServer 服务地址 |
| `TJWATER_GEOSERVER_WORKSPACE` | `tjwater_next` | GeoServer 工作区 |
| `TJWATER_SERVER_API_BASE_URL` | `http://127.0.0.1:8000` | 浏览器访问的 TJWater 后端 API 地址 |
| `TJWATER_AGENT_API_BASE_URL` | `http://127.0.0.1:8787` | 浏览器访问的 Agent API 地址 |
| `TJWATER_AGENT_URL` | `http://127.0.0.1:8787` | 浏览器访问的 Agent API 地址 |
| `TJWATER_WORKBENCH_RUNTIME_MODE` | `auto` | 工作台时间模式:工况始终使用 mock,预约工单只读取后端投影;`auto` 和 `backend` 使用当天日期;`demo` 与 `history` 定位到最近一个有后端预约工单的日期,并在同一天生成 mock 工况,其中 `demo` 标记为演示时间。分析成果和后端任务状态不作为工况或日期依据 |
| `TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL` | 空 | 可选的外围系统模拟服务地址,留空时不发起连接 |
| `TJWATER_PERIPHERAL_SAMPLE_TIME_MODE` | `demo` | 历史回放默认时间模式:`demo` 使用固定有数据窗口,`recent` 使用最近时段 |
@@ -58,7 +63,11 @@ Agent 服务默认地址为 `http://127.0.0.1:8787`。需要完整对话能力
Keycloak 中的 `next-tjwater` Client 应关闭 Client Authentication,开启 Standard Flow,并关闭 Implicit Flow 与 Direct Access Grants。Valid Redirect URIs 和 Web Origins 只配置实际使用的前端地址。
业务数据与 Agent 请求分别由浏览器直接访问 `TJWATER_SERVER_API_BASE_URL` 和 `TJWATER_AGENT_API_BASE_URL`,部署环境需要为前端来源配置 CORS。语音播放始终请求同源 `/api/tts/edge`;开发和预览由 Vite 中间件处理,生产镜像会启动仅监听容器回环地址的 Edge TTS 适配器。无需配置 TTS 服务 URL,可选的服务端变量 `EDGE_TTS_VOICE` 用于覆盖默认中文语音。
业务数据与 Agent 请求分别由浏览器直接访问 `TJWATER_SERVER_API_BASE_URL` 和 `TJWATER_AGENT_URL`,部署环境需要为前端来源配置 CORS。语音播放始终请求同源 `/api/tts/edge`;开发和预览由 Vite 中间件处理,生产镜像会启动仅监听容器回环地址的 Edge TTS 适配器。无需配置 TTS 服务 URL,可选的服务端变量 `EDGE_TTS_VOICE` 用于覆盖默认中文语音。
Agent 模型目录由 Agent 服务提供,页面重新获得焦点或网络重连时刷新;发送指令前再次读取当前目录。仍有效的用户选择会保留,失效时改用目录中的默认模型或首个可用模型,并同步更新菜单。目录为空时提示错误并阻止生成请求。前端始终提交目录返回的完整模型 ID。
Agent 运行时仍可发送消息,默认“引导当前任务”(steer),也可选择“排队处理”(queue);停止按钮独立保留。尚未处理的消息显示在输入框上方,可以逐条切换发送方式。处理状态由 Agent 的原生收件箱决定,已处理后不再允许修改;追加消息和模式切换复用当前会话归属及授权,不会先中断执行。
## 常用命令
+2 -2
View File
@@ -10,7 +10,7 @@ runtime_config=$(jq -cn \
--arg mapUrl "${TJWATER_MAP_URL:-https://geoserver.waternetwork.cn/geoserver}" \
--arg geoserverWorkspace "${TJWATER_GEOSERVER_WORKSPACE:-tjwater_next}" \
--arg serverApiBaseUrl "${TJWATER_SERVER_API_BASE_URL:-http://127.0.0.1:8000}" \
--arg agentApiBaseUrl "${TJWATER_AGENT_API_BASE_URL:-http://127.0.0.1:8787}" \
--arg agentApiBaseUrl "${TJWATER_AGENT_URL:-http://127.0.0.1:8787}" \
--arg workbenchRuntimeMode "${TJWATER_WORKBENCH_RUNTIME_MODE:-auto}" \
--arg peripheralSampleApiBaseUrl "${TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL:-}" \
--arg peripheralSampleTimeMode "${TJWATER_PERIPHERAL_SAMPLE_TIME_MODE:-demo}" \
@@ -28,7 +28,7 @@ runtime_config=$(jq -cn \
TJWATER_MAP_URL: $mapUrl,
TJWATER_GEOSERVER_WORKSPACE: $geoserverWorkspace,
TJWATER_SERVER_API_BASE_URL: $serverApiBaseUrl,
TJWATER_AGENT_API_BASE_URL: $agentApiBaseUrl,
TJWATER_AGENT_URL: $agentApiBaseUrl,
TJWATER_WORKBENCH_RUNTIME_MODE: $workbenchRuntimeMode,
TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL: $peripheralSampleApiBaseUrl,
TJWATER_PERIPHERAL_SAMPLE_TIME_MODE: $peripheralSampleTimeMode,
+2 -5
View File
@@ -93,7 +93,7 @@
- 用途:面板内部的 Header、输入区域、筛选器、按钮、紧凑控制区和任务堆叠卡。
- 当前组件范围:
- Agent 收起侧栏条目、Agent 操作摘要按钮、命令面板快捷入口。
- Agent 收起侧栏条目、输入操作栏和命令面板快捷入口。
- 图层、量测和通用控制面板中的交互控件。
- 预约任务面板与工况详情中的筛选器、元信息块和次级操作。
- 任务浮条的非活动卡片。
@@ -103,7 +103,6 @@
- 主要文件:
- `src/features/agent/components/agent-collapsed-rail.tsx`
- `src/features/agent/components/agent-command-panel.tsx`
- `src/features/agent/components/agent-operational-brief.tsx`
- `src/features/map/core/components/control-panel.tsx`
- `src/features/map/core/components/layer-control.tsx`
- `src/features/map/core/components/measure-panel.tsx`
@@ -127,14 +126,13 @@
- 用途:消息、报告、表单、属性列表、时间线内容和其他密集阅读区域。
- 当前组件范围:
- Agent 消息详情、操作摘要、UI Envelope 内容和命令面板空状态。
- Agent 消息详情、UI Envelope 内容和命令面板空状态。
- 预约任务列表、工况详情、KPI、报告内容与下拉列表。
- 要素洞察、属性列表和底图名称标签。
- Header 下拉菜单中的详情块与空状态。
- 主要文件:
- `src/features/agent/components/agent-command-panel.tsx`
- `src/features/agent/components/agent-message-details.tsx`
- `src/features/agent/components/agent-operational-brief.tsx`
- `src/features/agent/components/agent-ui-envelope-renderer.tsx`
- `src/features/workbench/components/scheduled-condition-feed.tsx`
- `src/features/workbench/components/scheduled-condition-detail-panel.tsx`
@@ -183,7 +181,6 @@
建议用于明确由 Agent 或模型生成的内容,帮助用户区分事实数据、业务状态与机器推理。现有组件中适合的落点包括:
- `FeatureInsightPanel` 的“Agent 解释”区块。该区块目前使用 `material-tone-warning`,如果内容只是解释或推理而非风险告警,更适合改用 `material-tone-agent`。
- `AgentOperationalBrief`:Agent 生成的任务判断、建议动作或需要人工确认的结论区块。
- `AgentMessageDetails`:推理摘要、模型结论、建议方案等非原始证据内容。
- `AgentUiEnvelopeRenderer`:由 Agent 返回的建议面板或注册组件中的结论区块。
- `ScheduledConditionDetailPanel`:Agent 针对工况生成的处置建议区块。
+2
View File
@@ -98,3 +98,5 @@ pnpm test:browser tests/browser/agent-answer-ui-real.e2e.ts --workers=1
Vite 统一解析 React/React DOM,Playwright 按端口使用独立的依赖缓存,避免同时
运行的开发服务将旧临时目录的 React 预构建写回测试缓存。
消息入口为 `POST /api/v1/responses`,采用 Responses 文本输入、Response 输出和标准 SSE 生命周期。前端 `ResponsesChatTransport` 将网络事件转换为已有 UIMessage 渲染数据;审批、凭据续期、地图动作及 answer-ui 走 `tjwater.*` 扩展事件,不将 AI SDK 的 UIMessage 流作为网络协议。会话、模型及动作回执等接口统一使用 `/api/v1` 前缀,原 `/agent/chat` 路径已移除;Agent 服务和现有会话数据保留。
+5 -3
View File
@@ -33,7 +33,7 @@
- `--status-warning-soft`:香槟色 25% 与冷瓷混合的确认、暂停和结果未知提示底色;同互补色相的文字使用 `(L=0.430, C=0.070)`,得到 `#604D1D`;状态标记使用 `(L=0.600, C=0.090)`,得到 `#977D3C`。
- `rose`:失败、冲突或破坏性操作。
宝石蓝用于操作和选中状态,银灰用于材质层级。香槟色只用于确认、警告与 Agent 欢迎标记的小面积点缀,不作为普通面板的整块底色。成功、失败、地图业务图层和数据曲线保留各自语义色。
宝石蓝用于工作台操作和选中状态,银灰用于工作台材质层级。Agent 对话区使用独立的低饱和雾蓝灰层次:工作面 `#E8ECF3`、阅读面 `#F8F9FC`、用户消息 `#DDE4F1`,主操作使用岩蓝 `#465F91`。岩蓝与工作台品牌色保持同一视觉语言,同时降低亮度和饱和度,避免输入区显得刺眼。这组颜色按阅读层级和对比度选择,不要求遵循固定的相似色公式,也不扩散到地图和顶部工作台。香槟色只用于确认、警告与 Agent 欢迎标记的小面积点缀,不作为普通面板的整块底色。成功、失败、地图业务图层和数据曲线保留各自语义色。
## 3. 排版规则
@@ -55,7 +55,9 @@
## 6. 层级与深度
保持既有亚克力半透明材质,在透底、模糊、噪点和边缘高光上调整色调。桌面 Agent dock 延续既有实底布局,使用银灰色调。
保持既有亚克力半透明材质,在透底、模糊、噪点和边缘高光上调整色调。桌面 Agent dock 使用雾蓝灰实体底板,导航、历史和底部操作区保留同色调的局部亚克力;正文和文字输入区使用冷白实体阅读面,岩蓝只用于主操作、焦点和交互反馈。文字输入与权限、模型、语音和发送操作栏分开承担材质,聚焦边框仅围绕文字输入区。对话内容直接进入消息流,不在顶部重复展示分析摘要。每个功能区只保留一层模糊,历史展开内容与导航共用材质层。
桌面普通空会话使用浅雾蓝灰工作面和冷白文字输入区,居中输入组件距面板两侧至少 12px,最大宽度 600px,文字输入区初始高度 160px、字号 16px;多行输入的高度按可用视口限制,保持操作栏和推荐问题可见。模型根据当前用户与项目的近期任务、告警、分析成果和普通会话生成最多三条问题,在输入组件下方独立显示来源与时间,点击直接发送;首次开始对话时,同一个表单以 280ms 位移和尺寸过渡回到底部,随后使用 48px 的紧凑文字区和 40px 操作控件,减少动态效果偏好下则立即切换。移动端保留紧凑输入布局,生成的问题仍使用独立悬浮胶囊。推荐异步加载,不阻塞输入;打开空会话、切换模型或新建空会话时触发,普通重渲染和宽度变化不重新生成,模型失败时不以固定问题冒充生成结果。
| 材质 | 浅色底图 alpha | 影像底图 alpha |
|---|---:|---:|
@@ -63,7 +65,7 @@
| 浮动业务面板 | 0.56 | 0.72 |
| 地图控件 | 0.74 | 0.86 |
| Agent 移动抽屉和历史 | 0.82 | 0.88 |
| Agent 浮动输入区和运行摘要 | 0.86 | 0.90 |
| Agent 浮动输入区 | 0.86 | 0.90 |
| 正文阅读面 | 0.96 | 0.985 |
分组标题及次级数据使用银灰色差,不叠加投影。模糊仍为面板 24px、控件及 Agent 浮层 18px。地图和影像中的底色应能透过外层材质;报告正文保留更高遮蔽度,保证阅读。
+1 -1
View File
@@ -20,7 +20,7 @@ export default defineConfig({
trace: "on-first-retry"
},
webServer: {
command: `TJWATER_VITE_CACHE_DIR=node_modules/.vite-playwright-${testPort} TJWATER_AUTH_MODE=disabled TJWATER_AGENT_API_BASE_URL=http://127.0.0.1:8787 pnpm dev --port ${testPort} --strictPort`,
command: `TJWATER_VITE_CACHE_DIR=node_modules/.vite-playwright-${testPort} TJWATER_AUTH_MODE=disabled TJWATER_AGENT_URL=http://127.0.0.1:8787 pnpm dev --port ${testPort} --strictPort`,
url: baseURL,
reuseExistingServer: false
},
+1
View File
@@ -4,6 +4,7 @@ globalThis.__TJWATER_CONFIG__ = {
TJWATER_KEYCLOAK_CLIENT_ID: "next-tjwater",
TJWATER_MAPBOX_ACCESS_TOKEN: "",
TJWATER_TIANDITU_TOKEN: "",
TJWATER_AGENT_URL: "http://127.0.0.1:8787",
TJWATER_SERVER_API_BASE_URL: "http://127.0.0.1:8000",
TJWATER_WORKBENCH_RUNTIME_MODE: "auto"
};
+2 -2
View File
@@ -29,7 +29,7 @@ test("renders the migrated WebGIS workbench shell", async ({ page }) => {
TJWATER_MAP_URL: expect.any(String),
TJWATER_GEOSERVER_WORKSPACE: expect.any(String),
TJWATER_SERVER_API_BASE_URL: expect.any(String),
TJWATER_AGENT_API_BASE_URL: expect.any(String)
TJWATER_AGENT_URL: expect.any(String)
});
expect(Object.keys(runtimeConfig as Record<string, unknown>)).not.toContainEqual(
expect.stringMatching(/^(NEXT_PUBLIC_|VITE_)/)
@@ -49,7 +49,7 @@ test("animates the compact header menu without overflowing", async ({ page }) =>
await page.setViewportSize({ width: 375, height: 812 });
await page.goto("/");
await page.locator('button[aria-label^="查看异常处置面板"]:visible').click();
await page.locator('button[aria-label^="查看待办与告警"]:visible').click();
const menu = page.locator('[role="menu"][data-state="open"]');
await expect(menu).toBeVisible();
@@ -0,0 +1,33 @@
import { afterEach, expect, it, vi } from "vitest";
import { createAgentApiClient } from "./client";
afterEach(() => vi.unstubAllGlobals());
it("reads mailbox pages without sending or consuming messages and validates the contract", async () => {
const message = { message_id: "m1", task_id: "t1", run_id: "r1", source_session_id: "task-session", target_session_id: "chat-session", direction: "to_chat", text: "现场记录已核对", status: "pending", in_reply_to: null, created_at: "2026-10-13T00:00:00Z", source_owner_id: "private-owner" };
const fetchMock = vi.fn().mockResolvedValue(Response.json({ items: [message], next_cursor: "m1" }));
vi.stubGlobal("fetch", fetchMock);
const client = createAgentApiClient("http://agent.local", { getAccessToken: async () => "token", getProjectId: async () => "p1" });
const page = await client.listAgentMessages("chat-session", { cursor: "previous" });
expect(page.items[0]).not.toHaveProperty("source_owner_id");
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe("http://agent.local/api/v1/session/chat-session/agent-messages?limit=50&cursor=previous");
expect(init.method).toBeUndefined();
expect(init.body).toBeUndefined();
expect(new Headers(init.headers).get("X-Project-Id")).toBe("p1");
fetchMock.mockResolvedValue(Response.json({ items: [{ ...message, target_session_id: null, direction: "to_task" }], next_cursor: null }));
expect((await client.listAgentMessages("chat-session")).items[0].target_session_id).toBeNull();
fetchMock.mockResolvedValue(Response.json({ items: [{ ...message, status: "invented" }], next_cursor: null }));
await expect(client.listAgentMessages("chat-session")).rejects.toThrow();
});
it("accepts inbox notifications only for the subscribed session", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response([
'event: agent_message\ndata: {"session_id":"other"}\n\n',
'event: agent_message\ndata: {"session_id":"main"}\n\n',
': heartbeat\n\n'
].join(""))));
const onMessage = vi.fn();
await createAgentApiClient("http://agent.local").streamAgentMessages("main", { onMessage });
expect(onMessage).toHaveBeenCalledOnce();
});
+21
View File
@@ -0,0 +1,21 @@
import { z } from "zod";
export const agentMessageSchema = z.object({
message_id: z.string().min(1),
task_id: z.string().min(1),
run_id: z.string().min(1),
source_session_id: z.string().min(1),
target_session_id: z.string().min(1).nullable(),
direction: z.enum(["to_task", "to_chat"]),
text: z.string(),
status: z.enum(["pending", "enqueued", "delivered", "failed"]),
in_reply_to: z.string().nullable(),
created_at: z.string(),
failure_detail: z.string().nullable().optional()
});
export const agentMessagePageSchema = z.object({
items: z.array(agentMessageSchema),
next_cursor: z.string().nullable()
});
export type AgentMailboxMessage = z.infer<typeof agentMessageSchema>;
export type AgentMessagePage = z.infer<typeof agentMessagePageSchema>;
+50 -3
View File
@@ -1,7 +1,54 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createAgentApiClient } from "./client";
describe("Agent prompt suggestions", () => {
it("submits and edits pending input through the scoped Responses and session endpoints", async () => {
const input = { id: `msg_${"1".repeat(32)}`, text: "补充压力测点", delivery: "steer", status: "pending" };
const fetchMock = vi.fn().mockResolvedValueOnce(Response.json(input)).mockResolvedValueOnce(Response.json({ items: [input] }))
.mockResolvedValueOnce(Response.json({ ...input, delivery: "queue" }));
vi.stubGlobal("fetch", fetchMock);
const client = createAgentApiClient("http://agent.local", { getAccessToken: async () => "token", getProjectId: async () => "project" });
await expect(client.submitResponseInput("session-1", input.text, "steer", input.id)).resolves.toEqual(input);
expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({ input: input.text, conversation: "session-1", stream: false, tjwater: { delivery: "steer", input_id: input.id } });
await expect(client.listResponseInputs("session-1")).resolves.toEqual([input]);
await expect(client.updateResponseInput("session-1", input.id, "queue")).resolves.toEqual({ ...input, delivery: "queue" });
expect(String(fetchMock.mock.calls[0]?.[0])).toBe("http://agent.local/api/v1/responses");
expect(String(fetchMock.mock.calls[2]?.[0])).toContain(`/api/v1/session/session-1/inputs/${input.id}`);
for (const [, request] of fetchMock.mock.calls) {
expect(new Headers(request?.headers).get("Authorization")).toBe("Bearer token");
expect(new Headers(request?.headers).get("X-Project-Id")).toBe("project");
}
});
it("uses the scoped Agent request and rejects invented activity references", async () => {
const fetchMock = vi.fn().mockResolvedValue(Response.json({ suggestions: [{ question: "核对城南压力告警的证据", activity_id: "alert:pressure" }] }));
vi.stubGlobal("fetch", fetchMock);
const client = createAgentApiClient("http://agent.local", { getAccessToken: async () => "token", getProjectId: async () => "project" });
const activities = [{ id: "alert:pressure", kind: "alert" as const, title: "城南压力告警", occurred_at: "2026-10-09T07:00:00Z", origin: "backend" as const }];
const controller = new AbortController();
expect(await client.generatePromptSuggestions(activities, "test/model", controller.signal)).toEqual([{ question: "核对城南压力告警的证据", activityId: "alert:pressure" }]);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe("http://agent.local/api/v1/suggestions");
expect(new Headers(init.headers).get("Authorization")).toBe("Bearer token");
expect(new Headers(init.headers).get("X-Project-Id")).toBe("project");
expect(init.signal).toBe(controller.signal);
fetchMock.mockResolvedValue(Response.json({ suggestions: [{ question: "引用其他项目", activity_id: "unknown" }] }));
await expect(client.generatePromptSuggestions(activities, "test/model")).rejects.toThrow("Invalid Agent suggestion");
vi.unstubAllGlobals();
});
});
describe("Agent API client sessions", () => {
it("forces renewal and sends only the refresh request id with verified context headers", async () => {
const getAccessToken = vi.fn(async () => "TEST_NEW_TOKEN");
const fetchMock = vi.fn(async () => Response.json({ accepted: true }, { status: 202 }));
vi.stubGlobal("fetch", fetchMock);
await createAgentApiClient("http://agent.local", { getAccessToken, getProjectId: async () => "p" }).refreshCredential("s", "request-1");
expect(getAccessToken).toHaveBeenCalledWith(true);
const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
expect(new Headers(init.headers).get("Authorization")).toBe("Bearer TEST_NEW_TOKEN");
expect(JSON.parse(String(init.body))).toEqual({ request_id: "request-1" });
});
afterEach(() => {
vi.unstubAllGlobals();
});
@@ -85,10 +132,10 @@ describe("Agent API client sessions", () => {
});
expect(fetchMock).toHaveBeenNthCalledWith(
1,
"http://not-agent.local/api/v1/agent/chat/ui-registry",
"http://not-agent.local/api/v1/ui-registry",
undefined
);
expect(fetchMock).toHaveBeenNthCalledWith(2, "http://agent.local/api/v1/agent/chat/ui-registry", undefined);
expect(fetchMock).toHaveBeenNthCalledWith(2, "http://agent.local/api/v1/ui-registry", undefined);
});
it("sends title update payloads using backend field names", async () => {
@@ -100,7 +147,7 @@ describe("Agent API client sessions", () => {
});
expect(fetchMock).toHaveBeenCalledWith(
"http://agent.local/api/v1/agent/chat/session/session-1/title",
"http://agent.local/api/v1/session/session-1/title",
expect.objectContaining({
method: "PATCH",
body: JSON.stringify({
+103 -6
View File
@@ -1,5 +1,6 @@
import { env } from "@/shared/config/env";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import { agentMessagePageSchema, type AgentMessagePage } from "./agent-messages";
export type AgentRunStatus = "running" | "completed" | "error" | "aborted";
export type AgentSessionPurpose = "ordinary_chat" | "task_execution" | "task_collaboration";
@@ -37,6 +38,9 @@ export type AgentModelsResponse = {
models: AgentModelOption[];
};
export type AgentInputDelivery = "steer" | "queue";
export type AgentResponseInput = { id: string; text: string; delivery: AgentInputDelivery; status: "pending" | "delivered" };
export type AgentChatSession = {
id?: string;
session_id: string;
@@ -70,6 +74,21 @@ export type AgentChatSessionSummary = {
taskId?: string;
};
export type AgentRecentActivity = {
id: string;
kind: "alert" | "task" | "analysis" | "session";
title: string;
occurred_at: string;
summary?: string;
status?: string;
origin: "backend" | "agent" | "demo";
};
export type AgentPromptSuggestion = {
question: string;
activityId: string | null;
};
export type AgentLoadedChatSession = AgentChatSessionSummary & {
sessionId: string;
isTitleManuallyEdited: boolean;
@@ -94,7 +113,12 @@ export type AgentSessionStreamDataEventType =
| "session_title"
| "done"
| "error"
| "auth_required";
| "auth_required"
| "credential_refresh_required"
| "credential_refreshed"
| "credential_refresh_failed"
| "agent_message"
| "user_input";
export type AgentSessionStreamEvent =
| {
@@ -112,6 +136,12 @@ export type AgentSessionStreamEvent =
};
export type AgentApiClient = {
submitResponseInput: (sessionId: string, text: string, delivery: AgentInputDelivery, id: string) => Promise<AgentResponseInput>;
listResponseInputs: (sessionId: string) => Promise<AgentResponseInput[]>;
updateResponseInput: (sessionId: string, id: string, delivery: AgentInputDelivery) => Promise<AgentResponseInput>;
listAgentMessages: (sessionId: string, options?: { cursor?: string; signal?: AbortSignal }) => Promise<AgentMessagePage>;
streamAgentMessages: (sessionId: string, options: { signal?: AbortSignal; onMessage: () => void }) => Promise<void>;
generatePromptSuggestions: (activities: AgentRecentActivity[], model: string, signal?: AbortSignal) => Promise<AgentPromptSuggestion[]>;
createSession: (options?: { triggerContext?: AgentEventTriggerContext }) => Promise<AgentChatSession>;
listSessions: (options?: { purpose?: AgentSessionPurpose; taskId?: string }) => Promise<AgentChatSessionSummary[]>;
createTaskCollaborationSession: (taskId: string) => Promise<AgentChatSession>;
@@ -143,17 +173,18 @@ export type AgentApiClient = {
options: { sessionId: string; answers: string[][] }
) => Promise<void>;
rejectQuestion: (requestId: string, options: { sessionId: string }) => Promise<void>;
refreshCredential: (sessionId: string, requestId: string) => Promise<void>;
abort: (sessionId: string) => Promise<void>;
};
export type AgentApiClientOptions = {
getAccessToken?: AccessTokenProvider;
getProjectId?: () => Promise<string>;
getProjectId?: () => Promise<string | undefined>;
};
const AGENT_API_BASE_URLS = [env.TJWATER_AGENT_API_BASE_URL.replace(/\/$/, "")];
const AGENT_API_BASE_URLS = [env.TJWATER_AGENT_URL.replace(/\/$/, "")];
const CHAT_PATH = "/api/v1/agent/chat";
const API_PATH = "/api/v1";
export function createAgentApiClient(
baseUrls: string | string[] = AGENT_API_BASE_URLS,
@@ -187,6 +218,44 @@ export function createAgentApiClient(
};
return {
async submitResponseInput(sessionId, text, delivery, id) {
return requestJson<AgentResponseInput>("/responses", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ input: text, conversation: sessionId, stream: false, tjwater: { delivery, input_id: id } })
});
},
async listResponseInputs(sessionId) {
const result = await requestJson<{ items: AgentResponseInput[] }>(`/session/${encodeURIComponent(sessionId)}/inputs`);
return result.items;
},
async updateResponseInput(sessionId, id, delivery) {
return requestJson<AgentResponseInput>(`/session/${encodeURIComponent(sessionId)}/inputs/${encodeURIComponent(id)}`, {
method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ delivery })
});
},
async generatePromptSuggestions(activities, model, signal) {
const result = await requestJson<unknown>("/suggestions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ activities, model }),
signal,
});
if (!isRecord(result) || !Array.isArray(result.suggestions) || result.suggestions.length < 1 || result.suggestions.length > 3) {
throw new Error("Invalid Agent suggestions");
}
const ids = new Set(activities.map((item) => item.id));
const questions = new Set<string>();
return result.suggestions.map((item): AgentPromptSuggestion => {
if (!isRecord(item) || typeof item.question !== "string" || !item.question.trim() || item.question.length > 180 ||
(item.activity_id !== null && (typeof item.activity_id !== "string" || !ids.has(item.activity_id)))) {
throw new Error("Invalid Agent suggestion");
}
const question = item.question.trim();
if (questions.has(question)) throw new Error("Duplicate Agent suggestion");
questions.add(question);
return { question, activityId: item.activity_id as string | null };
});
},
async createSession(options) {
return requestJson<AgentChatSession>("/session", {
method: "POST",
@@ -254,6 +323,22 @@ export function createAgentApiClient(
await readSessionSseStream(response, options.onEvent);
},
async listAgentMessages(sessionId, options) {
const query = new URLSearchParams({ limit: "50" });
if (options?.cursor) query.set("cursor", options.cursor);
return agentMessagePageSchema.parse(await requestJson<unknown>(
`/session/${encodeURIComponent(sessionId)}/agent-messages?${query}`, { signal: options?.signal }
));
},
async streamAgentMessages(sessionId, options) {
const response = await request(`/session/${encodeURIComponent(sessionId)}/agent-messages/stream`, { signal: options.signal });
if (!response.ok) throw new Error(getResponseErrorMessage(await response.json(), response.status));
await readSessionSseStream(response, (event) => {
if (event.type === "agent_message" && event.sessionId === sessionId) options.onMessage();
});
},
async updateSessionTitle(sessionId, title, options) {
const normalizedTitle = title.trim();
if (!normalizedTitle) {
@@ -329,6 +414,13 @@ export function createAgentApiClient(
});
},
async refreshCredential(sessionId, requestId) {
await options.getAccessToken?.(true);
await requestJson(`/session/${encodeURIComponent(sessionId)}/credentials`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ request_id: requestId })
});
},
async abort(sessionId) {
await requestJson<unknown>("/abort", {
method: "POST",
@@ -346,7 +438,7 @@ async function fetchWithFallback(
path: string,
init?: RequestInit,
getAccessToken?: AccessTokenProvider,
getProjectId?: () => Promise<string>
getProjectId?: () => Promise<string | undefined>
) {
const orderedBaseUrls = [activeBaseUrl, ...baseUrls.filter((item) => item !== activeBaseUrl)];
let lastError: unknown;
@@ -361,7 +453,7 @@ async function fetchWithFallback(
for (const baseUrl of orderedBaseUrls) {
try {
const response = await fetch(`${baseUrl}${CHAT_PATH}${path}`, requestInit);
const response = await fetch(`${baseUrl}${API_PATH}${path}`, requestInit);
if (response.ok) {
setActiveBaseUrl(baseUrl);
return response;
@@ -603,7 +695,12 @@ function isSessionStreamDataEventType(value: string): value is AgentSessionStrea
value === "session_title" ||
value === "done" ||
value === "error" ||
value === "credential_refresh_required" ||
value === "credential_refreshed" ||
value === "credential_refresh_failed" ||
value === "auth_required"
|| value === "agent_message"
|| value === "user_input"
);
}
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import type { UIMessage, UIMessageChunk } from "ai";
import { createResponseRequest, responsesToUiStream } from "./responses-transport";
const bytes = (events: Record<string, unknown>[], partial = false) => {
const text = events.map((event) => `event: ${String(event.type)}\r\ndata: ${JSON.stringify(event)}\r\n\r\n`).join("");
const encoded = new TextEncoder().encode(text);
return new ReadableStream<Uint8Array>({ start(controller) {
if (partial) for (let i = 0; i < encoded.length; i += 7) controller.enqueue(encoded.slice(i, i + 7));
else controller.enqueue(encoded);
controller.close();
} });
};
const collect = async (stream: ReadableStream<UIMessageChunk>) => {
const parts: UIMessageChunk[] = [];
const reader = stream.getReader();
while (true) { const next = await reader.read(); if (next.done) break; parts.push(next.value); }
return parts;
};
const events = (): Record<string, unknown>[] => [
{ type: "response.created", sequence_number: 0, response: { id: "resp_1" } },
{ type: "response.output_item.added", sequence_number: 1, item: { type: "message", id: "msg_1", role: "assistant", content: [] } },
{ type: "response.content_part.added", sequence_number: 2, item_id: "msg_1", content_index: 0, part: { type: "output_text", text: "" } },
{ type: "response.output_text.delta", sequence_number: 3, item_id: "msg_1", content_index: 0, delta: "压力待核验" },
{ type: "tjwater.permission_request", sequence_number: 4, response_id: "resp_1", data: { request_id: "permission_1" } },
{ type: "response.output_text.done", sequence_number: 5, item_id: "msg_1", content_index: 0, text: "压力待核验" },
{ type: "response.completed", sequence_number: 6, response: { id: "resp_1", status: "completed" } },
];
describe("Responses transport", () => {
it("sends only the latest user input and keeps business options explicit", () => {
const messages = [{ id: "old", role: "assistant", parts: [{ type: "text", text: "旧回答" }] }, { id: "new", role: "user", parts: [{ type: "text", text: "继续核查" }] }] as UIMessage[];
expect(createResponseRequest(messages, { conversation: "owned", approvalMode: "always" })).toEqual({
input: [{ role: "user", content: [{ type: "input_text", text: "继续核查" }] }], stream: true, conversation: "owned",
tjwater: { approval_mode: "always", capabilities: [] },
});
});
it("decodes split UTF-8 and CRLF while retaining approval events", async () => {
expect(await collect(responsesToUiStream(bytes(events(), true)))).toEqual([
{ type: "start", messageId: "msg_1" }, { type: "text-start", id: "msg_1:0" },
{ type: "text-delta", id: "msg_1:0", delta: "压力待核验" },
{ type: "data-permission_request", data: { request_id: "permission_1" } },
{ type: "text-end", id: "msg_1:0" }, { type: "finish", finishReason: "stop" },
]);
});
it("reports premature disconnects, mismatched owners and invalid event order", async () => {
await expect(collect(responsesToUiStream(bytes(events().slice(0, 5))))).rejects.toThrow("断开");
const wrong = events(); wrong[4] = { ...wrong[4], response_id: "other" };
await expect(collect(responsesToUiStream(bytes(wrong)))).rejects.toThrow("归属");
await expect(collect(responsesToUiStream(bytes([events()[0]!, events()[3]!])))).rejects.toThrow();
});
it("turns failed and cancelled Responses into visible errors", async () => {
const failed = events();
failed[6] = { type: "response.failed", sequence_number: 6, response: { id: "resp_1", error: { message: "数据源不可用" } } };
expect((await collect(responsesToUiStream(bytes(failed)))).at(-2)).toEqual({ type: "error", errorText: "数据源不可用" });
});
});
@@ -0,0 +1,108 @@
import { HttpChatTransport, type UIMessage, type UIMessageChunk } from "ai";
const record = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
export function createResponseRequest(messages: UIMessage[], options: {
conversation?: string; model?: string; approvalMode?: string; capabilities?: string[];
triggerContext?: unknown; taskContext?: unknown;
}) {
const latest = [...messages].reverse().find((message) => message.role === "user");
const text = latest?.parts.filter((part) => part.type === "text").map((part) => part.text).join("\n").trim();
if (!text) throw new Error("Responses 请求需要用户文本");
return {
input: [{ role: "user", content: [{ type: "input_text", text }] }], stream: true,
...(options.model ? { model: options.model } : {}),
...(options.conversation ? { conversation: options.conversation } : {}),
tjwater: { approval_mode: options.approvalMode ?? "request", capabilities: options.capabilities ?? [],
...(options.triggerContext ? { trigger_context: options.triggerContext } : {}),
...(options.taskContext ? { task_context: options.taskContext } : {}),
},
};
}
/** Keep UI rendering independent of the public Responses wire protocol. */
export function responsesToUiStream(stream: ReadableStream<Uint8Array>): ReadableStream<UIMessageChunk> {
const decoder = new TextDecoder();
let buffer = "";
let responseId: string | undefined;
let messageStarted = false;
let messageId: string | undefined;
let terminal = false;
let sequence = -1;
const texts = new Set<string>();
const parse = (block: string, controller: TransformStreamDefaultController<UIMessageChunk>) => {
if (block.length > 1024 * 1024) throw new Error("Responses 单个流事件过大");
const lines = block.split(/\r?\n/);
const data = lines.filter((line) => line.startsWith("data:")).map((line) => line.slice(5).trimStart()).join("\n");
if (!data) return;
if (terminal) throw new Error("Responses 流在结束后仍返回事件");
const value: unknown = JSON.parse(data);
if (!record(value) || typeof value.type !== "string" || !Number.isInteger(value.sequence_number) || (value.sequence_number as number) <= sequence) throw new Error("Responses 流事件格式不正确");
sequence = value.sequence_number as number;
const named = lines.find((line) => line.startsWith("event:"))?.slice(6).trim();
if (named && named !== value.type) throw new Error("Responses 流事件类型不一致");
if (value.type === "response.created") {
if (responseId || !record(value.response) || typeof value.response.id !== "string") throw new Error("Responses 创建事件不正确");
responseId = value.response.id;
return;
}
if (!responseId) throw new Error("Responses 流缺少创建事件");
const textId = () => {
if (value.item_id !== messageId || !Number.isInteger(value.content_index) || (value.content_index as number) < 0) throw new Error("Responses 文本归属不正确");
return `${messageId}:${String(value.content_index)}`;
};
if (value.type === "response.output_item.added" && record(value.item) && value.item.type === "message") {
if (messageStarted || typeof value.item.id !== "string") throw new Error("Responses 回答消息不正确");
messageStarted = true;
messageId = value.item.id;
controller.enqueue({ type: "start", messageId: value.item.id });
} else if (value.type === "response.content_part.added" && record(value.part) && value.part.type === "output_text") {
if (!messageStarted) throw new Error("Responses 流缺少回答消息");
texts.add(textId());
controller.enqueue({ type: "text-start", id: textId() });
} else if (value.type === "response.output_text.delta") {
if (!texts.has(textId()) || typeof value.delta !== "string") throw new Error("Responses 文本事件顺序不正确");
controller.enqueue({ type: "text-delta", id: textId(), delta: value.delta });
} else if (value.type === "response.output_text.done") {
if (!texts.delete(textId())) throw new Error("Responses 文本结束事件不正确");
controller.enqueue({ type: "text-end", id: textId() });
} else if (value.type.startsWith("tjwater.")) {
if (value.response_id !== responseId || !/^tjwater\.[a-z][a-z0-9_]*$/.test(value.type)) throw new Error("业务流事件归属不正确");
controller.enqueue({ type: `data-${value.type.slice(8)}`, data: value.data,
...(typeof value.id === "string" ? { id: value.id } : {}), ...(value.transient === true ? { transient: true } : {}) } as UIMessageChunk);
} else if (["response.completed", "response.failed", "response.incomplete"].includes(value.type)) {
if (!record(value.response) || value.response.id !== responseId || texts.size) throw new Error("Responses 结束事件不正确");
terminal = true;
if (!messageStarted) controller.enqueue({ type: "start", messageId: responseId });
if (value.type !== "response.completed") {
const failure = value.response.error;
controller.enqueue({ type: "error", errorText: record(failure) && typeof failure.message === "string" ? failure.message : "请求未完成或已中止" });
}
controller.enqueue({ type: "finish", finishReason: value.type === "response.completed" ? "stop" : "error" });
}
};
const flushBlocks = (controller: TransformStreamDefaultController<UIMessageChunk>) => {
let match: RegExpExecArray | null;
while ((match = /\r?\n\r?\n/.exec(buffer))) {
const block = buffer.slice(0, match.index);
buffer = buffer.slice(match.index + match[0].length);
parse(block, controller);
}
if (buffer.length > 1024 * 1024) throw new Error("Responses 单个流事件过大");
};
return stream.pipeThrough(new TransformStream<Uint8Array, UIMessageChunk>({
transform(chunk, controller) { buffer += decoder.decode(chunk, { stream: true }); flushBlocks(controller); },
flush(controller) {
buffer += decoder.decode();
flushBlocks(controller);
if (buffer.trim()) parse(buffer, controller);
if (!terminal) throw new Error("Responses 流在完成前断开,请重新打开会话查看状态");
},
}));
}
export class ResponsesChatTransport<Message extends UIMessage> extends HttpChatTransport<Message> {
protected processResponseStream(stream: ReadableStream<Uint8Array>) {
return responsesToUiStream(stream);
}
}
+14 -1
View File
@@ -1,8 +1,21 @@
import { describe, expect, it, vi } from "vitest";
import type { AgentApiClient } from "./client";
import { fetchAgentBootstrap, fetchAgentSessions } from "./swr";
import { fetchAgentBootstrap, fetchAgentSessions, resolveAgentModelSelection } from "./swr";
describe("Agent API SWR helpers", () => {
it("preserves valid model choices and resolves removed choices against the current catalog", () => {
const models = [
{ id: "primary/fast", label: "快速", description: "Primary", icon: "bolt" as const },
{ id: "secondary/expert", label: "专家", description: "Secondary", icon: "sparkle" as const }
];
const catalog = { defaultModel: "secondary/expert", models };
expect(resolveAgentModelSelection(catalog, "primary/fast")).toBe("primary/fast");
expect(resolveAgentModelSelection(catalog, "retired/fast")).toBe("secondary/expert");
expect(resolveAgentModelSelection(catalog)).toBe("secondary/expert");
expect(resolveAgentModelSelection({ ...catalog, defaultModel: "missing/model" }, "retired/fast")).toBe("primary/fast");
expect(resolveAgentModelSelection({ defaultModel: "primary/fast", models: [] }, "primary/fast")).toBe("");
});
it("loads bootstrap data with a normalized UI registry", async () => {
const client = {
getUiRegistry: vi.fn(async () => ({
+8 -1
View File
@@ -1,7 +1,8 @@
import type {
AgentApiClient,
AgentChatSessionSummary,
AgentModelOption
AgentModelOption,
AgentModelsResponse
} from "./client";
import { parseUiRegistry, type UIRegistry } from "../ui-envelope";
import { parseFrontendActionRegistry, type FrontendActionRegistry } from "../frontend-action";
@@ -16,6 +17,12 @@ export type AgentBootstrapData = {
defaultModel: string;
};
export function resolveAgentModelSelection(catalog: AgentModelsResponse, current = ""): string {
if (catalog.models.some((model) => model.id === current)) return current;
if (catalog.models.some((model) => model.id === catalog.defaultModel)) return catalog.defaultModel;
return catalog.models[0]?.id ?? "";
}
export async function fetchAgentBootstrap(client: AgentApiClient): Promise<AgentBootstrapData> {
const [rawRegistry, rawFrontendActionRegistry, modelsResponse] = await Promise.all([
client.getUiRegistry(),
@@ -4,21 +4,25 @@ import { getAnswerPresentation } from "../answer-ui/answer-presentation";
import { Disclosure } from "@/shared/ui/disclosure";
import {
Activity,
ArrowUpRight,
CheckCircle2,
ChevronDown,
ChevronsUpDown,
ChevronLeft,
DatabaseZap,
Folder,
FolderPlus,
History,
LoaderCircle,
MapPinned,
Mic,
Pause,
Play,
Plus,
Quote,
SendHorizontal,
Shield,
ShieldCheck,
Square,
UserRound,
Volume2,
X
} from "lucide-react";
@@ -27,6 +31,7 @@ import {
useCallback,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
@@ -58,12 +63,15 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from "@/shared/ui/dropdown-menu";
import { Button } from "@/shared/ui/button";
import { cn } from "@/shared/ui/cn";
import { StatusBadge, StatusDot, type StatusTone } from "@/shared/ui/status";
import type { AgentChatSessionSummary } from "../api/client";
import type { AgentChatSessionSummary, AgentInputDelivery, AgentResponseInput } from "../api/client";
import { AgentPersona } from "./agent-persona";
import {
agentPanelItemVariants,
@@ -71,6 +79,8 @@ import {
agentPanelSectionVariants
} from "./agent-motion";
import { AgentHistoryPanel } from "./agent-history-panel";
import { AgentMessageInbox } from "./agent-message-inbox";
import type { ComponentProps } from "react";
import { AgentMessageDetails, AgentMessageProgress } from "./agent-message-details";
import { AgentUiEnvelopeRenderer } from "./agent-ui-envelope-renderer";
import { useAgentSpeechRecognition, useAgentSpeechSynthesis } from "../hooks/use-agent-voice";
@@ -101,6 +111,11 @@ export type AgentWorkspaceContextItem = {
tone?: "neutral" | "info" | "warning";
};
export type AgentConversationProjectOption = {
id: string;
name: string;
};
type AgentCommandPanelProps = {
presentation?: AgentCommandPanelPresentation;
collapsing?: boolean;
@@ -108,20 +123,37 @@ type AgentCommandPanelProps = {
sessionTitle?: string;
sessionHistory?: AgentChatSessionSummary[];
sessionHistoryLoading?: boolean;
promptSuggestions?: Array<{ question: string; sourceLabel?: string }>;
promptSuggestionsLoading?: boolean;
promptSuggestionsUnavailable?: boolean;
activeSessionId?: string | null;
qaOnly?: boolean;
statusLabel?: string;
connectionStatus?: AgentConnectionStatus;
streaming?: boolean;
inputDelivery?: AgentInputDelivery;
pendingInputs?: AgentResponseInput[];
onInputDeliveryChange?: (delivery: AgentInputDelivery) => void;
onPendingInputDeliveryChange?: (id: string, delivery: AgentInputDelivery) => Promise<void> | void;
messages?: AgentChatMessage[];
streamRenderState?: AgentStreamRenderState;
modelOptions?: AgentModelOption[];
selectedModel?: string;
conversationScope?: "personal" | "project";
onConversationScopeChange?: (scope: "personal" | "project") => Promise<void> | void;
conversationProjects?: AgentConversationProjectOption[];
selectedConversationProjectId?: string | null;
onConversationProjectChange?: (projectId: string) => Promise<void> | void;
currentProjectName?: string | null;
onOpenProjectCreator?: () => void;
approvalMode?: AgentApprovalMode;
uiResults?: AgentUiResult[];
reviewPanel?: ReactNode;
messageInbox?: ComponentProps<typeof AgentMessageInbox>;
workspaceContext?: AgentWorkspaceContextItem[];
prompt: string;
promptFocusRequest?: number;
promptReference?: { title: string; detail?: string; placeholder?: string };
onCollapse: () => void;
onPromptChange: Dispatch<SetStateAction<string>>;
onRefreshHistory?: () => Promise<void> | void;
@@ -142,22 +174,6 @@ type AgentCommandPanelProps = {
onRejectQuestion?: (request: AgentQuestionRequest) => Promise<void> | void;
};
const AGENT_PROMPT_SUGGESTIONS = [
"分析当前管网中的水力瓶颈管道,并给出改造建议。",
"供水服务分区分析。",
"帮我分析当前管网压力异常点,并按风险等级排序。",
"帮我生成一份今日运行简报,包含问题、原因和建议。",
"查询关键 SCADA 点位最近 24 小时的异常波动。",
"排查当前管网爆管风险,并说明优先处置建议。"
] as const;
const AGENT_CAPABILITIES = [
{ icon: DatabaseZap, label: "水力瓶颈诊断" },
{ icon: Activity, label: "压力异常研判" },
{ icon: ShieldCheck, label: "爆管风险排查" },
{ icon: MapPinned, label: "GIS 地图联动" }
] as const;
export function AgentCommandPanel({
presentation = "desktop-dock",
collapsing = false,
@@ -165,18 +181,35 @@ export function AgentCommandPanel({
sessionTitle = "新对话",
sessionHistory = [],
sessionHistoryLoading = false,
promptSuggestions = [],
promptSuggestionsLoading = false,
promptSuggestionsUnavailable = false,
activeSessionId,
qaOnly = false,
connectionStatus = "connecting",
streaming = false,
inputDelivery = "steer",
pendingInputs = [],
onInputDeliveryChange,
onPendingInputDeliveryChange,
messages = [],
streamRenderState = {},
modelOptions = [],
selectedModel = "",
conversationScope = "project",
onConversationScopeChange,
conversationProjects = [],
selectedConversationProjectId,
onConversationProjectChange,
currentProjectName,
onOpenProjectCreator,
approvalMode = "request",
uiResults = [],
reviewPanel,
messageInbox,
prompt,
promptFocusRequest = 0,
promptReference,
onCollapse,
onPromptChange,
onRefreshHistory,
@@ -200,11 +233,31 @@ export function AgentCommandPanel({
const historyTriggerRef = useRef<HTMLButtonElement | null>(null);
const historyPanelRef = useRef<HTMLDivElement | null>(null);
const promptRestoreGenerationRef = useRef(0);
const composerRef = useRef<HTMLDivElement | null>(null);
const promptTextareaRef = useRef<HTMLTextAreaElement | null>(null);
const composerStartRectRef = useRef<DOMRect | null>(null);
const composerTransitionRef = useRef<Animation | null>(null);
const mountedRef = useRef(true);
const desktopFloating = presentation === "desktop-floating";
const floatingPresentation = desktopFloating || presentation === "mobile-sheet";
const hasConversation = messages.length > 0 || streaming;
const hasConversation = messages.length > 0 || streaming || Boolean(messageInbox?.messages.length);
const centeredComposer = !hasConversation && (presentation === "desktop-dock" || Boolean(promptReference));
const showStartSuggestions = centeredComposer && !qaOnly && !promptReference;
const prefersReducedMotion = useReducedMotion();
const trimmedPrompt = prompt.trim();
useEffect(() => {
if (!promptFocusRequest) return;
const frame = requestAnimationFrame(() => {
const textarea = promptTextareaRef.current;
textarea?.focus();
textarea?.setSelectionRange(textarea.value.length, textarea.value.length);
});
return () => cancelAnimationFrame(frame);
}, [promptFocusRequest]);
const selectedConversationProject = conversationProjects.find(
(project) => project.id === selectedConversationProjectId
);
const selectedProjectName = selectedConversationProject?.name ?? currentProjectName ?? "当前项目";
const {
speechState,
speakingMessageId,
@@ -296,12 +349,31 @@ export function AgentCommandPanel({
if (streaming) stopListening();
}, [stopListening, streaming]);
useLayoutEffect(() => {
if (centeredComposer || !composerStartRectRef.current || !composerRef.current) return;
const start = composerStartRectRef.current;
composerStartRectRef.current = null;
const end = composerRef.current.getBoundingClientRect();
if (prefersReducedMotion || (start.x === end.x && start.y === end.y)) return;
composerTransitionRef.current?.cancel();
composerTransitionRef.current = composerRef.current.animate(
[
{ transform: `translate(${start.x - end.x}px, ${start.y - end.y}px)` },
{ transform: "translate(0, 0)" }
],
{ duration: 280, easing: "cubic-bezier(0.22, 1, 0.36, 1)" }
);
}, [centeredComposer, prefersReducedMotion]);
const submitPrompt = (nextPrompt: string) => {
if (!nextPrompt || streaming) {
if (!nextPrompt) {
return;
}
const restoreGeneration = promptRestoreGenerationRef.current + 1;
promptRestoreGenerationRef.current = restoreGeneration;
if (centeredComposer && !prefersReducedMotion && composerRef.current) {
composerStartRectRef.current = composerRef.current.getBoundingClientRect();
}
onPromptChange("");
stopListening();
setScrollRequestId((requestId) => requestId + 1);
@@ -335,6 +407,7 @@ export function AgentCommandPanel({
<aside
aria-label="Agent 命令面板"
data-agent-presentation={presentation}
data-agent-starting={centeredComposer}
className={cn(
"pointer-events-auto flex h-full min-h-0 w-full flex-col",
"agent-panel-shell",
@@ -361,12 +434,12 @@ export function AgentCommandPanel({
historyOpen && "agent-panel-header-expanded"
)}
>
<div className={cn("flex items-center justify-between gap-2 px-3", presentation === "desktop-dock" ? "min-h-14 py-1.5" : "min-h-16 py-2")}>
<div className={cn("flex items-center justify-between gap-2 px-3", presentation === "desktop-dock" ? "h-14 py-1" : "min-h-16 py-2")}>
<div className="flex min-w-0 flex-1 items-center gap-2.5 text-slate-900">
<AgentPersona className={presentation === "desktop-dock" ? "h-10 w-10" : "h-12 w-12"} state={personaState} />
<div className="min-w-0 flex-1">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<div className="min-w-0 space-y-0.5">
<div className="flex min-w-0 items-center gap-3">
<p className="shrink-0 text-xs font-semibold uppercase text-[var(--ink-muted)]">
对话主题
</p>
@@ -382,12 +455,90 @@ export function AgentCommandPanel({
{agentConnectionMetadata[connectionStatus].label}
</StatusBadge>
</div>
<p
className="truncate text-sm font-semibold text-slate-950"
title={sessionTitle}
>
{sessionTitle}
</p>
<div className="flex min-w-0 items-center gap-2">
<p
className="min-w-0 shrink truncate text-sm font-semibold text-slate-950"
title={sessionTitle}
>
{sessionTitle}
</p>
{onConversationScopeChange && !qaOnly ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="ghost" size="sm" className="agent-project-trigger h-7 max-w-36 shrink-0 gap-1.5 rounded-xl px-2.5 text-xs font-medium text-slate-600 focus-visible:ring-1 focus-visible:ring-offset-0"
aria-label="选择对话归属" disabled={streaming}>
{conversationScope === "personal"
? <UserRound size={13} aria-hidden="true" />
: <Folder size={13} aria-hidden="true" />}
<span className="truncate">
{conversationScope === "personal" ? "个人会话" : selectedProjectName}
</span>
<ChevronDown size={12} className="opacity-60" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent data-agent-presentation={presentation} align="start" sideOffset={8} className="agent-panel-overlay w-60 p-2">
<DropdownMenuLabel className="px-3 pb-1.5 pt-1 text-xs font-medium text-[var(--ink-muted)]">
对话归属
</DropdownMenuLabel>
<DropdownMenuItem
className={cn(
"min-h-10 rounded-lg px-3 text-sm",
conversationScope === "personal" && "bg-[var(--action-selection-soft)] text-slate-900"
)}
onSelect={() => void onConversationScopeChange("personal")}
>
<UserRound size={14} className="text-[var(--ink-muted)]" aria-hidden="true" />
<span className="flex-1">个人会话</span>
{conversationScope === "personal" ? <CheckCircle2 size={14} className="text-[var(--action-blue-hover)]" aria-hidden="true" /> : null}
</DropdownMenuItem>
{conversationProjects.length > 0 ? conversationProjects.map((project) => {
const selected = conversationScope === "project"
&& project.id === selectedConversationProjectId;
return (
<DropdownMenuItem
key={project.id}
className={cn(
"min-h-10 rounded-lg px-3 text-sm",
selected && "bg-[var(--action-selection-soft)] text-slate-900"
)}
onSelect={() => {
if (onConversationProjectChange) {
void onConversationProjectChange(project.id);
return;
}
void onConversationScopeChange("project");
}}
>
<Folder size={14} className="text-[var(--ink-muted)]" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate">{project.name}</span>
{selected ? <CheckCircle2 size={14} className="text-[var(--action-blue-hover)]" aria-hidden="true" /> : null}
</DropdownMenuItem>
);
}) : (
<DropdownMenuItem
className={cn(
"min-h-10 rounded-lg px-3 text-sm",
conversationScope === "project" && "bg-[var(--action-selection-soft)] text-slate-900"
)}
onSelect={() => void onConversationScopeChange("project")}
>
<Folder size={14} className="text-[var(--ink-muted)]" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate">{selectedProjectName}</span>
{conversationScope === "project" ? <CheckCircle2 size={14} className="text-[var(--action-blue-hover)]" aria-hidden="true" /> : null}
</DropdownMenuItem>
)}
<DropdownMenuSeparator className="my-1" />
<DropdownMenuItem
className="min-h-10 rounded-lg px-3 text-sm font-medium text-[var(--action-blue-hover)] focus:bg-[var(--action-soft)] focus:text-[var(--action-blue-hover)]"
onSelect={onOpenProjectCreator}
>
<FolderPlus size={14} aria-hidden="true" />
新建项目
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
</div>
</div>
</div>
@@ -481,7 +632,7 @@ export function AgentCommandPanel({
)}
>
<AnimatePresence mode="popLayout" initial={false}>
{messages.length > 0 ? (
{messages.length > 0 || Boolean(messageInbox?.messages.length) ? (
<BackendMessageList
key="messages"
presentation={presentation}
@@ -502,27 +653,12 @@ export function AgentCommandPanel({
onLocateAnswerFeatures={onLocateAnswerFeatures}
onSubmitAnswerForm={onSubmitPrompt}
/>
) : (
<motion.div
key="empty"
className={cn(
"flex flex-1 px-4",
presentation === "mobile-sheet"
? "items-start justify-center pb-0 pt-5"
: "items-center justify-center py-8"
)}
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelItemVariants}
>
<AgentEmptyState compact={presentation === "mobile-sheet"} />
</motion.div>
)}
) : null}
</AnimatePresence>
{!qaOnly ? <AgentUiEnvelopeRenderer results={uiResults} /> : null}
{reviewPanel}
{messageInbox ? <AgentMessageInbox {...messageInbox} /> : null}
</ConversationContent>
<AgentConversationScrollManager
activeSessionId={activeSessionId}
@@ -543,16 +679,20 @@ export function AgentCommandPanel({
<div
className={cn(
"agent-panel-band relative",
centeredComposer && "agent-panel-start-composer",
floatingPresentation
? "form-field agent-panel-floating-acrylic agent-panel-composer absolute bottom-3 z-20 rounded-2xl p-1"
: "border-t border-[var(--surface-divider)] p-3"
)}
>
{!hasConversation ? (
{showStartSuggestions ? (
<h2 className="agent-panel-start-heading">我能帮你处理什么?</h2>
) : null}
{!hasConversation && !centeredComposer && !qaOnly ? (
<div
className={cn(
"agent-panel-conversation pointer-events-none absolute inset-x-0 bottom-full z-10 px-3 pb-2 pt-2",
floatingPresentation && "bg-transparent"
"agent-panel-suggestions pointer-events-none absolute inset-x-0 bottom-full z-10 px-3 pb-2 pt-2",
floatingPresentation && "agent-panel-conversation bg-transparent"
)}
>
<Suggestions
@@ -560,78 +700,142 @@ export function AgentCommandPanel({
role="group"
className="pointer-events-auto px-0.5"
>
{AGENT_PROMPT_SUGGESTIONS.map((suggestion) => (
{promptSuggestions.map(({ question }) => (
<Suggestion
key={suggestion}
suggestion={suggestion}
key={question}
suggestion={question}
onClick={submitPrompt}
className="surface-reading max-w-full border border-[var(--surface-divider)] text-slate-600 hover:bg-[var(--action-soft)] hover:text-[var(--action-blue-hover)]"
>
<span className="truncate">{suggestion}</span>
<span className="truncate">{question}</span>
</Suggestion>
))}
{promptSuggestionsLoading ? <span className="px-2 text-xs text-[var(--ink-muted)]">正在整理推荐问题…</span> : null}
</Suggestions>
</div>
) : null}
<PromptInput
className="form-field agent-panel-control overflow-hidden rounded-2xl shadow-xs [&>[data-slot=input-group]]:rounded-[inherit] [&>[data-slot=input-group]]:border-0 [&>[data-slot=input-group]]:shadow-none"
onSubmit={(message) => {
const nextPrompt = message.text.trim();
if (nextPrompt) {
submitPrompt(nextPrompt);
}
}}
<div
ref={composerRef}
className="agent-panel-composer-motion"
>
<PromptInputBody>
<PromptInputTextarea
value={prompt}
onChange={(event) => {
promptRestoreGenerationRef.current += 1;
onPromptChange(event.currentTarget.value);
}}
className={cn("text-sm", floatingPresentation ? "min-h-20" : "min-h-14")}
placeholder="输入调度问题,Agent 将通过后端会话流式响应"
/>
</PromptInputBody>
<PromptInputFooter className="justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
{!qaOnly ? <ApprovalModeSelect presentation={presentation} value={approvalMode} disabled={streaming} onValueChange={onApprovalModeChange} /> : <span className="px-1 text-[11px] text-[var(--ink-muted)]">任务协作问答</span>}
{streaming ? (
<div className="mb-2 flex items-center justify-between gap-2 px-1 text-xs text-[var(--ink-muted)]">
<span>追加消息</span>
<InputDeliverySelect presentation={presentation} label="运行中消息发送方式"
value={inputDelivery} onValueChange={onInputDeliveryChange} />
</div>
<div className="flex shrink-0 items-center gap-2">
<AgentModelSelect
presentation={presentation}
models={modelOptions}
value={selectedModel}
onValueChange={onSelectModel}
/>
{isRecognitionSupported ? (
<VoiceInputButton
isListening={isListening}
disabled={streaming}
onClick={
isListening ? stopListening : () => startListening(showRecognitionError)
}
/>
) : null}
{pendingInputs.length ? (
<ul aria-label="待处理消息" className="mb-2 max-h-32 space-y-1 overflow-y-auto text-xs text-[var(--ink-muted)]">
{pendingInputs.map((input) => (
<li key={input.id} className="flex min-w-0 items-center gap-2 rounded-md bg-[var(--surface-subtle)] px-2 py-1.5">
<span className="min-w-0 flex-1 truncate" title={input.text}>{input.text}</span>
<InputDeliverySelect presentation={presentation} label={`消息发送方式:${input.text}`}
value={input.delivery} onValueChange={onPendingInputDeliveryChange
? (delivery) => { void onPendingInputDeliveryChange(input.id, delivery); }
: undefined} />
</li>
))}
</ul>
) : null}
<PromptInput
className={cn(
"agent-panel-control overflow-hidden rounded-2xl [&>[data-slot=input-group]]:rounded-[inherit] [&>[data-slot=input-group]]:border-0 [&>[data-slot=input-group]]:shadow-none",
presentation === "desktop-dock" ? "agent-panel-input-form" : "form-field shadow-xs"
)}
onSubmit={(message) => {
const nextPrompt = message.text.trim();
if (nextPrompt) {
submitPrompt(nextPrompt);
}
}}
>
<PromptInputBody className={presentation === "desktop-dock" ? "agent-panel-input-body form-field" : undefined}>
{promptReference ? (
<div role="group" aria-label="对话引用" className="mx-3 mt-3 flex min-w-0 items-start gap-2 rounded-lg bg-[var(--surface-subtle)] px-3 py-2 text-[var(--ink-muted)]">
<Quote aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
<div className="min-w-0 text-xs leading-relaxed">
<p className="break-words font-medium text-[var(--ink)]">{promptReference.title}</p>
{promptReference.detail ? <p className="break-words">{promptReference.detail}</p> : null}
</div>
</div>
) : null}
<PromptInputSubmit
aria-label={streaming ? "停止 Agent 指令" : "发送 Agent 指令"}
title={streaming ? "停止 Agent 指令" : "发送 Agent 指令"}
className="agent-panel-primary-icon"
disabled={!streaming && !trimmedPrompt}
status={streaming ? "streaming" : "ready"}
onStop={onStopPrompt}
>
{streaming ? undefined : (
<SendHorizontal
size={15}
style={{ transform: "rotate(-45deg)" }}
aria-hidden="true"
<PromptInputTextarea
ref={promptTextareaRef}
value={prompt}
onChange={(event) => {
promptRestoreGenerationRef.current += 1;
onPromptChange(event.currentTarget.value);
}}
className={cn("agent-panel-prompt-textarea text-sm", floatingPresentation ? "min-h-20" : "min-h-14")}
placeholder={promptReference?.placeholder ?? "询问供水问题,或描述需要处理的任务"}
/>
</PromptInputBody>
<PromptInputFooter className="agent-panel-input-tools justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
{!qaOnly ? <ApprovalModeSelect presentation={presentation} value={approvalMode} disabled={streaming} onValueChange={onApprovalModeChange} /> : <span className="px-1 text-[11px] text-[var(--ink-muted)]">任务协作问答</span>}
</div>
<div className="flex shrink-0 items-center gap-2">
<AgentModelSelect
presentation={presentation}
models={modelOptions}
value={selectedModel}
onValueChange={streaming ? undefined : onSelectModel}
/>
{isRecognitionSupported ? (
<VoiceInputButton
isListening={isListening}
disabled={streaming}
onClick={
isListening ? stopListening : () => startListening(showRecognitionError)
}
/>
)}
</PromptInputSubmit>
</div>
</PromptInputFooter>
</PromptInput>
) : null}
{streaming ? <Button type="button" variant="ghost" size="icon" aria-label="停止 Agent 指令" title="停止 Agent 指令"
className="agent-panel-icon-button shrink-0" onClick={onStopPrompt}><Square size={14} aria-hidden="true" /></Button> : null}
<PromptInputSubmit
aria-label={streaming ? inputDelivery === "steer" ? "引导当前 Agent 任务" : "将消息加入 Agent 队列" : "发送 Agent 指令"}
title={streaming ? inputDelivery === "steer" ? "引导当前 Agent 任务" : "将消息加入 Agent 队列" : "发送 Agent 指令"}
className="agent-panel-primary-icon"
disabled={!trimmedPrompt}
status="ready"
>
{(
<SendHorizontal
size={15}
style={{ transform: "rotate(-45deg)" }}
aria-hidden="true"
/>
)}
</PromptInputSubmit>
</div>
</PromptInputFooter>
</PromptInput>
</div>
{showStartSuggestions ? (
<div className="agent-panel-start-suggestions" role="group" aria-label="推荐问题" aria-live="polite" aria-busy={promptSuggestionsLoading}>
{promptSuggestionsLoading ? (
<p className="agent-panel-suggestion-status">正在根据最近活动整理问题…</p>
) : promptSuggestions.length ? promptSuggestions.map((suggestion) => (
<button
key={suggestion.question}
type="button"
className="agent-panel-start-suggestion group"
title={suggestion.question}
onClick={() => submitPrompt(suggestion.question)}
>
<Activity size={17} className="shrink-0 text-[var(--action-blue-hover)]" aria-hidden="true" />
<span className="min-w-0 flex-1">
<span className="line-clamp-2 block text-sm leading-5">{suggestion.question}</span>
{suggestion.sourceLabel ? <span className="mt-1 block text-[11px] leading-4 text-[var(--ink-muted)]">{suggestion.sourceLabel}</span> : null}
</span>
<ArrowUpRight size={14} className="shrink-0 text-[var(--ink-muted)]" aria-hidden="true" />
</button>
)) : (
<p className="agent-panel-suggestion-status">{promptSuggestionsUnavailable ? "推荐问题暂不可用,可直接输入任务。" : "描述调查目标,即可开始分析。"}</p>
)}
</div>
) : null}
</div>
</Agent>
</aside>
@@ -666,14 +870,14 @@ function VoiceInputButton({
aria-pressed={isListening}
title={label}
className={cn(
"group relative grid h-8 w-8 shrink-0 place-items-center overflow-visible rounded-lg border after:absolute after:-inset-1 after:content-['']",
"border-transparent bg-transparent shadow-none",
"group relative grid h-8 w-8 shrink-0 place-items-center overflow-visible rounded-lg border-0 after:absolute after:-inset-1 after:content-['']",
"bg-transparent shadow-none",
"transition-[color,background-color,border-color,box-shadow,transform] duration-200",
"focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)] focus-visible:ring-offset-1",
"focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-[var(--action-focus-ring)] focus-visible:ring-offset-0",
"disabled:cursor-not-allowed disabled:opacity-45",
isListening
? "status-tone-danger bg-[var(--status-soft)] text-[var(--status-foreground)] [@media(hover:hover)]:hover:border-[var(--status-border)] [@media(hover:hover)]:hover:brightness-95"
: "text-slate-600 [@media(hover:hover)]:hover:border-[var(--action-border-hover)] [@media(hover:hover)]:hover:bg-[var(--action-soft)] [@media(hover:hover)]:hover:text-[var(--action-blue-hover)]"
: "text-slate-600 [@media(hover:hover)]:hover:bg-[var(--action-soft)] [@media(hover:hover)]:hover:text-[var(--action-blue-hover)] active:bg-[var(--action-soft-pressed)]"
)}
disabled={disabled}
onClick={onClick}
@@ -762,6 +966,38 @@ function VoiceInputButton({
const AGENT_SELECT_ITEM_CLASS_NAME =
"rounded-lg border border-transparent px-2.5 py-2 focus:border-[var(--action-border-hover)] focus:bg-[var(--action-selection-soft-hover)] data-[highlighted]:border-[var(--action-border-hover)] data-[highlighted]:bg-[var(--action-selection-soft-hover)] motion-reduce:transition-none";
function InputDeliverySelect({ presentation, label, value, onValueChange }: {
presentation: AgentCommandPanelPresentation;
label: string;
value: AgentInputDelivery;
onValueChange?: (delivery: AgentInputDelivery) => void;
}) {
return <DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="outline" size="sm" aria-label={label}
className="agent-panel-control h-10 shrink-0 gap-2 px-2.5 text-xs font-medium"
disabled={!onValueChange}>
{value === "queue" ? "排队处理" : "引导当前任务"}
<ChevronDown className="size-3 opacity-60" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent data-agent-presentation={presentation} side="top" align="end" sideOffset={6}
collisionPadding={12} className="agent-panel-overlay agent-panel-control w-44 rounded-xl p-1.5 shadow-lg">
<DropdownMenuRadioGroup aria-label={label} value={value} onValueChange={(delivery) => {
if (delivery === "steer" || delivery === "queue") onValueChange?.(delivery);
}}>
{([ ["steer", "引导当前任务"], ["queue", "排队处理"] ] as const).map(([delivery, text]) => (
<DropdownMenuRadioItem key={delivery} value={delivery}
className={cn(AGENT_SELECT_ITEM_CLASS_NAME, "min-h-10 pl-8 pr-2.5 text-xs",
value === delivery && "border-[var(--surface-border)] bg-[var(--action-soft)] text-[var(--action-blue-hover)]")}>
{text}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>;
}
function ApprovalModeSelect({
presentation,
value,
@@ -780,9 +1016,9 @@ function ApprovalModeSelect({
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
variant="ghost"
size="sm"
className="shrink-0 gap-1.5 border-transparent bg-transparent px-1.5 text-xs text-slate-600 shadow-none hover:bg-[var(--surface-reading)]"
className="agent-panel-toolbar-trigger shrink-0 gap-1.5 border-0 bg-transparent px-1.5 text-xs text-slate-600 shadow-none data-[state=open]:bg-[var(--action-soft)] data-[state=open]:text-[var(--action-blue-hover)] focus-visible:ring-1 focus-visible:ring-offset-0"
disabled={disabled || !onValueChange}
aria-label="权限批准模式"
>
@@ -816,7 +1052,7 @@ function ApprovalModeSelect({
<Shield className="mt-0.5 text-[var(--action-blue-hover)]" aria-hidden="true" />
<span>
<span className="block text-sm font-semibold text-slate-800">请求批准</span>
<span className="block text-xs leading-5 text-[var(--ink-muted)]">工具权限逐次确认</span>
<span className="block text-xs leading-5 text-[var(--ink-muted)]">需要审批的请求逐次确认;只读白名单直接执行</span>
</span>
{requestApproval ? (
<CheckCircle2 className="ml-auto mt-0.5 text-[var(--action-blue-hover)]" aria-hidden="true" />
@@ -837,7 +1073,7 @@ function ApprovalModeSelect({
/>
<span>
<span className="block text-sm font-semibold text-slate-800">始终允许</span>
<span className="block text-xs leading-5 text-[var(--ink-muted)]">自动允许本轮权限请求</span>
<span className="block text-xs leading-5 text-[var(--ink-muted)]">自动批准本轮工具权限请求,不改变业务权限或安全限制</span>
</span>
{!requestApproval ? (
<CheckCircle2
@@ -951,7 +1187,7 @@ function AgentModelSelect({
value: string;
onValueChange?: (model: string) => void;
}) {
const selectedModel = models.find((model) => model.id === value) ?? models[0];
const selectedModel = models.find((model) => model.id === value);
if (!models.length) {
return (
@@ -964,9 +1200,9 @@ function AgentModelSelect({
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
variant="ghost"
size="sm"
className="w-12 shrink-0 justify-between gap-1 border-transparent bg-transparent px-1 text-xs text-slate-600 shadow-none hover:bg-[var(--surface-reading)] sm:w-24 sm:gap-1.5 sm:px-1.5"
className="agent-panel-toolbar-trigger w-12 shrink-0 justify-between gap-1 border-0 bg-transparent px-1 text-xs text-slate-600 shadow-none data-[state=open]:bg-[var(--action-soft)] data-[state=open]:text-[var(--action-blue-hover)] focus-visible:ring-1 focus-visible:ring-offset-0 sm:w-auto sm:min-w-[72px] sm:gap-1.5 sm:px-1.5"
disabled={!onValueChange}
aria-label="选择 Agent 模型"
aria-description={`当前模型:${selectedModel?.label ?? "模型"}`}
@@ -1041,7 +1277,7 @@ function FastModelIcon({ size }: { size: number }) {
height={size}
viewBox="0 0 24 24"
fill="none"
className="text-sky-600"
className="text-[var(--action-blue-hover)]"
aria-hidden="true"
>
<path
@@ -1070,111 +1306,6 @@ function ExpertModelIcon({ size }: { size: number }) {
);
}
function AgentReadyMark() {
return (
<svg viewBox="0 0 1024 1024" className="h-12 w-12 overflow-visible" aria-hidden="true">
<g className="agent-ready-orbit">
<path
d="M384.1536 952.1664a38.4 38.4 0 0 1-49.3568 22.528 498.3808 498.3808 0 0 1-284.928-273.92 38.4 38.4 0 0 1 70.8608-29.6448 421.5808 421.5808 0 0 0 240.896 231.6288 38.4 38.4 0 0 1 22.528 49.408zM952.1152 384.9728a38.4 38.4 0 0 1-49.4592-22.528 421.5296 421.5296 0 0 0-234.1376-241.5104 38.4 38.4 0 0 1 29.184-71.0656 498.3296 498.3296 0 0 1 276.8896 285.696 38.4 38.4 0 0 1-22.528 49.408z"
fill="var(--palette-brand)"
/>
<path
d="M981.248 768.0512a42.6496 42.6496 0 1 1-85.2992 0 42.6496 42.6496 0 0 1 85.2992 0zM127.9488 256.0512a42.6496 42.6496 0 1 1-85.3504 0 42.6496 42.6496 0 0 1 85.3504 0z"
fill="var(--palette-champagne)"
/>
<path
d="M810.496 938.8544a42.6496 42.6496 0 1 1-85.2992 0 42.6496 42.6496 0 0 1 85.3504 0zM298.496 85.504a42.6496 42.6496 0 1 1-85.2992 0 42.6496 42.6496 0 0 1 85.3504 0z"
fill="var(--palette-sand)"
/>
</g>
<path
className="agent-ready-core"
d="M511.9488 276.736l-27.8528 114.7392A126.0544 126.0544 0 0 1 391.3216 484.352l-114.7904 27.8528 114.7904 27.8016a126.0544 126.0544 0 0 1 92.7744 92.8256L512 747.52l27.8016-114.7392a126.0544 126.0544 0 0 1 92.8256-92.8256l114.7392-27.8016-114.7392-27.8528a126.0544 126.0544 0 0 1-92.8256-92.8256L512 276.736z m55.6544-62.1568c-14.1312-58.368-97.1776-58.368-111.36 0L417.28 375.296a57.344 57.344 0 0 1-42.1888 42.1888l-160.6656 38.912c-58.4192 14.1824-58.4192 97.28 0 111.4112l160.6656 38.9632c20.8384 5.12 37.12 21.3504 42.1888 42.1888l38.9632 160.7168c14.1824 58.368 97.2288 58.368 111.36 0l38.9632-160.7168a57.344 57.344 0 0 1 42.1888-42.1888l160.7168-38.912c58.368-14.1824 58.368-97.28 0-111.4112l-160.7168-38.9632a57.344 57.344 0 0 1-42.1888-42.1888l-38.912-160.7168z"
fill="var(--action-selection-soft)"
/>
</svg>
);
}
function AgentEmptyState({ compact = false }: { compact?: boolean }) {
if (compact) {
return (
<section
aria-labelledby="agent-empty-state-title"
className="mx-auto flex w-full max-w-[360px] items-center gap-3 px-1 py-1"
lang="zh-CN"
>
<div className="surface-control grid size-9 shrink-0 place-items-center rounded-lg shadow-[inset_0_0_0_1px_oklch(0.84_0.025_260)]">
<AgentReadyMark />
</div>
<div className="min-w-0 flex-1 text-left">
<h2
id="agent-empty-state-title"
className="text-sm font-semibold leading-5 text-slate-950"
>
我已就绪,请描述任务
</h2>
<p className="mt-0.5 text-xs leading-5 text-slate-600">
描述调查目标,我会整理证据并带回地图。
</p>
</div>
</section>
);
}
return (
<section
aria-labelledby="agent-empty-state-title"
className="agent-empty-state mx-auto w-full max-w-[360px]"
lang="zh-CN"
>
<div className="px-2 py-4">
<div className="flex items-center gap-4">
<div className="agent-empty-mark surface-control grid h-[72px] w-[72px] shrink-0 place-items-center rounded-xl shadow-[inset_0_0_0_1px_oklch(0.84_0.025_260)]">
<AgentReadyMark />
</div>
<div className="min-w-0 flex-1 text-left">
<h2
id="agent-empty-state-title"
className="text-balance text-lg font-semibold leading-7 text-slate-950"
>
我已就绪,请描述任务
</h2>
</div>
</div>
<p className="mt-5 text-pretty text-left text-sm leading-6 text-[#334155]">
直接说出你想调查的问题,我会整理监测与空间证据,并将分析结果带回地图。
</p>
</div>
<ul
className={cn(
"agent-capabilities surface-control grid grid-cols-2 overflow-hidden rounded-xl shadow-[inset_0_0_0_1px_rgba(148,163,184,0.32)]",
compact && "hidden"
)}
aria-label="Agent 分析能力"
>
{AGENT_CAPABILITIES.map(({ icon: Icon, label }, index) => (
<li
key={label}
className={cn(
"agent-capability flex min-h-20 items-center gap-3 px-3.5 py-3 text-left",
index % 2 === 0 && "border-r border-[var(--surface-border)]/80",
index < 2 && "border-b border-[var(--surface-border)]/80"
)}
>
<span className="agent-capability-icon grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-[#f9fbfe] text-[#334155] shadow-[inset_0_0_0_1px_rgba(148,163,184,0.28)]">
<Icon size={16} strokeWidth={1.8} aria-hidden="true" />
</span>
<span className="text-sm font-semibold leading-5 text-[#1e293b]">{label}</span>
</li>
))}
</ul>
</section>
);
}
function BackendMessageList({
presentation,
messages,
@@ -0,0 +1,37 @@
import { Mail } from "lucide-react";
import { Disclosure } from "@/shared/ui/disclosure";
import { Button } from "@/shared/ui/button";
import type { AgentMailboxMessage } from "../api/agent-messages";
const messageStatusLabels = { pending: "待接收", enqueued: "已排队", delivered: "已投递", failed: "投递失败" };
export function AgentMessageInbox({ messages, loading, error, hasMore, loadingMore, onLoadMore, onRefresh }: {
messages: AgentMailboxMessage[];
loading?: boolean;
error?: string | null;
hasMore?: boolean;
loadingMore?: boolean;
onLoadMore?: () => Promise<void>;
onRefresh?: () => void;
}) {
if (!messages.length && !loading && !error) return null;
const received = messages.filter((message) => message.direction === "to_chat").length;
return <Disclosure label={<span className="flex items-center gap-2 px-3"><Mail size={14} />任务消息{received ? ` · 收到 ${received} 条` : ""}</span>} initiallyOpen className="surface-reading rounded-xl">
<div className="space-y-3 p-3 text-xs" aria-live="polite">
<p className="text-[var(--ink-muted)]">新消息会在此显示。由你决定是否继续发送指令。</p>
{loading ? <p className="text-[var(--ink-muted)]">正在加载任务消息…</p> : null}
{error ? <div className="flex items-center justify-between gap-2"><p className="text-[var(--status-warning-foreground)]">{error}</p><Button variant="ghost" size="sm" onClick={onRefresh}>重试</Button></div> : null}
{messages.map((message) => <article key={message.message_id} className="space-y-1.5 border-t border-[var(--surface-divider)] pt-3">
<div className="flex flex-wrap items-center justify-between gap-2 text-[var(--ink-muted)]">
<span>{message.direction === "to_chat" ? "调查任务来信" : "发给调查任务"}</span>
<span>{messageStatusLabels[message.status]}</span>
</div>
<p className="whitespace-pre-wrap break-words text-sm leading-6 text-[var(--ink)]">{message.text}</p>
<p className="break-all text-[var(--ink-muted)]">task_id: <span className="select-text font-mono">{message.task_id}</span></p>
<p className="break-all text-[var(--ink-muted)]">run_id: <span className="select-text font-mono">{message.run_id}</span></p>
{message.failure_detail ? <p className="break-words text-[var(--status-danger-foreground)]">{message.failure_detail}</p> : null}
</article>)}
{hasMore ? <Button variant="ghost" size="sm" disabled={loadingMore} onClick={() => void onLoadMore?.().catch(() => undefined)}>{loadingMore ? "正在加载…" : "加载更多消息"}</Button> : null}
</div>
</Disclosure>;
}
@@ -1,135 +0,0 @@
import { ClipboardCheck, Crosshair, MapPinned, Radio, Route } from "lucide-react";
import type { ReactNode } from "react";
import { Button } from "@/shared/ui/button";
import { StatusDot, type StatusTone } from "@/shared/ui/status";
import { countPendingAgentInputs } from "../message-state";
import type { AgentChatMessage } from "../types";
export function AgentOperationalBrief({
messages,
statusLabel,
streaming,
onSubmitCommand
}: {
messages: AgentChatMessage[];
statusLabel: string;
streaming: boolean;
onSubmitCommand: (prompt: string) => void;
}) {
const brief = createOperationalBrief(messages, statusLabel, streaming);
return (
<div className="agent-panel-operational-brief agent-panel-control rounded-2xl p-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<StatusDot tone={brief.statusTone} activity={streaming ? "live" : "static"} size="md" />
<p className="truncate text-sm font-semibold text-slate-950">{brief.stateLabel}</p>
</div>
<p className="mt-1 truncate text-xs leading-5 text-slate-600" title={brief.task}>
{brief.task}
</p>
</div>
<dl className="mt-3 grid grid-cols-3 gap-2">
<AgentBriefMetric
icon={<Radio size={13} aria-hidden="true" />}
label="证据"
value={brief.evidence}
/>
<AgentBriefMetric
icon={<Route size={13} aria-hidden="true" />}
label="计划"
value={brief.plan}
/>
<AgentBriefMetric
icon={<ClipboardCheck size={13} aria-hidden="true" />}
label="确认"
value={brief.confirmation}
/>
</dl>
<div className="mt-3 grid grid-cols-2 gap-2">
<Button
type="button"
className="w-full text-xs"
disabled={streaming}
onClick={() => onSubmitCommand(brief.primaryCommand)}
>
<MapPinned size={14} aria-hidden="true" />
预览影响
</Button>
<Button
type="button"
variant="outline"
className="w-full text-xs"
disabled={streaming}
onClick={() => onSubmitCommand(brief.secondaryCommand)}
>
<Crosshair size={14} aria-hidden="true" />
检查证据
</Button>
</div>
</div>
);
}
function AgentBriefMetric({
icon,
label,
value
}: {
icon: ReactNode;
label: string;
value: string;
}) {
return (
<div className="agent-brief-metric min-w-0 rounded-xl border px-2 py-2">
<dt className="flex items-center gap-1.5 text-xs font-semibold text-slate-600">
<span className="text-[var(--action-blue-hover)]">{icon}</span>
{label}
</dt>
<dd className="mt-1 truncate text-xs font-semibold text-slate-900" title={value}>
{value}
</dd>
</div>
);
}
function createOperationalBrief(
messages: AgentChatMessage[],
statusLabel: string,
streaming: boolean
) {
const lastUserMessage = [...messages]
.reverse()
.find((message) => message.role === "user" && message.content.trim());
const progressItems = messages.flatMap((message) => message.progress ?? []);
const todoItems = messages.flatMap((message) => message.todos?.todos ?? []);
const latestProgress = progressItems.at(-1);
const activeTodo =
[...todoItems].reverse().find((todo) => todo.status === "in_progress") ??
[...todoItems].reverse().find((todo) => todo.status === "pending");
const pendingConfirmations = countPendingAgentInputs(messages);
const completedSteps = progressItems.filter((item) => item.status === "completed").length;
const task = lastUserMessage?.content.trim() || "等待调度指令";
const evidence = latestProgress?.detail || latestProgress?.title || statusLabel;
const plan =
activeTodo?.content || (completedSteps > 0 ? `${completedSteps} 项已完成` : "待生成");
const confirmation = pendingConfirmations > 0 ? `${pendingConfirmations} 项待确认` : "无需确认";
const stateLabel =
pendingConfirmations > 0 ? "等待人工确认" : streaming ? "Agent 分析中" : "分析完成";
const statusTone: StatusTone =
pendingConfirmations > 0 ? "warning" : streaming ? "info" : "success";
const promptContext = lastUserMessage?.content.trim() || "当前供水管网运行态势";
return {
task,
evidence,
plan,
confirmation,
stateLabel,
statusTone,
primaryCommand: `请基于“${promptContext}”预览空间影响范围,并列出需要确认的操作。`,
secondaryCommand: `请检查“${promptContext}”的证据链,按数据来源、风险和不确定性汇总。`
};
}
@@ -48,7 +48,7 @@ function AgentUiResultCard({ result }: { result: AgentUiResult }) {
const { envelope } = result;
return (
<div className="agent-panel-message w-full rounded-2xl p-3 text-slate-700 shadow-xs">
<div data-agent-ui-result={result.id} className="agent-panel-message w-full rounded-2xl p-3 text-slate-700 shadow-xs">
<div className="mb-3 flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-xl bg-[var(--action-selection-soft)] text-[var(--action-blue-hover)]">
@@ -260,7 +260,8 @@ function RegisteredComponentEnvelope({
if (envelope.component === "HistoryPanel") {
return (
<TrustedPanelShell icon={<History size={15} aria-hidden="true" />} title="历史数据面板">
<PropRows props={envelope.props} labels={{ feature_id: "资产 ID", featureId: "资产 ID", metric: "指标", range: "时间范围" }} />
<PropRows props={envelope.props} labels={{ feature_id: "资产 ID", featureId: "资产 ID", metric: "指标", range: "时间范围", start_time: "开始时间", end_time: "结束时间" }} />
{envelope.data ? <SafeChartEnvelope envelope={{ kind: "chart", schemaVersion: "agent-ui@1", grammar: "echarts-safe-subset", surface: "side_panel", spec: { title: "监测历史", chart_type: "line" }, data: envelope.data }} /> : null}
</TrustedPanelShell>
);
}
@@ -268,7 +269,7 @@ function RegisteredComponentEnvelope({
if (envelope.component === "ScadaPanel") {
return (
<TrustedPanelShell icon={<MonitorCog size={15} aria-hidden="true" />} title="SCADA 面板">
<PropRows props={envelope.props} labels={{ device_id: "设备 ID", deviceId: "设备 ID", metric: "指标", range: "时间范围" }} />
<PropRows props={envelope.props} labels={{ device_id: "设备 ID", deviceId: "设备 ID", metric: "指标", range: "时间范围", start_time: "开始时间", end_time: "结束时间" }} />
</TrustedPanelShell>
);
}
@@ -13,7 +13,7 @@ beforeEach(() => {
});
describe("SCADA frontend actions", () => {
it("accepts only the two registered SCADA action names", () => {
it("accepts registered view actions and rejects unknown action names", () => {
expect(parseFrontendActionRegistry({
schema_version: "frontend-action-registry@1",
actions: [
@@ -23,9 +23,10 @@ describe("SCADA frontend actions", () => {
]
})?.actions.map((action) => action.id)).toEqual([
"render_scada_analysis",
"clear_scada_analysis"
"clear_scada_analysis",
"zoom_to_map"
]);
expect(parseFrontendActionRequest(createRequest("zoom_to_map"))).toBeNull();
expect(parseFrontendActionRequest(createRequest("execute_script"))).toBeNull();
expect(parseFrontendActionRequest(createRequest("render_scada_analysis"))).not.toBeNull();
});
+2 -1
View File
@@ -1,4 +1,5 @@
export const FRONTEND_ACTION_NAMES = ["render_scada_analysis", "clear_scada_analysis"] as const;
export const FRONTEND_ACTION_NAMES = ["render_scada_analysis", "clear_scada_analysis", "locate_features", "zoom_to_map", "show_chart", "view_history"] as const;
export { parseFrontendChart } from "./chart";
export type FrontendActionName = (typeof FRONTEND_ACTION_NAMES)[number];
export type FrontendActionRegistry = { schema_version: "frontend-action-registry@1"; actions: Array<{ id: FrontendActionName; version: "frontend-action@1" }> };
export type FrontendActionRequest = { version: "frontend-action@1"; actionId: string; toolCallId: string; sessionId: string; name: FrontendActionName; params: Record<string, unknown>; issuedAt: number; expiresAt: number };
@@ -0,0 +1,44 @@
import { useCallback, useEffect, useRef, useState } from "react";
import useSWR from "swr";
import type { AgentApiClient } from "../api/client";
export function useAgentMessageInbox(client: AgentApiClient, sessionId: string | null) {
const inbox = useSWR(sessionId ? ["agent-message-inbox", sessionId] : null,
([, id]) => client.listAgentMessages(id), { revalidateOnFocus: true, shouldRetryOnError: false });
const [loadingMore, setLoadingMore] = useState(false);
const mutate = inbox.mutate;
const refresh = useCallback(() => { void mutate().catch(() => undefined); }, [mutate]);
const refreshRef = useRef(refresh);
useEffect(() => { refreshRef.current = refresh; }, [refresh]);
useEffect(() => {
if (!sessionId) return;
const controller = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
let retryDelay = 1500;
function subscribe() {
void client.streamAgentMessages(sessionId!, { signal: controller.signal, onMessage: () => { retryDelay = 1500; refreshRef.current(); } })
.catch(() => { retryDelay = Math.min(retryDelay * 2, 30_000); })
.finally(() => {
if (!controller.signal.aborted) timer = setTimeout(subscribe, retryDelay);
});
}
subscribe();
return () => { controller.abort(); clearTimeout(timer); };
}, [client, sessionId]);
return {
messages: inbox.data?.items ?? [],
hasMore: Boolean(inbox.data?.next_cursor),
loading: inbox.isLoading,
error: inbox.error ? "任务消息暂时无法加载" : null,
refresh,
loadingMore,
loadMore: async () => {
if (!sessionId || !inbox.data?.next_cursor || loadingMore) return;
setLoadingMore(true);
try {
const page = await client.listAgentMessages(sessionId, { cursor: inbox.data.next_cursor });
await inbox.mutate((current) => current ? { items: [...current.items, ...page.items.filter((item) => !current.items.some((existing) => existing.message_id === item.message_id))], next_cursor: page.next_cursor } : page, { revalidate: false });
} finally { setLoadingMore(false); }
}
};
}
+13 -2
View File
@@ -3,26 +3,35 @@ export { agentTaskDiscussionContextSchema, agentTaskContextReferenceSchema, type
export { readExecutionReport, updateExecutionReportProgress, appendExecutionReportText, type AgentExecutionReport } from "./execution-report";
export { parseExecutionPlan, readExecutionPlanHistory, executionPlanStages, executionStatusLabels, type AgentExecutionPlan } from "./execution-plan";
export { AgentCommandPanel } from "./components/agent-command-panel";
export { AgentChartView } from "./components/agent-ui-envelope-renderer";
export { countPendingAgentInputs, hasPendingAgentInput } from "./message-state";
export type {
AgentCommandPanelPresentation,
AgentConversationProjectOption,
AgentWorkspaceContextItem
} from "./components/agent-command-panel";
export { AgentPersona } from "./components/agent-persona";
export { createAgentApiClient } from "./api/client";
export { useAgentMessageInbox } from "./hooks/use-agent-message-inbox";
export type { AgentMailboxMessage } from "./api/agent-messages";
export type {
AgentApiClient,
AgentApiClientOptions,
AgentInputDelivery,
AgentResponseInput,
AgentChatSessionSummary,
AgentSessionPurpose,
AgentEventTriggerContext,
AgentLoadedChatSession,
AgentSessionStreamEvent
AgentSessionStreamEvent,
AgentRecentActivity,
AgentPromptSuggestion
} from "./api/client";
export {
agentBootstrapKey,
agentSessionsKey,
fetchAgentBootstrap,
resolveAgentModelSelection,
fetchAgentSessions,
type AgentBootstrapData
} from "./api/swr";
@@ -83,8 +92,10 @@ export type {
} from "./types";
export type { SafeChartData, SafeChartSeries, SafeChartSpec } from "./chart-data";
export { AgentChartView } from "./components/agent-ui-envelope-renderer";
export { parseFrontendChart } from "./frontend-action/chart";
export { parseAnswerUiBlock } from "./answer-ui/contract";
export type { AnswerUiBlock, AnswerUiPart } from "./answer-ui/contract";
export type { AnswerUiMapTarget } from "./answer-ui/answer-ui-view";
export { ResponsesChatTransport, createResponseRequest } from "./api/responses-transport";
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { investigationPresentationEventSchema } from "./investigation-presentation";
const event = (command: unknown) => ({ task_id: "71000000-0000-4000-8000-000000000001", run_id: "71000000-0000-4000-8000-000000000002", execution_generation: 1, command });
const overlay = { action: "set_overlay", overlay_id: "scope", title: "投诉记录分布", mode: "heatmap", evidence_id: "complaints",
data: { type: "FeatureCollection", features: [{ type: "Feature", properties: { weight: 2 }, geometry: { type: "Point", coordinates: [121.4, 31.1] } }] } };
describe("investigation presentation boundary", () => {
it("accepts only bounded registered target actions", () => {
expect(investigationPresentationEventSchema.safeParse(event({ action: "highlight_features", layer: "scada", feature_ids: ["P1"], effect: "pulse" })).success).toBe(true);
expect(investigationPresentationEventSchema.safeParse(event({ action: "highlight_features", layer: "drainage", feature_ids: ["P1"], effect: "pulse" })).success).toBe(false);
expect(investigationPresentationEventSchema.safeParse(event({ action: "run_script", script: "alert(1)" })).success).toBe(false);
});
it("rejects untrusted chart fields and mismatched series", () => {
const chart = { title: "投诉时段压力", chart_type: "line", x_data: ["08:00", "09:00"], series: [{ name: "P1", data: [20, 21] }] };
const command = { action: "open_chart", chart_id: "p1", chart, evidence_refs: ["pressure"] };
expect(investigationPresentationEventSchema.safeParse(event(command)).success).toBe(true);
expect(investigationPresentationEventSchema.safeParse(event({ ...command, chart: { ...chart, formatter: "script" } })).success).toBe(false);
expect(investigationPresentationEventSchema.safeParse(event({ ...command, chart: { ...chart, series: [{ name: "P1", data: [20] }] } })).success).toBe(false);
});
it("keeps heatmap coordinates and weights finite and point-only", () => {
expect(investigationPresentationEventSchema.safeParse(event(overlay)).success).toBe(true);
for (const feature of [
{ type: "Feature", properties: { weight: -1 }, geometry: { type: "Point", coordinates: [121.4, 31.1] } },
{ type: "Feature", properties: { weight: "2" }, geometry: { type: "Point", coordinates: [121.4, 31.1] } },
{ type: "Feature", properties: {}, geometry: { type: "Point", coordinates: [Infinity, 31.1] } },
{ type: "Feature", properties: {}, geometry: { type: "LineString", coordinates: [[121.4, 31.1], [121.5, 31.2]] } }
]) expect(investigationPresentationEventSchema.safeParse(event({ ...overlay, data: { type: "FeatureCollection", features: [feature] } })).success).toBe(false);
});
});
@@ -0,0 +1,66 @@
import { z } from "zod";
import type { FeatureCollection, Geometry } from "geojson";
import { parseFrontendChart } from "@/features/agent";
import { investigationMapTargetSchema } from "./investigation-report";
const id = z.string().trim().min(1).max(128);
const chartSchema = z.custom<NonNullable<ReturnType<typeof parseFrontendChart>>>((value) => parseFrontendChart(value) !== null);
const position = z.tuple([z.number().finite().min(-180).max(180), z.number().finite().min(-90).max(90)]).rest(z.number().finite()).refine((value) => value.length <= 3);
const line = z.array(position).min(2).max(20000);
const ring = z.array(position).min(4).max(20000).refine((value) => value[0][0] === value.at(-1)![0] && value[0][1] === value.at(-1)![1]);
const geometrySchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("Point"), coordinates: position }).strict(),
z.object({ type: z.literal("MultiPoint"), coordinates: z.array(position).min(1).max(20000) }).strict(),
z.object({ type: z.literal("LineString"), coordinates: line }).strict(),
z.object({ type: z.literal("MultiLineString"), coordinates: z.array(line).min(1).max(20000) }).strict(),
z.object({ type: z.literal("Polygon"), coordinates: z.array(ring).min(1).max(20000) }).strict(),
z.object({ type: z.literal("MultiPolygon"), coordinates: z.array(z.array(ring).min(1).max(20000)).min(1).max(20000) }).strict()
]).refine((geometry) => {
const count = (coordinates: unknown): number => !Array.isArray(coordinates) ? 0
: typeof coordinates[0] === "number" ? 1 : coordinates.reduce<number>((total, item) => total + count(item), 0);
return count(geometry.coordinates) <= 20000;
});
export const investigationOverlayDataSchema = z.object({
type: z.literal("FeatureCollection"),
features: z.array(z.object({
type: z.literal("Feature"),
id: z.union([z.string(), z.number().finite()]).optional(),
properties: z.record(z.string(), z.unknown()).nullable(),
geometry: geometrySchema
}).strict()).min(1).max(1000)
}).strict();
const overlaySchema = z.object({
action: z.literal("set_overlay"), overlay_id: id, title: z.string().trim().min(1).max(200),
mode: z.enum(["geometry", "heatmap"]), evidence_id: z.string().trim().min(1), data: investigationOverlayDataSchema
}).strict();
const commandSchema = z.discriminatedUnion("action", [
z.object({ action: z.literal("highlight_features"), layer: investigationMapTargetSchema.shape.source_id,
feature_ids: z.array(id).min(1).max(100), effect: z.enum(["steady", "pulse"]) }).strict(),
z.object({ action: z.literal("clear_highlights") }).strict(),
z.object({ action: z.literal("open_chart"), chart_id: id, chart: chartSchema, evidence_refs: z.array(z.string().trim().min(1)).min(1).max(100) }).strict(),
z.object({ action: z.literal("close_chart"), chart_id: id }).strict(),
overlaySchema,
z.object({ action: z.literal("clear_overlay"), overlay_id: id }).strict()
]).superRefine((command, ctx) => {
if (command.action !== "set_overlay" || command.mode !== "heatmap") return;
if (command.data.features.some((feature) => feature.geometry.type !== "Point"
|| (feature.properties?.weight !== undefined && (typeof feature.properties.weight !== "number" || !Number.isFinite(feature.properties.weight) || feature.properties.weight < 0)))) {
ctx.addIssue({ code: "custom", message: "投诉热力只接受点和有限非负权重" });
}
});
export const investigationPresentationEventSchema = z.object({
task_id: z.string().uuid(), run_id: z.string().uuid(), execution_generation: z.number().int().positive(), command: commandSchema
});
export const investigationPresentationCursorSchema = z.object({
task_id: z.string().uuid(), execution_generation: z.number().int().positive(),
active_run: z.object({ run_id: z.string().uuid(), generation: z.number().int().positive(),
status: z.enum(["queued", "claimed", "running", "succeeded", "failed", "timed_out", "cancelled"]) }).strict().nullable(),
event_cursor: z.number().int().nonnegative()
}).strict();
export type InvestigationPresentationCommand = z.infer<typeof commandSchema>;
export type InvestigationPresentationEvent = z.infer<typeof investigationPresentationEventSchema>;
export type InvestigationPresentationCursor = z.infer<typeof investigationPresentationCursorSchema>;
export type InvestigationOverlay = Extract<InvestigationPresentationCommand, { action: "set_overlay" }> & { visible: boolean };
export type InvestigationOverlayData = FeatureCollection<Geometry>;
@@ -1,9 +1,18 @@
import { z } from "zod";
import type { AgentTaskDiscussionContext } from "@/features/agent";
export type InvestigationDiscussion = { prompt?: string; context?: AgentTaskDiscussionContext };
export type InvestigationDiscussion = {
prompt?: string;
context?: AgentTaskDiscussionContext;
// Display metadata from the selected report, never the task's current round.
reportGeneration?: number | null;
};
const text = z.string().trim().min(1);
export const investigationMapTargetSchema = z.object({
source_id: z.enum(["junctions", "pipes", "valves", "pumps", "tanks", "reservoirs", "scada"]),
feature_id: text.max(128)
}).strict();
export const investigationDiagramSchema = z
.object({
title: text,
@@ -23,6 +32,8 @@ export const investigationDiagramSchema = z
.object({
node_id: z.string().regex(/^[A-Za-z_][A-Za-z0-9_-]{0,79}$/),
label: text,
status: z.enum(["pending", "running", "completed", "waiting_human", "failed", "skipped", "cancelled"]).nullish(),
map_targets: z.array(investigationMapTargetSchema).max(100).optional(),
evidence_refs: z.array(text).default([]),
action_refs: z.array(z.string().uuid()).default([])
})
@@ -91,3 +102,4 @@ export const investigationReportSchema = z
export type InvestigationReport = z.infer<typeof investigationReportSchema>;
export type InvestigationDiagram = z.infer<typeof investigationDiagramSchema>;
export type InvestigationMapTarget = z.infer<typeof investigationMapTargetSchema>;
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
fetchOperationalTaskEvidenceSnapshot,
fetchOperationalTaskNotifications,
parseOperationalTask,
parseOperationalTaskNotification
@@ -53,6 +54,14 @@ describe("operational task contract", () => {
it("accepts a task without any plan or report", () => {
expect(parseOperationalTask({ ...task(), latest_plan: null, latest_report: null }).latest_report).toBeNull();
});
it("reads ongoing progress without needing a conclusion or execution plan", () => {
const progress = { revision: 1, run_id: id, execution_generation: 2, published_at: time, source_session_id: "session",
progress: { summary: "正在核对投诉时段记录", diagram: { title: "当前核查", mermaid: "flowchart TD\nA[读取记录]", node_refs: [{ node_id: "A", label: "读取记录", status: "running", map_targets: [{ source_id: "scada", feature_id: "P1" }] }] } } };
const parsed = parseOperationalTask({ ...task(), latest_plan: null, latest_report: null, latest_progress: progress });
expect(parsed.latest_progress?.progress.diagram?.node_refs[0].status).toBe("running");
expect(parsed.latest_progress?.progress.diagram?.node_refs[0].map_targets?.[0].feature_id).toBe("P1");
expect(() => parseOperationalTask({ ...task(), latest_progress: { ...progress, execution_generation: 0 } })).toThrow();
});
it("accepts a withdrawn peripheral source timestamp", () => {
const value = task();
expect(parseOperationalTask({
@@ -133,3 +142,18 @@ describe("operational notification contract", () => {
expect(new URL(request!.url).searchParams.get("unread_only")).toBe("false");
});
});
it("reads persisted MCP evidence without expanding task access-request scopes", async () => {
const snapshot = { source: "query_model_baseline_series", source_ref: "mcp:fixture", queried_at: time,
origin: "system_data", data: { semantics: "model_baseline", data: [] }, truncated: true };
vi.stubGlobal("fetch", vi.fn(async () => Response.json(snapshot)));
expect(await fetchOperationalTaskEvidenceSnapshot(id, "mcp:fixture")).toEqual(snapshot);
for (const source of ["run_simulation", "run_burst_detection", "run_burst_location", "analyze_valve_isolation", "run_leakage_identification", "run_contaminant_simulation", "analyze_valve_closure", "analyze_flushing", "analyze_pressure_regulation", "optimize_sensor_placement"]) {
const analysis = { ...snapshot, source, data: { semantics: "analysis", data: {} } };
vi.stubGlobal("fetch", vi.fn(async () => Response.json(analysis)));
expect(await fetchOperationalTaskEvidenceSnapshot(id, "mcp:fixture")).toEqual(analysis);
}
vi.stubGlobal("fetch", vi.fn(async () => Response.json({ ...snapshot, source: "arbitrary_tool" })));
await expect(fetchOperationalTaskEvidenceSnapshot(id, "mcp:fixture")).rejects.toThrow();
});
@@ -1,5 +1,6 @@
import { z } from "zod";
import { investigationReportSchema } from "./investigation-report";
import { investigationReportSchema, investigationDiagramSchema } from "./investigation-report";
import { investigationPresentationCursorSchema } from "./investigation-presentation";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import { env } from "@/shared/config/env";
import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError } from "./project-client";
@@ -45,6 +46,7 @@ const taskSummarySchema = z.object({
workflow: z.string().min(1),
lifecycle_status: z.enum(["open", "waiting_human", "completed", "failed", "cancelled"]),
active_run: operationalTaskRunSchema.nullable(),
latest_run: operationalTaskRunSchema.nullable().optional(),
status: taskStatusSchema,
priority: taskPrioritySchema,
revision: z.number().int().positive(),
@@ -115,6 +117,14 @@ const reportRevisionSchema = z.object({
published_by: z.string().min(1),
report: reportInputSchema
}).strict();
const progressSnapshotSchema = z.object({
revision: z.number().int().positive(),
run_id: z.string().uuid(),
execution_generation: z.number().int().positive(),
published_at: workbenchDateTimeSchema,
source_session_id: z.string().min(1),
progress: z.object({ summary: z.string().trim().min(1), diagram: investigationDiagramSchema.nullable() }).strict()
}).strict();
const reportPageSchema = z.object({
items: z.array(reportRevisionSchema),
next_offset: z.number().int().nonnegative().nullable(),
@@ -212,6 +222,7 @@ const externalWorkOrderSnapshotSchema = z.preprocess((value) => {
const operationalTaskSchema = taskSummarySchema.extend({
latest_plan: planSnapshotSchema.nullable(),
latest_report: reportRevisionSchema.nullable(),
latest_progress: progressSnapshotSchema.nullish(),
evidence: z.array(evidenceSchema),
actions: z.array(actionSchema),
access_requests: z.array(accessRequestSchema).default([]),
@@ -269,7 +280,43 @@ const scheduleSchema = z.object({
}).strict();
const schedulePageSchema = z.object({ items: z.array(scheduleSchema) }).strict();
const taskDataSnapshotSchema = z.object({
source: z.enum(["workbench_runtime", "scada_devices", "scada_readings", "peripheral_points", "peripheral_work_orders", "peripheral_observations", "peripheral_test_data"]),
// Snapshot sources include product MCP queries; access-request scopes remain unchanged.
source: z.enum(["workbench_runtime", "scada_devices", "scada_readings", "peripheral_points", "peripheral_work_orders", "peripheral_observations", "peripheral_test_data",
"get_analysis_run",
"get_element_gis_properties",
"get_element_properties",
"get_model_baseline_at_time",
"get_network_options",
"get_network_schema",
"get_scada_device",
"get_sensor_placement_run",
"get_source_gis_properties",
"get_task_context",
"get_task_evidence",
"get_workbench_runtime",
"list_analysis_results",
"list_analysis_runs",
"list_elements",
"list_scada_devices",
"list_sensor_placement_runs",
"query_analysis_series",
"query_analysis_values",
"query_element_history",
"query_model_baseline_field",
"query_model_baseline_series",
"query_peripheral_data",
"query_scada_readings",
"run_burst_detection",
"run_simulation",
"run_contaminant_simulation",
"analyze_valve_closure",
"analyze_flushing",
"analyze_pressure_regulation",
"optimize_sensor_placement",
"run_burst_location",
"analyze_valve_isolation",
"run_leakage_identification",
]),
source_ref: z.string().min(1),
queried_at: workbenchDateTimeSchema,
origin: z.enum(["system_data", "synthetic"]),
@@ -286,6 +333,7 @@ export type OperationalTaskEvidence = z.infer<typeof evidenceSchema>;
export type OperationalTaskNotification = z.infer<typeof notificationSchema>;
export type OperationalTaskReport = z.infer<typeof reportRevisionSchema>;
export type OperationalTaskReportPage = z.infer<typeof reportPageSchema>;
export type OperationalTaskProgress = z.infer<typeof progressSnapshotSchema>;
export type OperationalTaskSchedule = z.infer<typeof scheduleSchema>;
export type OperationalTaskDataSnapshot = z.infer<typeof taskDataSnapshotSchema>;
export type ExternalWorkOrderSnapshot = z.infer<typeof externalWorkOrderSnapshotSchema>;
@@ -342,6 +390,12 @@ export async function fetchOperationalTaskReports(taskId: string, offset: number
));
}
export async function fetchOperationalTaskPresentationCursor(taskId: string, getAccessToken?: AccessTokenProvider, signal?: AbortSignal) {
return investigationPresentationCursorSchema.parse(await operationalTaskRequest(
`/operational-tasks/${encodeURIComponent(taskId)}/presentation-cursor`, getAccessToken, { signal }
));
}
export async function createOperationalTask(input: CreateOperationalTaskInput, getAccessToken?: AccessTokenProvider) {
return operationalTaskSchema.parse(await operationalTaskRequest(
"/operational-tasks",
@@ -0,0 +1,61 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { searchPlaces } from "./place-search-client";
const item = { id: "place-1", name: "南桥路", address: "奉贤区南桥路", longitude: 121.441891, latitude: 31.220188, category: "道路名", district: "奉贤区" };
const results = { items: [item], total: 1, truncated: false, region: "奉贤区" };
afterEach(() => vi.restoreAllMocks());
describe("Fengxian place search client", () => {
it("uses the new endpoint with trimmed keywords, identity and project headers", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json(results));
expect(await searchPlaces(" 南桥路 ", "project-1", async () => "read-token", undefined)).toEqual(results);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe("http://127.0.0.1:8000/api/v1/place-search-requests");
expect(init?.method).toBe("POST");
expect(JSON.parse(init?.body as string)).toEqual({ keyword: "南桥路" });
const headers = new Headers(init?.headers);
expect(headers.get("Authorization")).toBe("Bearer read-token");
expect(headers.get("X-Project-Id")).toBe("project-1");
expect(headers.get("Content-Type")).toBe("application/json");
});
it("accepts empty search results", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json({ ...results, items: [], total: 0 }));
expect((await searchPlaces("未知位置", "project-1")).items).toEqual([]);
});
it("accepts at most ten Fengxian candidates", async () => {
const capped = { ...results, items: Array.from({ length: 10 }, (_, index) => ({ ...item, id: `place-${index}` })), total: 10, truncated: true };
vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json(capped));
expect(await searchPlaces("南桥路", "project-1")).toEqual(capped);
});
it.each([
{ ...results, items: [{ ...item, longitude: 181 }] },
{ ...results, items: [{ ...item, latitude: 91 }] },
{ ...results, items: [item, item] },
{ ...results, region: "北京市" },
{ ...results, items: [{ ...item, district: "徐汇区" }] },
{ ...results, total: 0 },
{ ...results, total: 11 },
{ ...results, items: Array.from({ length: 11 }, (_, index) => ({ ...item, id: `place-${index}` })) },
{ status: "0", location: { lon: "121", lat: "31" } }
])("rejects malformed, mismatched or legacy responses: %j", async (payload) => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json(payload));
await expect(searchPlaces("南桥路", "project-1")).rejects.toThrow("未获得有效的地名搜索结果");
});
it("does not fetch blank keywords", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch");
await expect(searchPlaces(" ", "project-1")).rejects.toThrow("请输入");
expect(fetchMock).not.toHaveBeenCalled();
});
it.each([403, 502, 503, 504])("preserves server failures for HTTP %s", async (status) => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json({ detail: "查询暂不可用" }, { status }));
await expect(searchPlaces("南桥路", "project-1")).rejects.toThrow("查询暂不可用");
});
it("does not fetch after cancellation during token refresh", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch");
const controller = new AbortController();
await expect(searchPlaces("南桥路", "project-1", async () => {
controller.abort(); return "read-token";
}, controller.signal)).rejects.toThrow();
expect(fetchMock).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,53 @@
import { z } from "zod";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import { env } from "@/shared/config/env";
import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError } from "./project-client";
export const MAX_PLACE_SEARCH_RESULTS = 10;
const placeSchema = z.object({
id: z.string().min(1), name: z.string().trim().min(1), address: z.string(),
longitude: z.number().finite().min(-180).max(180),
latitude: z.number().finite().min(-90).max(90),
category: z.string().nullable(), district: z.literal("奉贤区")
});
const resultsSchema = z.object({
items: z.array(placeSchema).max(MAX_PLACE_SEARCH_RESULTS), total: z.number().int().min(0).max(MAX_PLACE_SEARCH_RESULTS),
truncated: z.boolean().default(false), region: z.literal("奉贤区")
});
export type PlaceSearchItem = z.infer<typeof placeSchema>;
export type PlaceSearchResults = z.infer<typeof resultsSchema>;
export async function searchPlaces(
keyword: string,
projectId: string | null,
getAccessToken?: AccessTokenProvider,
signal?: AbortSignal
): Promise<PlaceSearchResults> {
const query = keyword.trim();
if (!query) throw new Error("请输入需要搜索的地址或地名");
if (query.length > 120) throw new Error("查询关键字不能超过120个字符");
const requestSignal = signal
? AbortSignal.any([signal, AbortSignal.timeout(20_000)])
: AbortSignal.timeout(20_000);
const ownedProject = projectId ? null : await findOwnedWorkbenchProject(getAccessToken, requestSignal);
const accessToken = ownedProject?.accessToken ?? await getAccessToken?.();
requestSignal.throwIfAborted();
const resolvedProjectId = projectId ?? ownedProject?.project.project_id;
if (!resolvedProjectId) throw new Error("未找到地名搜索所属项目");
const response = await fetch(`${env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "")}/api/v1/place-search-requests`, {
method: "POST",
headers: createWorkbenchHeaders(accessToken, resolvedProjectId, true),
body: JSON.stringify({ keyword: query }),
signal: requestSignal
});
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) throw new Error(getServerError(payload, response.status, "地名搜索"));
const parsed = resultsSchema.safeParse(payload);
if (!parsed.success
|| new Set(parsed.data.items.map((item) => item.id)).size !== parsed.data.items.length
|| parsed.data.total !== parsed.data.items.length) {
throw new Error("未获得有效的地名搜索结果,请稍后重试");
}
return parsed.data;
}
@@ -2,6 +2,7 @@ import { z } from "zod";
import type { FeatureCollection, Geometry, Position } from "geojson";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import { env } from "@/shared/config/env";
import { formatDateTime } from "@/shared/utils/date-time";
import type {
ScheduledConditionRecord,
ScheduledWorkOrderItem,
@@ -427,8 +428,7 @@ function getStatusTone(status: string) {
}
function formatRuntimeAlertTime(value: string) {
const date = new Date(value);
return `${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")} ${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
return formatRuntimeDateTime(value).replace("/", "-");
}
function getConditionDataSource(
@@ -452,13 +452,12 @@ function resolveOperationalRuntimeSource(
}
function formatRuntimeDateTime(value: string) {
return new Intl.DateTimeFormat("zh-CN", {
return formatDateTime(value, {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false
}).format(new Date(value));
minute: "2-digit"
});
}
function isGeoJsonFeatureCollection(value: unknown): value is FeatureCollection<Geometry> {
@@ -34,6 +34,8 @@ const eventSchema = z.object({
"operational.run.queued",
"operational.run.changed",
"operational.report.published",
"operational.progress.changed",
"operational.presentation.requested",
"operational.action.changed",
"operational.notification.changed",
"workbench.alert.changed",
@@ -1,4 +1,4 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import type { ComponentProps } from "react";
import { describe, expect, it, vi } from "vitest";
import { type AgentExecutionPlan } from "@/features/agent";
@@ -130,6 +130,25 @@ function taskPanel(task: OperationalTask, overrides: Partial<ComponentProps<type
return { ...props, rerender };
}
it("references the selected report round instead of the latest task round, including legacy reports", () => {
const task = { ...taskBase, execution_generation: 2 };
const { rerender, ...props } = taskPanel(task);
const button = screen.getByRole("button", { name: "Agent 协作" });
expect(button).toHaveAttribute("title", "打开调查协作对话,引用此任务,不会直接发送。");
fireEvent.click(button);
expect(props.onContinue).toHaveBeenLastCalledWith({
context: { report_revision: task.latest_report!.revision },
reportGeneration: task.latest_report!.execution_generation
});
rerender(<AgentExecutionPlanPanel {...props} task={{ ...task,
latest_report: { ...task.latest_report!, execution_generation: null }
}} />);
fireEvent.click(screen.getByRole("button", { name: "Agent 协作" }));
expect(props.onContinue).toHaveBeenLastCalledWith({
context: { report_revision: task.latest_report!.revision }, reportGeneration: null
});
});
describe("operational task approval and evidence", () => {
it("shows the selected v2 route first and keeps unchosen work available", () => {
const node = (id: string, title: string, status: "completed" | "waiting" | "skipped") => ({
@@ -294,7 +313,8 @@ describe("operational task approval and evidence", () => {
onContinue={vi.fn()} onDecision={vi.fn()} onExecute={vi.fn()} onReconcile={vi.fn()}
onRefresh={vi.fn()} onLoadEvidenceSnapshot={vi.fn()}
/>);
fireEvent.click(screen.getByRole("button", { name: "查看新报告" }));
// Live reading follows published reports without requiring an update click.
expect(screen.queryByRole("button", { name: "查看新报告" })).not.toBeInTheDocument();
expect(screen.getByText(/正在阅读:历史报告/)).toBeVisible();
});
@@ -347,11 +367,24 @@ describe("operational task approval and evidence", () => {
task={{ ...currentPlanTask, latest_plan: { ...latestPlan, execution_generation: null } }}
{...props}
/>);
// An unassigned historical plan cannot supply a numbered report's diagram.
expect(screen.queryByRole("heading", { name: "调查脉络" })).not.toBeInTheDocument();
expect(within(screen.getByRole("region", { name: "任务报告" }))
.getByText(taskBase.latest_report!.report.current_conclusion, { exact: true })).toBeVisible();
rerender(<AgentExecutionPlanPanel
task={{ ...currentPlanTask,
latest_plan: { ...latestPlan, execution_generation: null },
latest_report: { ...taskBase.latest_report!, execution_generation: null }
}}
{...props}
/>);
// The legacy relationship remains readable when both records lack a round.
expect(screen.getByRole("heading", { name: "调查脉络" })).toBeVisible();
expect(screen.queryByText(/generation|第\s*\d+\s*代/i)).not.toBeInTheDocument();
});
it("shows exact payload and evidence metadata to a viewer without action controls", () => {
it("shows exact payload and evidence metadata to a viewer without action controls", async () => {
taskPanel(taskBase);
expect(screen.getByText("工单标题")).toBeVisible();
expect(screen.getByText("位置")).toBeVisible();
@@ -363,9 +396,10 @@ describe("operational task approval and evidence", () => {
expect(screen.getByText(/当前账号仅可查看/)).toBeVisible();
expect(screen.queryByText(/operational_action\.work_order\.approve/)).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "批准创建预约工单" })).not.toBeInTheDocument();
fireEvent.click(screen.getByLabelText("主要依据").querySelector("summary")!);
expect(screen.getByText("SCADA 水质监测")).toBeVisible();
expect(screen.getByText("系统数据")).toBeVisible();
fireEvent.click(screen.getByRole("button", { name: "展开主要依据" }));
fireEvent.click(screen.getByRole("dialog", { name: "主要依据" }).querySelector("summary")!);
await waitFor(() => expect(within(screen.getByRole("dialog", { name: "主要依据" })).getByText("SCADA 水质监测")).toBeVisible());
expect(within(screen.getByRole("dialog", { name: "主要依据" })).getByText("系统数据")).toBeVisible();
});
it("keeps pending state after a decision network error", async () => {
@@ -449,7 +483,7 @@ describe("operational task approval and evidence", () => {
actions: [completedAction]
});
fireEvent.click(screen.getByText("任务时间线"));
fireEvent.click(screen.getByRole("button", { name: "展开任务时间线" }));
const items = [...screen.getByLabelText("任务时间线记录").querySelectorAll("li")];
expect(items.map((item) => item.textContent)).toEqual([
expect.stringContaining("预约工单已创建,等待现场处理"),
@@ -1,4 +1,5 @@
import { InvestigationReportView } from "./investigation-report-view";
import { formatDateTime as formatDisplayDateTime } from "@/shared/utils/date-time";
import { InvestigationTaskReport } from "./investigation-task-report";
import type { InvestigationDiscussion } from "../api/investigation-report";
import type { OperationalTaskReportPage } from "../api/operational-task-client";
@@ -61,6 +62,7 @@ export type ExecutionPlanView = {
};
type AgentExecutionPlanPanelProps = {
onLocate?: (targets: import("../api/investigation-report").InvestigationMapTarget[]) => void;
task: OperationalTask;
onContinue: (discussion?: InvestigationDiscussion) => void;
onDecision: (actionId: string, expectedVersion: number, decision: OperationalTaskDecision, comment?: string) => Promise<unknown>;
@@ -77,6 +79,7 @@ type AgentExecutionPlanPanelProps = {
};
export function AgentExecutionPlanPanel({
onLocate,
task,
onContinue,
onDecision,
@@ -92,7 +95,7 @@ export function AgentExecutionPlanPanel({
focusActionId
}: AgentExecutionPlanPanelProps) {
if (task.task_type === "event_investigation") return <InvestigationTaskReport key={task.task_id} task={task}
onContinue={onContinue} onLoadReports={onLoadReports} onReportViewed={onReportViewed} focusActionId={focusActionId}
onContinue={onContinue} onLoadReports={onLoadReports} onReportViewed={onReportViewed} focusActionId={focusActionId} onLocate={onLocate}
renderAction={(action) => <HumanActionCard key={action.action_id} task={task} action={action} onDecision={onDecision}
onExecute={onExecute} onDraftUpdate={onDraftUpdate} onConfirmDispatch={onConfirmDispatch} onReconcile={onReconcile} onRefresh={onRefresh} />}
renderEvidence={(item) => <EvidenceDisclosure key={item.evidence_id} item={item} onLoadSnapshot={onLoadEvidenceSnapshot} />}
@@ -412,7 +415,7 @@ function EvidenceDisclosure({ item, onLoadSnapshot }: { item: OperationalTaskEvi
catch (nextError) { setError(nextError instanceof Error ? nextError.message : "数据快照读取失败。"); }
finally { setLoading(false); }
};
return <Disclosure label={<span className="line-clamp-2 text-left text-xs leading-5 text-slate-700">{item.summary}</span>} className="surface-control rounded-lg border border-[var(--surface-divider)]/70 px-2.5">
return <Disclosure label={<span className="line-clamp-2 wrap-anywhere text-left text-xs leading-5 text-slate-700">{item.summary}</span>} className="surface-control rounded-lg border border-[var(--surface-divider)]/70 px-2.5">
<div className="border-t border-[var(--surface-divider)]/70 py-2">
<dl className="grid gap-1 text-[11px] leading-5"><EvidenceMeta label="来源" value={item.source_label} /><EvidenceMeta label="时间" value={formatDateTime(item.observed_at)} /><EvidenceMeta label="类型" value={evidenceKindLabel(item.kind)} />{item.source_ref ? <EvidenceMeta label="引用" value={item.source_ref} mono /> : null}</dl>
{item.has_snapshot && !snapshot ? <Button className="mt-2" variant="outline" size="sm" disabled={loading} onClick={() => void load()}>{loading ? <Loader2 className="animate-spin" /> : null}查看数据快照</Button> : null}
@@ -424,7 +427,7 @@ function EvidenceDisclosure({ item, onLoadSnapshot }: { item: OperationalTaskEvi
function SnapshotSummary({ snapshot }: { snapshot: OperationalTaskDataSnapshot }) {
const rows = snapshotRows(snapshot.data).slice(0, 12);
return <section aria-label="数据快照" className="mt-2 rounded-lg bg-[var(--surface-reading)] px-2.5 py-2 shadow-[inset_0_0_0_1px_rgba(148,163,184,0.3)]"><p className="text-[11px] font-semibold text-slate-700">{snapshot.source_ref} · 查询于 {formatDateTime(snapshot.queried_at)}</p><dl className="mt-1 grid gap-1 text-[11px] leading-5">{rows.map(([key, value]) => <EvidenceMeta key={key} label={key} value={value} />)}</dl>{snapshot.truncated ? <p className="mt-1 text-[11px] text-[var(--status-warning-foreground)]">数据已按有界查询范围截断。</p> : null}</section>;
return <section aria-label="数据快照" className="mt-2 min-w-0 rounded-lg bg-[var(--surface-reading)] px-2.5 py-2 shadow-[inset_0_0_0_1px_rgba(148,163,184,0.3)]"><p className="wrap-anywhere text-[11px] font-semibold text-slate-700">{snapshot.source_ref} · 查询于 {formatDateTime(snapshot.queried_at)}</p><dl className="mt-1 grid gap-1 text-[11px] leading-5">{rows.map(([key, value]) => <EvidenceMeta key={key} label={key} value={value} />)}</dl>{snapshot.truncated ? <p className="mt-1 text-[11px] text-[var(--status-warning-foreground)]">数据已按有界查询范围截断。</p> : null}</section>;
}
function snapshotRows(value: unknown, prefix = ""): [string, string][] {
@@ -434,7 +437,7 @@ function snapshotRows(value: unknown, prefix = ""): [string, string][] {
}
function EvidenceMeta({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) {
return <div className="grid grid-cols-[48px_1fr] gap-2"><dt className="font-semibold text-[var(--ink-muted)]">{label}</dt><dd className={cn("break-words text-slate-700", mono && "font-mono")}>{value}</dd></div>;
return <div className="grid min-w-0 grid-cols-[48px_minmax(0,1fr)] gap-2"><dt className="min-w-0 wrap-anywhere font-semibold text-[var(--ink-muted)]">{label}</dt><dd className={cn("min-w-0 wrap-anywhere text-slate-700", mono && "font-mono")}>{value}</dd></div>;
}
export function AccessRequestCard({ request, nodeTitle, canDecide, onDecision, onRefresh }: {
@@ -662,7 +665,7 @@ function dataScopeLabel(scope: string) { return ({
} as Record<string, string>)[scope] ?? scope; }
function taskStatusLabel(status: OperationalTask["status"]) { return { queued: "排队中", running: "处理中", waiting_human: "待人工", completed: "已完成", failed: "失败", cancelled: "已取消" }[status]; }
function evidenceKindLabel(kind: OperationalTaskEvidence["kind"]) { return { system_data: "系统数据", external_feedback: "外部反馈", human_input: "人工提供", synthetic: "合成演练" }[kind]; }
function formatDateTime(value: string) { const parsed = new Date(value); return Number.isNaN(parsed.getTime()) ? value : new Intl.DateTimeFormat("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false }).format(parsed); }
function formatDateTime(value: string) { return formatDisplayDateTime(value, { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }); }
function toLocalDateTime(value: unknown) {
if (typeof value !== "string") return "";
const date = new Date(value);
@@ -0,0 +1,166 @@
import { ChevronDown, LocateFixed, MapPin, Search, Trash2 } from "lucide-react";
import { useId, useLayoutEffect, useRef } from "react";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
import { cn } from "@/shared/ui/cn";
import type { WorkbenchGeocoding } from "../hooks/use-workbench-geocoding";
import { MAX_PLACE_SEARCH_RESULTS, type PlaceSearchItem } from "../api/place-search-client";
const RESULT_VIEWPORT_CLASS_NAME = "h-72 min-h-0 max-h-72 overflow-y-auto overscroll-contain pr-1 [scrollbar-gutter:stable] [scrollbar-width:thin]";
function PlaceSummary({ item, index, annotation = item.district }: { item: PlaceSearchItem; index?: number; annotation?: string }) {
return (
<>
{index !== undefined ? <span aria-hidden="true" className="w-5 shrink-0 self-start text-center text-[11px] font-normal leading-5 tabular-nums text-[var(--ink-muted)]">
{String(index + 1).padStart(2, "0")}
</span> : null}
<span className="min-w-0 flex-1 space-y-1">
<span className="flex items-start justify-between gap-2 leading-5">
<span className="min-w-0 flex-1 break-words font-semibold text-[var(--ink)]">{item.name}</span>
<span aria-label={index === undefined && item.category ? `地点类型:${item.category}` : undefined} className="max-w-[40%] shrink-0 break-words text-right text-[11px] font-normal text-[var(--ink-muted)]">{annotation}</span>
</span>
<span className="block break-words text-[11px] font-normal leading-4 text-[var(--ink-muted)]">{item.address}</span>
</span>
</>
);
}
export function GeocodingPanel({ geocoding }: { geocoding: WorkbenchGeocoding }) {
const inputId = useId();
const statusId = useId();
const candidateListRef = useRef<HTMLUListElement | null>(null);
const changeSelectionRef = useRef<HTMLButtonElement | null>(null);
const { address, result, results, pending, error, enabled } = geocoding;
const selectionCollapsed = Boolean(result && !geocoding.choosing);
const hideSelectionHint = Boolean(results?.items.length && enabled && !error);
useLayoutEffect(() => {
if (selectionCollapsed || !result) return;
const list = candidateListRef.current;
const selected = list?.querySelector<HTMLButtonElement>('button[aria-pressed="true"]');
if (!list || !selected) return;
const viewport = list.getBoundingClientRect();
const row = selected.getBoundingClientRect();
list.scrollTop += row.top - viewport.top - (list.clientHeight - row.height) / 2;
}, [selectionCollapsed, result, results]);
const status = error
?? (pending ? "正在搜索地名…"
: !enabled ? "等待地图加载后即可定位"
: result ? "已在地图标出所选位置"
: results ? results.items.length ? "选择一个候选位置进行定位" : "奉贤区内未找到匹配位置,请细化关键字"
: "搜索奉贤区内地址或地名,支持回车查询");
return (
<div className="flex min-h-0 flex-1 flex-col gap-3">
<form
className="flex shrink-0 flex-col gap-2"
onSubmit={(event) => {
event.preventDefault();
void geocoding.search();
}}
>
<label htmlFor={inputId} className="block px-1 text-xs font-semibold text-[var(--ink-muted)]">
查询地址
</label>
<div className="flex items-start gap-2">
<Input
id={inputId}
value={address}
onChange={(event) => geocoding.changeAddress(event.target.value)}
placeholder="道路、门牌号或地名"
className="h-10 min-w-0 flex-1 rounded-xl border-[var(--surface-border)] bg-[var(--surface-reading)] text-xs shadow-none md:text-xs"
autoComplete="street-address"
maxLength={120}
aria-invalid={Boolean(error)}
aria-describedby={statusId}
/>
<Button
type="submit"
variant="outline"
aria-label="搜索地名"
className="shrink-0 gap-1.5 px-3 text-xs"
disabled={!enabled || !address.trim()}
loading={pending}
loadingLabel="正在搜索地名"
>
<Search aria-hidden="true" />查询
</Button>
</div>
<div
id={statusId}
role={error ? "alert" : "status"}
aria-live={error ? "assertive" : "polite"}
className={hideSelectionHint ? "sr-only" : cn("flex min-h-5 items-start gap-1.5 px-1 text-[11px] leading-5", error ? "text-[var(--status-danger-foreground)]" : "text-[var(--ink-muted)]")}
>
{result && !error ? <MapPin size={13} className="mt-0.5 shrink-0" aria-hidden="true" /> : null}
<span>{status}</span>
</div>
</form>
{results && results.items.length > 0 ? (
<section aria-label="搜索结果" className="flex min-h-0 flex-1 flex-col gap-2 border-t border-[var(--surface-divider)] pt-3">
<div className="flex h-10 shrink-0 items-center justify-between gap-2 px-1 text-[11px] text-[var(--ink-muted)]">
<span className="text-xs font-semibold">{selectionCollapsed ? "已选位置" : "选择位置"}</span>
{selectionCollapsed ? (
<Button ref={changeSelectionRef} type="button" variant="secondary" className="gap-1.5 px-3 text-xs" aria-expanded={false}
onClick={(event) => {
geocoding.changeSelection();
if (event.detail === 0) window.requestAnimationFrame(() => {
const list = candidateListRef.current;
const selected = list?.querySelector<HTMLButtonElement>('button[aria-pressed="true"]');
(selected ?? list?.querySelector<HTMLButtonElement>("button"))?.focus({ preventScroll: true });
});
}}>
更换位置<ChevronDown aria-hidden="true" />
</Button>
) : <span className="tabular-nums">{results.truncated ? `前 ${results.total} 个结果` : `共 ${results.total} 个结果`}</span>}
</div>
{!selectionCollapsed ? <ul ref={candidateListRef} aria-busy={pending} className={cn(RESULT_VIEWPORT_CLASS_NAME, "bg-transparent", pending && "opacity-60")}>
{results.items.map((item, index) => (
<li key={item.id} className="border-b border-[var(--surface-divider)] last:border-b-0">
<Button type="button" variant="ghost" aria-pressed={result?.id === item.id} data-selection-tone="soft"
aria-label={`定位 ${item.name} ${item.address}`} disabled={pending || !enabled}
className={cn("h-auto min-h-16 w-full justify-start gap-3 rounded-lg border-0 whitespace-normal px-1 py-3 text-left text-xs focus-visible:ring-inset focus-visible:ring-offset-0 [@media(hover:hover)]:hover:bg-[var(--surface-control)] [@media(hover:hover)]:hover:text-[var(--ink)]",
result?.id === item.id && "bg-[var(--action-selection-soft)] text-[var(--action-blue-hover)] [&_span]:text-[var(--action-blue-hover)] [@media(hover:hover)]:hover:bg-[var(--action-selection-soft)]")}
onClick={(event) => {
geocoding.select(item.id);
if (event.detail === 0) window.requestAnimationFrame(() => changeSelectionRef.current?.focus());
}}>
<PlaceSummary item={item} index={index} />
</Button>
</li>
))}
</ul> : null}
{result && selectionCollapsed ? (
<section aria-label="定位结果" className={cn(RESULT_VIEWPORT_CLASS_NAME, "flex flex-col gap-2 overflow-hidden")}>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain [scrollbar-width:thin]">
<div className="space-y-3">
<div className="flex min-h-16 items-center gap-3 border-b border-[var(--surface-divider)] px-1 py-3 text-xs">
<PlaceSummary item={result} annotation={result.category ?? result.district} />
</div>
<dl aria-label="经纬度坐标" className="space-y-1 px-1">
<dt className="text-[11px] leading-4 text-[var(--ink-muted)]">经度 / 纬度</dt>
<dd className="whitespace-nowrap text-sm font-medium leading-5 tabular-nums text-[var(--ink)]">
{`${result.longitude.toFixed(6)}, ${result.latitude.toFixed(6)}`}
</dd>
</dl>
</div>
</div>
<div className="grid shrink-0 grid-cols-2 gap-2">
<Button type="button" variant="secondary" className="text-xs" disabled={!enabled} onClick={geocoding.locate}>
<LocateFixed aria-hidden="true" />重新定位
</Button>
<Button type="button" variant="secondary" className="text-xs" onClick={geocoding.clear}>
<Trash2 aria-hidden="true" />清除结果
</Button>
</div>
</section>
) : null}
<p className="flex min-h-5 shrink-0 items-start gap-1.5 px-1 text-[11px] leading-5 text-[var(--ink-muted)]">
{selectionCollapsed ? <><MapPin size={13} className="mt-0.5 shrink-0" aria-hidden="true" /><span>已在地图标出所选位置</span></>
: results.truncated ? `最多显示 ${MAX_PLACE_SEARCH_RESULTS} 个位置,请细化关键词`
: "选择一个候选位置进行定位"}
</p>
</section>
) : null}
</div>
);
}
@@ -1,9 +1,10 @@
import { useEffect, useRef, useState, type ReactNode, type SVGProps } from "react";
import { GitBranch, Maximize2, RotateCcw, X, ZoomIn, ZoomOut, type LucideIcon } from "lucide-react";
import { RotateCcw, ZoomIn, ZoomOut, type LucideIcon } from "lucide-react";
import { MessageResponse } from "@/shared/ai-elements/message";
import { Button } from "@/shared/ui/button";
import { cn } from "@/shared/ui/cn";
import type { InvestigationDiagram } from "../api/investigation-report";
import type { InvestigationDiagram, InvestigationMapTarget } from "../api/investigation-report";
import { InvestigationExpandCard } from "./investigation-expand-card";
import {
INVESTIGATION_DIAGRAM_FONT,
investigationDiagramConfig,
@@ -26,13 +27,10 @@ function controlIcon(Icon: LucideIcon, control: string) {
};
}
// The icon markers also identify controls in Streamdown's fullscreen portal.
const diagramControlIcons = {
ZoomInIcon: controlIcon(ZoomIn, "zoom-in"),
ZoomOutIcon: controlIcon(ZoomOut, "zoom-out"),
RotateCcwIcon: controlIcon(RotateCcw, "reset"),
Maximize2Icon: controlIcon(Maximize2, "fullscreen"),
XIcon: controlIcon(X, "close")
RotateCcwIcon: controlIcon(RotateCcw, "reset")
};
function DiagramUnavailable() {
@@ -49,11 +47,15 @@ const getNodeId = (node: Element | null | undefined) =>
export function InvestigationDiagramView({
diagram,
renderDetails,
onAsk
onAsk,
running = false,
onLocate
}: {
diagram: InvestigationDiagram;
renderDetails: (node: InvestigationDiagram["node_refs"][number]) => ReactNode;
onAsk?: (node: InvestigationDiagram["node_refs"][number]) => void;
running?: boolean;
onLocate?: (targets: InvestigationMapTarget[]) => void;
}) {
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const pointerStart = useRef<{ nodeId: string; x: number; y: number } | null>(null);
@@ -74,12 +76,8 @@ export function InvestigationDiagramView({
active = false;
};
}, [diagram.mermaid]);
return (
<section aria-label="调查脉络" className="investigation-report-section">
<h3 className="flex items-center gap-2 text-sm font-semibold text-[var(--ink)]">
<GitBranch size={15} aria-hidden />
{diagram.title}
</h3>
const content = (close: (restoreFocus?: boolean) => void) => (
<>
<p className="mt-1 text-xs leading-5 text-[var(--ink-muted)]">
核查方向与判断分支。可放大查看,点击节点阅读关联依据。
</p>
@@ -108,22 +106,21 @@ export function InvestigationDiagramView({
<MessageResponse
icons={diagramControlIcons}
plugins={investigationDiagramPlugins}
translations={{
viewFullscreen: "全屏查看",
exitFullscreen: "退出全屏"
}}
mermaid={{
errorComponent: DiagramUnavailable,
config: investigationDiagramConfig
}}
controls={{
mermaid: { fullscreen: true, download: false, copy: false, panZoom: true }
mermaid: { fullscreen: false, download: false, copy: false, panZoom: true }
}}
>{`\`\`\`mermaid\n${styleInvestigationDiagram(diagram)}\n\`\`\``}</MessageResponse>
>{`\`\`\`mermaid\n${styleInvestigationDiagram(diagram, running)}\n\`\`\``}</MessageResponse>
) : (
<p className="p-4 text-xs text-[var(--ink-muted)]">正在排版调查图示。</p>
)}
</div>
{running && diagram.node_refs.some((node) => node.status === "running") ? (
<p aria-label="图示执行状态" className="mt-2 text-xs text-[var(--action-blue)]">蓝色:正在执行</p>
) : null}
{diagram.node_refs.some((node) => node.evidence_refs.length || node.action_refs.length) ? (
<div
className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-[var(--ink-muted)]"
@@ -175,14 +172,23 @@ export function InvestigationDiagramView({
aria-label={`节点详情 ${selected.label}`}
>
<p className="text-sm font-medium text-[var(--ink)]">{selected.label}</p>
{selected.status ? <p className="text-xs text-[var(--ink-muted)]">{!running && selected.status === "running" ? "记录时执行中" : {
pending: "尚未开始", running: "正在执行", completed: "已完成", waiting_human: "等待人工反馈", failed: "执行失败", skipped: "已跳过", cancelled: "已取消"
}[selected.status]}</p> : null}
{renderDetails(selected)}
{onLocate && selected.map_targets?.length ? <Button size="sm" variant="outline" onClick={() => { close(false); onLocate(selected.map_targets!); }}>在地图查看</Button> : null}
{onAsk ? (
<Button size="sm" variant="outline" onClick={() => onAsk(selected)}>
<Button size="sm" variant="outline" onClick={() => { close(false); onAsk(selected); }}>
围绕此节点提问
</Button>
) : null}
</div>
) : null}
</section>
</>
);
return (
<InvestigationExpandCard title={diagram.title} label="调查脉络" preview={null} reuseContent wide triggerLabel="放大查看">
{content}
</InvestigationExpandCard>
);
}
@@ -0,0 +1,132 @@
import { useCallback, useId, useRef, useState, type ReactNode, type Ref } from "react";
import { createPortal } from "react-dom";
import * as Dialog from "@radix-ui/react-dialog";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Maximize2, X } from "lucide-react";
import { Button } from "@/shared/ui/button";
import { cn } from "@/shared/ui/cn";
/** Report-specific progressive disclosure. The source stays mounted while reading. */
export function InvestigationExpandCard({
title, label = title, preview, children, wide = false, onOpen, contentRef,
triggerLabel = `展开${title}`, className, revision, disabled = false, reuseContent = false
}: {
title: string;
label?: string;
preview: ReactNode;
children: ReactNode | ((close: (restoreFocus?: boolean) => void) => ReactNode);
wide?: boolean;
onOpen?: () => void;
contentRef?: Ref<HTMLElement>;
triggerLabel?: string;
className?: string;
/** Freeze versioned prose while reading; live controls must not use this. */
revision?: string;
disabled?: boolean;
/** Move one live content host between slots, preserving expensive renderers. */
reuseContent?: boolean;
}) {
const id = useId();
const triggerRef = useRef<HTMLButtonElement>(null);
const restoreFocusRef = useRef(true);
const [open, setOpen] = useState(false);
const [keyboard, setKeyboard] = useState(false);
const [reading, setReading] = useState<{ revision: string; content: typeof children } | null>(null);
const [contentHost] = useState(() => reuseContent ? document.createElement("div") : null);
const previewSlot = useRef<HTMLDivElement | null>(null);
const attachPreview = useCallback((slot: HTMLDivElement | null) => {
previewSlot.current = slot;
if (slot && contentHost && !contentHost.isConnected) slot.appendChild(contentHost);
}, [contentHost]);
const attachDetail = useCallback((slot: HTMLDivElement | null) => {
const preview = previewSlot.current;
if (!preview || !contentHost) return;
if (slot) {
// Reserve only the vacated inline space while expanded. The graph itself
// keeps its natural height, including after content updates or resizing.
// Use the layout height, not a bounding box scaled by the card animation.
preview.style.height = getComputedStyle(preview).height;
slot.appendChild(contentHost);
} else {
preview.appendChild(contentHost);
preview.style.removeProperty("height");
}
}, [contentHost]);
const reduced = useReducedMotion();
const duration = reduced || keyboard ? 0 : 0.24;
const transition = { duration, ease: [0.22, 1, 0.36, 1] as const };
const close = (restoreFocus = true) => { restoreFocusRef.current = restoreFocus; setOpen(false); };
const content = reading?.content ?? children;
return (
<Dialog.Root open={open} onOpenChange={(next) => {
setOpen(next);
if (next) {
restoreFocusRef.current = true;
setReading(revision === undefined ? null : { revision, content: children });
onOpen?.();
}
}}>
<motion.section
ref={contentRef}
aria-label={label}
className={cn("investigation-report-section investigation-expand-card", className)}
layoutId={reduced || keyboard ? undefined : id}
layoutDependency={open}
transition={transition}
style={{ borderRadius: 12 }}
>
<div className="investigation-card-heading">
<h3 className="text-sm font-semibold text-[var(--ink)]">{title}</h3>
<Dialog.Trigger asChild>
<Button ref={triggerRef} variant="ghost" size="icon-sm" aria-label={triggerLabel} disabled={disabled}
title={triggerLabel} onClick={(event) => setKeyboard(event.detail === 0)}>
<Maximize2 size={15} aria-hidden />
</Button>
</Dialog.Trigger>
</div>
{/* Contain child margins so the occupied and vacated slots measure alike. */}
{contentHost ? <div ref={attachPreview} className="flow-root" /> : preview}
</motion.section>
{contentHost ? createPortal(typeof children === "function" ? children(close) : children, contentHost) : null}
<Dialog.Portal forceMount>
<AnimatePresence initial={false}>
{open ? (
<Dialog.Overlay forceMount asChild key="backdrop">
<motion.div className="investigation-detail-backdrop"
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={transition} />
</Dialog.Overlay>
) : null}
{open ? (
<div className="investigation-detail-positioner" key="detail">
<Dialog.Content forceMount asChild aria-describedby={undefined}
onEscapeKeyDown={(event) => { event.stopPropagation(); setKeyboard(true); }}
onCloseAutoFocus={(event) => { event.preventDefault(); if (restoreFocusRef.current) triggerRef.current?.focus({ preventScroll: true }); }}>
<motion.section className={cn("investigation-detail-panel", wide && "investigation-detail-panel-wide")}
layoutId={reduced || keyboard ? undefined : id} layoutDependency={open}
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
transition={transition} style={{ borderRadius: 12 }}>
<header className="investigation-detail-heading">
<Dialog.Title className="text-base font-semibold text-[var(--ink)]">{title}</Dialog.Title>
<Dialog.Close asChild>
<Button variant="ghost" size="icon-sm" aria-label="关闭详情"><X size={17} aria-hidden /></Button>
</Dialog.Close>
</header>
<div className="investigation-detail-scroll">
{contentHost ? <div ref={attachDetail} /> : typeof content === "function" ? content(close) : content}
</div>
{reading && revision !== undefined && reading.revision !== revision ? (
<div className="investigation-report-update" role="status">
<span>已有更新,当前保留正在阅读的版本。</span>
<Button variant="outline" size="sm" onClick={() => setReading({ revision, content: children })}>查看最新内容</Button>
</div>
) : null}
</motion.section>
</Dialog.Content>
</div>
) : null}
</AnimatePresence>
</Dialog.Portal>
</Dialog.Root>
);
}
@@ -0,0 +1,55 @@
import { useState } from "react";
import { Eye, EyeOff, LocateFixed, X } from "lucide-react";
import { AgentChartView } from "@/features/agent";
import { Button } from "@/shared/ui/button";
import { Disclosure } from "@/shared/ui/disclosure";
import type { OperationalTask } from "../api/operational-task-client";
import type { InvestigationOverlay } from "../api/investigation-presentation";
import type { InvestigationChart } from "../hooks/use-investigation-presentation";
type Props = {
task: OperationalTask; charts: InvestigationChart[]; overlay: InvestigationOverlay | null;
cameraPaused: boolean; status: "connecting" | "live" | "disconnected" | "error"; error: string | null;
width: number; relatedLayers: Array<{ id: string; label: string; visible: boolean }>;
onResumeCamera: () => void; onCloseChart: (id: string) => void;
onToggleOverlay: () => void; onFitOverlay: () => void;
onToggleLayer: (id: string, visible: boolean) => void;
};
export function InvestigationPresentationPanel({ task, charts, overlay, cameraPaused, status, error, width, relatedLayers,
onResumeCamera, onCloseChart, onToggleOverlay, onFitOverlay, onToggleLayer }: Props) {
const [collapsed, setCollapsed] = useState(false);
const references = (ids: string[]) => <Disclosure label="查看依据" className="mt-2">
{ids.map((id) => <p key={id} className="py-1 text-xs leading-5 text-[var(--ink-muted)]">{task.evidence.find((item) => item.evidence_id === id)?.summary ?? `关联资料:${id}`}</p>)}
</Disclosure>;
return <aside aria-label="调查实时展示" data-testid="investigation-presentation-panel"
className="surface-reading pointer-events-auto absolute bottom-[calc(var(--workbench-timeline-height)+1.25rem)] left-3 z-40 max-h-[55dvh] overflow-y-auto rounded-xl border border-[var(--surface-divider)] p-3 text-[var(--ink)] shadow-xs"
style={{ width, maxWidth: "calc(100% - 24px)" }}>
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-sm font-semibold">调查实时展示</p>
<Button variant="ghost" size="sm" onClick={() => setCollapsed(!collapsed)}>{collapsed ? "展开展示" : "收起展示"}</Button>
</div>
<p className="mt-1 text-xs text-[var(--ink-muted)]">{status === "connecting" ? "连接中" : status === "disconnected" ? "连接中断" : status === "error" ? "部分展示未完成" : "跟随所选调查"}</p>
{error ? <p role="status" className="mt-2 text-xs text-[var(--status-warning-foreground)]">{error}</p> : null}
{cameraPaused ? <div className="mt-2 flex flex-wrap items-center gap-2 text-xs"><span>镜头跟随已暂停</span><Button size="sm" variant="outline" onClick={onResumeCamera}>恢复跟随</Button></div> : null}
{!collapsed ? <>
{relatedLayers.length ? <div aria-label="调查相关图层" className="mt-2 flex flex-wrap gap-1">
{relatedLayers.map((layer) => <Button key={layer.id} variant="outline" size="sm" aria-pressed={layer.visible} onClick={() => onToggleLayer(layer.id, !layer.visible)}>{layer.visible ? <Eye size={14} aria-hidden /> : <EyeOff size={14} aria-hidden />}{layer.label}</Button>)}
</div> : null}
{overlay ? <section aria-label="调查覆盖层" className="mt-3 border-t border-[var(--surface-divider)] pt-3">
<p className="text-sm font-medium">{overlay.title}</p>
<p className="mt-1 text-xs text-[var(--ink-muted)]">{overlay.mode === "heatmap" ? "记录集中度,颜色不表示已确认影响范围" : "调查范围与关联空间资料"}</p>
<div className="mt-2 flex flex-wrap gap-2">
<Button size="sm" variant="outline" onClick={onToggleOverlay}>{overlay.visible ? "隐藏覆盖层" : "显示覆盖层"}</Button>
<Button size="sm" variant="outline" onClick={onFitOverlay}><LocateFixed size={14} aria-hidden />定位覆盖层</Button>
</div>
{references([overlay.evidence_id])}
</section> : null}
{charts.map((item) => <section key={item.chart_id} data-investigation-chart={item.chart_id} className="mt-3 border-t border-[var(--surface-divider)] pt-3">
<div className="mb-2 flex justify-end"><Button variant="ghost" size="icon" aria-label={`关闭图表 ${item.chart.title}`} onClick={() => onCloseChart(item.chart_id)}><X size={16} /></Button></div>
<AgentChartView chart={item.chart} />
{references(item.evidence_refs)}
</section>)}
</> : null}
</aside>;
}
@@ -1,5 +1,7 @@
import { FileText } from "lucide-react";
import { StatusBadge } from "@/shared/ui/status";
import { formatDateTime } from "@/shared/utils/date-time";
import { formatInvestigationText } from "../utils/investigation-text";
import type { OperationalTask } from "../api/operational-task-client";
export function InvestigationReportView({ task, reportLabel }: { task: OperationalTask; reportLabel: string }) {
@@ -24,25 +26,25 @@ export function InvestigationReportView({ task, reportLabel }: { task: Operation
<p className="text-xs font-semibold leading-5 text-slate-900">
{!current ? reportLabel : insufficient ? insufficientLabel : report.stage === "interim" ? "阶段性判断" : report.outcome === "concluded" ? "调查结论" : "历史报告内容"}
</p>
<p className="mt-1 break-words text-xs leading-6 text-slate-700">{report.current_conclusion}</p>
{insufficient ? <p className="mt-1 break-words text-xs leading-5 text-[var(--status-warning-foreground)]">结束原因:{report.stop_reason}</p> : null}
<p className="mt-1 break-words text-xs leading-6 text-slate-700">{formatInvestigationText(report.current_conclusion)}</p>
{insufficient ? <p className="mt-1 break-words text-xs leading-5 text-[var(--status-warning-foreground)]">结束原因:{formatInvestigationText(report.stop_reason)}</p> : null}
</div>
{report.data_gaps && report.data_gaps.length > 0 ? <div aria-label="数据缺口" className="space-y-2">
<h4 className="text-xs font-semibold text-slate-800">{insufficient ? "关键数据缺口" : "数据范围限制"}</h4>
{report.data_gaps.map((gap) => <div key={gap.gap_id} className="space-y-1 border-l border-[var(--surface-divider)] pl-3 text-xs leading-5">
<p className="break-words font-medium text-slate-900">{gap.missing_data}{gap.blocking ? " · 阻止形成结论" : ""}</p>
<p className="break-words text-slate-600">查询范围:{gap.scope}</p>
<p className="break-words text-slate-600">判断影响:{gap.impact}</p>
<p className="break-words text-slate-600">需补齐:{gap.required_data}</p>
<p className="break-words text-[var(--ink-muted)]">依据:{gap.evidence_refs.map((id) => task.evidence.find((item) => item.evidence_id === id)?.summary ?? id).join(";")}</p>
<p className="break-words font-medium text-slate-900">{formatInvestigationText(gap.missing_data)}{gap.blocking ? " · 阻止形成结论" : ""}</p>
<p className="break-words text-slate-600">查询范围:{formatInvestigationText(gap.scope)}</p>
<p className="break-words text-slate-600">判断影响:{formatInvestigationText(gap.impact)}</p>
<p className="break-words text-slate-600">需补齐:{formatInvestigationText(gap.required_data)}</p>
<p className="break-words text-[var(--ink-muted)]">依据:{formatInvestigationText(gap.evidence_refs.map((id) => task.evidence.find((item) => item.evidence_id === id)?.summary ?? id).join(";"))}</p>
</div>)}
</div> : report.data_gaps == null ? <p className="text-[11px] leading-5 text-[var(--ink-muted)]">历史记录未提供结构化数据缺口。</p> : null}
{!insufficient && report.recommendations.length > 0 ? <ReportItems title="处置建议" items={report.recommendations} /> : null}
{!insufficient && report.pending_items.length > 0 ? <ReportItems title="待确认事项" items={report.pending_items} /> : null}
{insufficient ? <p className="text-xs leading-5 text-slate-600">补齐上述数据后可发起新一轮调查。原投诉工单与现场处置进度独立记录。</p> : null}
<div className="space-y-1 border-t border-[var(--surface-divider)]/70 pt-2 text-[11px] leading-5 text-[var(--ink-muted)]">
<p className="break-words">结论边界:{report.conclusion_boundary}</p>
<p>报告第 {snapshot.revision} 版 · {new Date(snapshot.published_at).toLocaleString("zh-CN", { hour12: false })}</p>
<p className="break-words">结论边界:{formatInvestigationText(report.conclusion_boundary)}</p>
<p>报告第 {snapshot.revision} 版 · {formatDateTime(snapshot.published_at)}</p>
</div>
</> : <p className="text-xs leading-6 text-slate-600">尚未形成任务报告。</p>}
</section>;
@@ -50,6 +52,6 @@ export function InvestigationReportView({ task, reportLabel }: { task: Operation
function ReportItems({ title, items }: { title: string; items: string[] }) {
return <div><h4 className="text-xs font-semibold text-slate-800">{title}</h4>
<ul className="mt-1 list-disc space-y-1 pl-4 text-xs leading-5 text-slate-700">{items.map((item, index) => <li key={index} className="break-words">{item}</li>)}</ul>
<ul className="mt-1 list-disc space-y-1 pl-4 text-xs leading-5 text-slate-700">{items.map((item, index) => <li key={index} className="break-words">{formatInvestigationText(item)}</li>)}</ul>
</div>;
}
@@ -1,9 +1,11 @@
import { useEffect, useState, type ReactNode } from "react";
import { FileText, History, MessageSquare } from "lucide-react";
import { MessageSquare } from "lucide-react";
import { Button } from "@/shared/ui/button";
import { Disclosure } from "@/shared/ui/disclosure";
import { StatusBadge } from "@/shared/ui/status";
import type { InvestigationDiagram, InvestigationDiscussion } from "../api/investigation-report";
import { formatDateTime as dateLabel } from "@/shared/utils/date-time";
import { formatInvestigationText } from "../utils/investigation-text";
import type { InvestigationDiagram, InvestigationDiscussion, InvestigationMapTarget } from "../api/investigation-report";
import type {
OperationalTask,
OperationalTaskAction,
@@ -17,6 +19,8 @@ import {
isPendingInvestigationAction
} from "../utils/investigation-presentation";
import { InvestigationDiagramView } from "./investigation-diagram-view";
import { InvestigationExpandCard } from "./investigation-expand-card";
import { useReportUpdateMotion } from "../hooks/use-report-update-motion";
type ReportSelection = { snapshot: OperationalTaskReport; diagram: InvestigationDiagram | null };
type Props = {
@@ -27,9 +31,9 @@ type Props = {
onLoadReports?: (offset: number) => Promise<OperationalTaskReportPage>;
onReportViewed?: (generation: number, revision: number) => void;
focusActionId?: string | null;
onLocate?: (targets: InvestigationMapTarget[]) => void;
};
const dateLabel = (value: string) => new Date(value).toLocaleString("zh-CN", { hour12: false });
const roundLabel = (snapshot: OperationalTaskReport) =>
snapshot.execution_generation == null ? "历史报告" : `第 ${snapshot.execution_generation} 轮`;
const actionTitle = (action: OperationalTaskAction) =>
@@ -123,27 +127,35 @@ export function InvestigationTaskReport({
renderEvidence,
onLoadReports,
onReportViewed,
focusActionId
focusActionId,
onLocate
}: Props) {
const [selection, setSelection] = useState<ReportSelection | null>(() =>
task.latest_report
? { snapshot: task.latest_report, diagram: investigationDiagram(task, task.latest_report) }
: null
);
// Null follows live task content. Only a deliberate history selection freezes a report.
const [selection, setSelection] = useState<ReportSelection | null>(null);
const [history, setHistory] = useState<OperationalTaskReport[]>([]);
const [historyOffset, setHistoryOffset] = useState<number | null>(0);
const [historyLoading, setHistoryLoading] = useState(false);
const [historyError, setHistoryError] = useState<string | null>(null);
const snapshot = selection?.snapshot ?? null;
const live = selection === null;
const snapshot = selection?.snapshot ?? task.latest_report;
const report = snapshot?.report;
const diagram = selection?.diagram ?? (snapshot ? null : investigationDiagram(task, null));
const progress = task.latest_progress?.execution_generation === task.execution_generation ? task.latest_progress : null;
// A current final report owns its archived graph. During investigation, a
// progress snapshot owns its whole graph, including an explicit null. A prior
// round's final report must never hide the new round's progress.
const currentFinalReport = snapshot?.execution_generation === task.execution_generation
&& report?.stage === "final";
const diagram = live
? progress && !currentFinalReport ? progress.progress.diagram : investigationDiagram(task, snapshot)
: selection.diagram;
const diagramRunning = Boolean(live && !currentFinalReport && progress && task.active_run?.run_id === progress.run_id && task.active_run.status === "running");
const previousRound = Boolean(
snapshot && snapshot.execution_generation !== task.execution_generation
);
const olderRevision = Boolean(
snapshot && task.latest_report && snapshot.revision !== task.latest_report.revision
);
const latestAvailable = Boolean(task.latest_report && (!snapshot || olderRevision));
const latestAvailable = !live;
const insufficient = report?.outcome === "insufficient_data";
const currentInsufficient = task.latest_report?.execution_generation === task.execution_generation
&& task.latest_report?.report.outcome === "insufficient_data";
@@ -153,14 +165,8 @@ export function InvestigationTaskReport({
const majorEvidence = (snapshot ? evidence : task.evidence).slice(0, 3);
const remainingEvidence = (snapshot ? evidence : task.evidence).slice(3);
const timeline = investigationTimeline(task, snapshot);
useEffect(() => {
if (!selection && task.latest_report)
setSelection({
snapshot: task.latest_report,
diagram: investigationDiagram(task, task.latest_report)
});
}, [selection, task]);
const reportRef = useReportUpdateMotion(task.task_id,
`${snapshot?.execution_generation}:${snapshot?.revision}:${progress?.run_id}:${progress?.revision}`, live);
useEffect(() => {
if (snapshot?.execution_generation == null || !onReportViewed) return;
@@ -182,6 +188,7 @@ export function InvestigationTaskReport({
const discuss = (prompt: string, extra?: { evidence_id?: string; gap_id?: string }) =>
onContinue({
prompt,
reportGeneration: snapshot?.execution_generation,
context: { ...(snapshot ? { report_revision: snapshot.revision } : {}), ...extra }
});
const selectReport = (item: OperationalTaskReport) =>
@@ -202,7 +209,7 @@ export function InvestigationTaskReport({
};
return (
<article aria-label="调查任务报告" className="investigation-report surface-reading">
<article ref={reportRef} aria-label="调查任务报告" className="investigation-report surface-reading">
<header className="investigation-report-header">
<div className="min-w-0">
<p className="text-xs text-[var(--ink-muted)]">
@@ -227,8 +234,9 @@ export function InvestigationTaskReport({
<Button
variant="outline"
size="sm"
title="打开调查协作对话,引用此任务,不会直接发送。"
onClick={() =>
onContinue({ context: snapshot ? { report_revision: snapshot.revision } : {} })
onContinue({ context: snapshot ? { report_revision: snapshot.revision } : {}, reportGeneration: snapshot?.execution_generation })
}
>
<MessageSquare size={14} aria-hidden />
@@ -238,19 +246,16 @@ export function InvestigationTaskReport({
{latestAvailable ? (
<div className="investigation-report-update" role="status">
<span>第 {task.latest_report!.execution_generation ?? "未知"} 轮有新报告</span>
<Button variant="outline" size="sm" onClick={() => selectReport(task.latest_report!)}>
查看新报告
<span>正在阅读历史报告{olderRevision ? ",已有新报告" : ""}</span>
<Button variant="outline" size="sm" onClick={() => setSelection(null)}>
返回实时内容
</Button>
</div>
) : null}
<section aria-label="任务报告" className="investigation-report-section">
<InvestigationExpandCard title="当前结果" label="任务报告" disabled={!report}
revision={`${snapshot?.execution_generation}:${snapshot?.revision}`} preview={<>
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="flex items-center gap-2 text-sm font-semibold text-[var(--ink)]">
<FileText size={15} aria-hidden />
当前结果
</h3>
{report ? (
<StatusBadge tone={insufficient ? "warning" : "neutral"}>
{insufficient
@@ -282,37 +287,42 @@ export function InvestigationTaskReport({
? "调查结论"
: "历史报告内容"}
</p>
<p className="mt-1 text-sm leading-6 text-[var(--ink)]">{report.current_conclusion}</p>
<p className="investigation-conclusion-preview mt-1 text-sm leading-6 text-[var(--ink)]">{formatInvestigationText(report.current_conclusion)}</p>
<p className="mt-2 text-xs leading-5 text-[var(--ink-muted)]">
结论边界:{report.conclusion_boundary}
结论边界:{formatInvestigationText(report.conclusion_boundary)}
</p>
{insufficient ? (
<p className="mt-2 text-sm leading-6 text-[var(--status-warning-foreground)]">
结束原因:{report.stop_reason}
结束原因:{formatInvestigationText(report.stop_reason)}
</p>
) : null}
<p className="mt-2 text-xs text-[var(--ink-muted)]">
正在阅读:{roundLabel(snapshot)} · 报告第 {snapshot.revision} 版 ·{" "}
{dateLabel(snapshot.published_at)}
</p>
{!insufficient &&
(report.recommendations.length > 0 || report.pending_items.length > 0) ? (
<Disclosure label="判断说明与建议" className="mt-2">
{report.recommendations.length > 0 ? (
<ReportItems title="处置建议" items={report.recommendations} />
) : null}
{report.pending_items.length > 0 ? (
<ReportItems title="待核实问题" items={report.pending_items} />
) : null}
</Disclosure>
) : null}
</>
) : (
<p className="mt-3 text-sm leading-6 text-[var(--ink-muted)]">
尚未发布本轮调查报告。已保存的调查资料可在下方查看。
</p>
)}
</section>
</>}>
{report && snapshot ? <>
<p className="text-xs text-[var(--ink-muted)]">{roundLabel(snapshot)} · 报告第 {snapshot.revision} 版 · {dateLabel(snapshot.published_at)}</p>
<p className="mt-3 whitespace-pre-wrap text-sm leading-7 text-[var(--ink)]">{formatInvestigationText(report.current_conclusion)}</p>
<p className="mt-3 text-sm leading-6 text-[var(--ink-muted)]">结论边界:{formatInvestigationText(report.conclusion_boundary)}</p>
{insufficient ? <p className="mt-3 text-sm leading-6 text-[var(--status-warning-foreground)]">结束原因:{formatInvestigationText(report.stop_reason)}</p> : <>
{report.recommendations.length > 0 ? <ReportItems title="处置建议" items={report.recommendations} /> : null}
{report.pending_items.length > 0 ? <ReportItems title="待核实问题" items={report.pending_items} /> : null}
</>}
</> : <p className="text-sm text-[var(--ink-muted)]">尚未发布本轮调查报告。</p>}
</InvestigationExpandCard>
{live ? <section aria-label="当前调查进展" className="investigation-report-section">
<h3 className="text-sm font-semibold text-[var(--ink)]">当前调查进展</h3>
<p className="mt-2 whitespace-pre-wrap break-words text-sm leading-6 text-[var(--ink)]">{formatInvestigationText(progress?.progress.summary ?? "尚未发布本轮调查进展。")}</p>
{progress ? <p className="mt-1 text-xs text-[var(--ink-muted)]">实时内容 · 第 {progress.execution_generation} 轮 · 更新 {dateLabel(progress.published_at)}</p> : null}
</section> : null}
{pendingActions.length > 0 ? (
<section aria-label="当前需处理" className="investigation-report-section">
@@ -387,13 +397,13 @@ export function InvestigationTaskReport({
</h3>
{report.data_gaps.map((gap) => (
<div key={gap.gap_id} className="mt-3">
<p className="text-sm font-medium leading-6 text-[var(--ink)]">{gap.missing_data}</p>
<p className="text-sm leading-6 text-[var(--ink-muted)]">判断影响:{gap.impact}</p>
<p className="text-sm font-medium leading-6 text-[var(--ink)]">{formatInvestigationText(gap.missing_data)}</p>
<p className="text-sm leading-6 text-[var(--ink-muted)]">判断影响:{formatInvestigationText(gap.impact)}</p>
<div className="mt-1 flex flex-wrap items-center gap-x-4">
<Disclosure label="查看查询依据">
<div className="space-y-1 py-2 text-xs leading-5 text-[var(--ink-muted)]">
<p>查询范围:{gap.scope}</p>
<p>需补齐:{gap.required_data}</p>
<p>查询范围:{formatInvestigationText(gap.scope)}</p>
<p>需补齐:{formatInvestigationText(gap.required_data)}</p>
{gap.evidence_refs.map((id) => (
<EvidenceReference
key={id}
@@ -409,7 +419,7 @@ export function InvestigationTaskReport({
size="sm"
onClick={() =>
discuss(
`我要补充“${gap.missing_data}”相关的现场事实。请说明需要的记录范围和时间信息。`,
`我要补充“${formatInvestigationText(gap.missing_data)}”相关的现场事实。请说明需要的记录范围和时间信息。`,
{ gap_id: gap.gap_id }
)
}
@@ -433,8 +443,10 @@ export function InvestigationTaskReport({
{diagram ? (
<InvestigationDiagramView
key={`${snapshot?.revision ?? "plan"}:${diagram.mermaid}`}
key={live ? `live:${task.task_id}` : `report:${snapshot?.revision}`}
diagram={diagram}
running={diagramRunning}
onLocate={onLocate}
renderDetails={(node) => (
<>
{node.evidence_refs.map((id) => (
@@ -473,22 +485,43 @@ export function InvestigationTaskReport({
/>
) : null}
<section aria-label="主要依据" className="investigation-report-section">
<h3 className="text-sm font-semibold text-[var(--ink)]">主要依据</h3>
<InvestigationExpandCard title="主要依据" disabled={task.evidence.length === 0} preview={<>
<div className="investigation-evidence-preview">
{majorEvidence.map((item) => <div key={item.evidence_id}>
<p className="text-sm leading-6 text-[var(--ink)]">{formatInvestigationText(item.summary)}</p>
<p className="text-xs leading-5 text-[var(--ink-muted)]">{item.source_label} · {dateLabel(item.observed_at ?? item.recorded_at)}</p>
<Button type="button" variant="outline" size="sm" className="mt-2" onClick={() => discuss(
`请解释“${formatInvestigationText(item.summary)}”这条证据的范围、来源和判断影响。`, { evidence_id: item.evidence_id }
)}><MessageSquare size={14} aria-hidden />围绕此证据提问</Button>
</div>)}
</div>
<p className="mt-2 text-xs text-[var(--ink-muted)]">
{(snapshot ? evidence : task.evidence).length > 0
? `共 ${(snapshot ? evidence : task.evidence).length} 条引用依据,展开查看来源与原始记录。`
: "尚无可展示的引用证据。"}
</p>
</>}>
{(close) => <>
{majorEvidence.map((item) => (
<div key={item.evidence_id} className="mt-2">
{renderEvidence(item)}
<button
{onLocate && diagram?.node_refs.some((node) => node.evidence_refs.includes(item.evidence_id) && node.map_targets?.length) ? (
<button type="button" className="investigation-text-action" onClick={() => { close(false); onLocate(diagram.node_refs.filter((node) => node.evidence_refs.includes(item.evidence_id)).flatMap((node) => node.map_targets ?? [])); }}>在地图查看</button>
) : null}
<Button
type="button"
className="investigation-text-action"
onClick={() =>
discuss(`请解释“${item.summary}”这条证据的范围、来源和判断影响。`, {
variant="outline"
size="sm"
className="mt-2"
onClick={() => { close(false);
discuss(`请解释“${formatInvestigationText(item.summary)}”这条证据的范围、来源和判断影响。`, {
evidence_id: item.evidence_id
})
}
});
}}
>
<MessageSquare size={14} aria-hidden />
围绕此证据提问
</button>
</Button>
</div>
))}
{(snapshot ? evidence : task.evidence).length === 0 ? (
@@ -499,17 +532,20 @@ export function InvestigationTaskReport({
{remainingEvidence.map((item) => (
<div key={item.evidence_id}>
{renderEvidence(item)}
<button
<Button
type="button"
className="investigation-text-action"
onClick={() =>
discuss(`请解释“${item.summary}”的来源和判断影响。`, {
variant="outline"
size="sm"
className="mt-2"
onClick={() => { close(false);
discuss(`请解释“${formatInvestigationText(item.summary)}”的来源和判断影响。`, {
evidence_id: item.evidence_id
})
}
});
}}
>
<MessageSquare size={14} aria-hidden />
围绕此证据提问
</button>
</Button>
</div>
))}
</Disclosure>
@@ -522,45 +558,31 @@ export function InvestigationTaskReport({
.map(renderEvidence)}
</Disclosure>
) : null}
</section>
</>}
</InvestigationExpandCard>
<section className="investigation-report-section">
<Disclosure label="任务时间线">
<p className="pb-2 text-xs leading-5 text-[var(--ink-muted)]">
按时间倒序汇总当前任务记录;报告事件对应当前正在阅读的版本。
</p>
<ol aria-label="任务时间线记录" className="investigation-timeline">
{timeline.map((item) => (
<li key={item.id} className="investigation-timeline-item">
<time dateTime={item.occurredAt} className="block text-[11px] text-[var(--ink-muted)]">
{dateLabel(item.occurredAt)}
</time>
<p className="text-xs font-medium leading-5 text-[var(--ink)]">{item.title}</p>
<p className="text-xs leading-5 text-[var(--ink-muted)]">{item.detail}</p>
</li>
))}
</ol>
</Disclosure>
<InvestigationExpandCard title="任务时间线" disabled={timeline.length === 0} preview={
<p className="mt-2 text-sm leading-6 text-[var(--ink-muted)]">
{timeline[0] ? `${timeline[0].title} · ${dateLabel(timeline[0].occurredAt)},共 ${timeline.length} 条记录。` : "尚无调查记录。"}
</p>
}>
<InvestigationTimeline items={timeline} />
</InvestigationExpandCard>
{onLoadReports ? (
<Disclosure
label={
<span className="flex items-center gap-2">
<History size={14} aria-hidden />
报告版本
</span>
}
onToggle={(event) => {
if (event.currentTarget.open && history.length === 0 && !historyLoading)
void loadHistory(0);
}}
<InvestigationExpandCard title="报告版本"
preview={<p className="mt-2 text-sm leading-6 text-[var(--ink-muted)]">
{snapshot ? `${roundLabel(snapshot)} · 报告第 ${snapshot.revision} 版 · ${dateLabel(snapshot.published_at)}` : "尚未发布报告。"}
</p>}
onOpen={() => { if (history.length === 0 && !historyLoading) void loadHistory(0); }}
>
{(close) => <>
{history.map((item) => (
<button
key={item.revision}
type="button"
className="investigation-history-row"
aria-pressed={snapshot?.revision === item.revision}
onClick={() => selectReport(item)}
onClick={() => { selectReport(item); close(); }}
>
<span>
{roundLabel(item)} · 报告第 {item.revision} 版
@@ -593,16 +615,30 @@ export function InvestigationTaskReport({
{historyError ? "重新加载" : "更多报告"}
</Button>
) : null}
</Disclosure>
</>}
</InvestigationExpandCard>
) : null}
<p className="mt-3 text-xs text-[var(--ink-muted)]">
<p className="px-4 pb-4 pt-1 text-xs text-[var(--ink-muted)]">
任务更新于 {dateLabel(task.updated_at)}
</p>
</section>
</article>
);
}
function InvestigationTimeline({ items }: { items: InvestigationTimelineItem[] }) {
return <>
<p className="pb-3 text-xs leading-5 text-[var(--ink-muted)]">按时间倒序汇总当前任务记录;报告事件对应当前正在阅读的版本。</p>
<ol aria-label="任务时间线记录" className="investigation-timeline">
{items.map((item) => <li key={item.id} className="investigation-timeline-item">
<time dateTime={item.occurredAt} className="block text-[11px] text-[var(--ink-muted)]">{dateLabel(item.occurredAt)}</time>
<p className="text-xs font-medium leading-5 text-[var(--ink)]">{item.title}</p>
<p className="text-xs leading-5 text-[var(--ink-muted)]">{formatInvestigationText(item.detail)}</p>
</li>)}
</ol>
{items.length === 0 ? <p className="text-sm text-[var(--ink-muted)]">尚无调查记录。</p> : null}
</>;
}
function EvidenceReference({
id,
task,
@@ -626,7 +662,7 @@ function ReportItems({ title, items }: { title: string; items: string[] }) {
<p className="font-medium">{title}</p>
<ul className="mt-1 list-disc space-y-1 pl-4">
{items.map((item, index) => (
<li key={index}>{item}</li>
<li key={index}>{formatInvestigationText(item)}</li>
))}
</ul>
</div>
@@ -9,7 +9,25 @@ import { MapDevPanel } from "./map-dev-panel";
afterEach(cleanup);
describe("MapDevPanel flow controls", () => {
describe("MapDevPanel", () => {
it("defaults to following investigations and allows disabling it", async () => {
const user = userEvent.setup();
const onFollowChange = vi.fn();
render(
<MapDevPanel
commands={createCommands()}
controllerState={createControllerState()}
detailFeature={null}
onClose={vi.fn()}
onInvestigationFollowChange={onFollowChange}
/>
);
const follow = screen.getByRole("checkbox", { name: "跟随所选调查" });
expect(follow).toBeChecked();
await user.click(follow);
expect(onFollowChange).toHaveBeenCalledWith(false);
});
it("updates a pipe with its signed velocity", async () => {
const user = userEvent.setup();
const commands = createCommands();
@@ -23,6 +41,7 @@ describe("MapDevPanel flow controls", () => {
/>
);
await user.click(screen.getByText("水流调试"));
const pipeId = screen.getByLabelText("管道 ID");
const velocity = screen.getByLabelText("有符号流速");
await user.clear(pipeId);
@@ -37,7 +56,8 @@ describe("MapDevPanel flow controls", () => {
});
});
it("shows the current flow direction counts", () => {
it("shows the current flow direction counts", async () => {
const user = userEvent.setup();
render(
<MapDevPanel
commands={createCommands()}
@@ -55,9 +75,12 @@ describe("MapDevPanel flow controls", () => {
/>
);
expect(screen.getByRole("status", {
name: ""
})).toHaveTextContent("已赋值 4 · 正向 2 · 反向 1 · 停止 1");
await user.click(screen.getByText("水流调试"));
expect(
screen.getByRole("status", {
name: ""
})
).toHaveTextContent("已赋值 4 · 正向 2 · 反向 1 · 停止 1");
});
});
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
import { Crosshair, RotateCcw, X, ZoomIn } from "lucide-react";
import { Button } from "@/shared/ui/button";
import { Checkbox } from "@/shared/ui/checkbox";
import { cn } from "@/shared/ui/cn";
import { Disclosure } from "@/shared/ui/disclosure";
import { FieldSelect } from "@/shared/ui/field-select";
import { Input } from "@/shared/ui/input";
import {
@@ -30,6 +30,8 @@ type MapDevPanelProps = {
detailFeature: DetailFeature | null;
flowDataSource?: "realtime" | "mock";
onClose: () => void;
investigationFollow?: boolean;
onInvestigationFollowChange?: (enabled: boolean) => void;
};
const GROUP_LABELS = Object.fromEntries(
@@ -102,7 +104,9 @@ export function MapDevPanel({
controllerState,
detailFeature,
flowDataSource = "mock",
onClose
onClose,
investigationFollow = true,
onInvestigationFollowChange
}: MapDevPanelProps) {
const [sourceId, setSourceId] = useState<AvailableWaterNetworkSourceId>("pipes");
const [featureId, setFeatureId] = useState("440681");
@@ -231,13 +235,18 @@ export function MapDevPanel({
return (
<aside
aria-label="地图开发面板"
className="acrylic-panel pointer-events-auto absolute right-3 top-[4.5rem] z-40 hidden max-h-[calc(100dvh-5.5rem)] w-[400px] flex-col overflow-hidden rounded-2xl border lg:flex"
className="acrylic-panel pointer-events-auto absolute right-3 top-[4.5rem] z-40 hidden max-h-[calc(100dvh-5.5rem)] w-[400px] max-w-[calc(100vw-1.5rem)] flex-col overflow-hidden rounded-2xl border lg:flex"
data-testid="map-dev-panel"
>
<div className="flex h-12 shrink-0 items-center justify-between border-b border-[var(--surface-divider)]/70 px-4">
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-[var(--surface-divider)]/70 px-4 py-3">
<div>
<h2 className="text-sm font-semibold text-slate-950">地图能力 Dev Panel</h2>
<p className="text-[11px] text-[var(--ink-muted)]">仅本次页面会话生效</p>
<h2 className="text-sm font-semibold text-foreground">
地图调试{" "}
<span className="ml-1 text-[11px] font-medium text-[var(--ink-muted)]">Dev Panel</span>
</h2>
<p className="mt-1 text-[11px] leading-4 text-[var(--ink-muted)]">
按功能展开 · 设置仅本次页面会话生效
</p>
</div>
<Button
size="icon"
@@ -250,18 +259,40 @@ export function MapDevPanel({
</Button>
</div>
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto p-3">
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-3 [scrollbar-gutter:stable]">
{controllerState.errorCode ? (
<div
role="alert"
className="status-tone-danger rounded-xl border border-[var(--status-border)] bg-[var(--status-soft)] px-3 py-2 text-xs font-medium text-[var(--status-foreground)]"
className="status-tone-danger my-3 break-words rounded-xl border border-[var(--status-border)] bg-[var(--status-soft)] px-3 py-2 text-xs font-medium text-[var(--status-foreground)]"
>
{ERROR_LABELS[controllerState.errorCode] ?? controllerState.errorCode}
</div>
) : null}
<PanelSection title="Feature" description="WFS 验证后执行独立高亮或相机定位。">
<div className="grid grid-cols-[120px_1fr] gap-2">
{onInvestigationFollowChange ? (
<PanelSection
title="调查联动"
description="选中调查后,自动跟随新的高亮、图表与影响范围展示。"
initiallyOpen
status={investigationFollow ? "已开启" : "已关闭"}
>
<ToggleField
label="跟随所选调查"
checked={investigationFollow}
onChange={onInvestigationFollowChange}
/>
</PanelSection>
) : null}
<PanelSection
title="要素定位"
description="读取目标要素后,执行地图高亮或相机定位。"
initiallyOpen
status={
controllerState.pending ? "执行中" : controllerState.target ? "已高亮" : undefined
}
>
<div className="grid grid-cols-[120px_minmax(0,1fr)] gap-2">
<FieldSelect
label="业务源"
value={sourceId}
@@ -336,10 +367,13 @@ export function MapDevPanel({
</PanelSection>
<PanelSection
title="全网水流"
description={flowDataSource === "realtime"
? "已读取后端最近 15 分钟的管道流速;可用有符号流速覆盖单管方向。"
: "后端流速不可用,开启时使用正反向演示流向;有符号流速可覆盖单管方向。"}
title="水流调试"
status={controllerState.flowVisible ? "已开启" : "未开启"}
description={
flowDataSource === "realtime"
? "已读取后端最近 15 分钟的管道流速;可用有符号流速覆盖单管方向。"
: "后端流速不可用,开启时使用正反向演示流向;有符号流速可覆盖单管方向。"
}
>
<div className="grid grid-cols-2 gap-2">
<Button
@@ -407,8 +441,9 @@ export function MapDevPanel({
</div>
{flowSummary.total > 0 ? (
<StatusText>
{flowDataSource === "realtime" ? "后端实时流速" : "演示流向"} · 已赋值 {flowSummary.total} · 正向 {flowSummary.forward} · 反向{" "}
{flowSummary.reverse} · 停止 {flowSummary.stopped}
{flowDataSource === "realtime" ? "后端实时流速" : "演示流向"} · 已赋值{" "}
{flowSummary.total} · 正向 {flowSummary.forward} · 反向 {flowSummary.reverse} · 停止{" "}
{flowSummary.stopped}
</StatusText>
) : controllerState.flowVisible ? (
<StatusText>演示流向已启用 · 正反向交错显示</StatusText>
@@ -418,8 +453,13 @@ export function MapDevPanel({
</PanelSection>
<PanelSection
title="样式编辑"
description="统一配置业务几何与整层 MVT 标注,交互语义色保持不变。"
title="图层样式"
status={
controllerState.styledGroups.length > 0
? `${controllerState.styledGroups.length} 组已覆盖`
: undefined
}
description="调整业务图层的颜色、透明度和尺寸,交互语义色保持不变。"
>
<FieldSelect
label="业务图层组"
@@ -519,91 +559,6 @@ export function MapDevPanel({
) : null}
</>
)}
<div className="space-y-2.5 rounded-xl bg-[var(--surface-control)]/75 p-3 shadow-[inset_0_0_0_1px_rgba(148,163,184,0.18)]">
<div className="flex items-start justify-between gap-3">
<div>
<h4 className="text-[11px] font-semibold text-slate-800">整层标注</h4>
<p className="mt-0.5 text-[10px] leading-4 text-[var(--ink-muted)]">
作用于当前图层所有可见 Feature,自动避让重叠。
</p>
</div>
<span className="shrink-0 rounded-md bg-teal-50 px-1.5 py-0.5 text-[9px] font-semibold text-teal-700">
MVT 原生
</span>
</div>
<div className="grid grid-cols-2 gap-2">
<ToggleField label="数值" checked={showValueLabels} onChange={setShowValueLabels} />
<ToggleField label="设施 ID" checked={showFeatureIds} onChange={setShowFeatureIds} />
</div>
{showValueLabels ? (
<>
<FieldSelect
label="数值属性"
value={property}
onValueChange={setProperty}
options={LABEL_PRESETS[groupId].options}
/>
<div className="grid grid-cols-2 gap-2">
<Field label="精度 0-3">
<Input
type="number"
min={0}
max={3}
value={precision}
onChange={(event) => setPrecision(Number(event.target.value))}
className={inputClassName}
/>
</Field>
<Field label="单位">
<Input
maxLength={16}
value={unit}
onChange={(event) => setUnit(event.target.value)}
className={inputClassName}
/>
</Field>
</div>
</>
) : null}
<RangeField
label="字号"
min={10}
max={18}
step={1}
value={labelSize}
onChange={setLabelSize}
/>
<ColorField label="文字颜色" value={labelColor} onChange={setLabelColor} />
<div className="grid grid-cols-2 gap-2">
<Button
type="button"
disabled={!showValueLabels && !showFeatureIds}
onClick={applyLabels}
className="w-full text-xs"
>
应用标注
</Button>
<Button
type="button"
disabled={!hasActiveLabels}
onClick={() => {
setShowValueLabels(false);
setShowFeatureIds(false);
commands.clearValueLabel();
}}
className="w-full text-xs"
variant="outline"
>
清除全部标注
</Button>
</div>
{activeLabel ? (
<StatusText>
已标注全部 {GROUP_LABELS[groupId]} Feature · {activeLabel.showId ? "ID + " : ""}
{activeLabel.showValue ? activeLabel.property : "仅 ID"}
</StatusText>
) : null}
</div>
<div className="grid grid-cols-2 gap-2">
<Button type="button" onClick={applyLayerStyle} className="w-full text-xs">
应用图层样式
@@ -621,6 +576,95 @@ export function MapDevPanel({
<StatusText>已覆盖当前图层组样式</StatusText>
) : null}
</PanelSection>
<PanelSection
title="数值标注"
description="作用于所选图层的全部可见要素,使用原生 MVT 标注并自动避让。"
status={
hasActiveLabels
? `${Object.keys(controllerState.valueLabels).length} 组已标注`
: undefined
}
>
<FieldSelect
label="标注图层组"
value={groupId}
onValueChange={(value) => selectStyleGroup(value as LayerGroupId)}
options={STYLE_GROUP_IDS.map((id) => ({ value: id, label: GROUP_LABELS[id] }))}
/>
<div className="grid grid-cols-2 gap-2">
<ToggleField label="数值" checked={showValueLabels} onChange={setShowValueLabels} />
<ToggleField label="设施 ID" checked={showFeatureIds} onChange={setShowFeatureIds} />
</div>
{showValueLabels ? (
<>
<FieldSelect
label="数值属性"
value={property}
onValueChange={setProperty}
options={LABEL_PRESETS[groupId].options}
/>
<div className="grid grid-cols-2 gap-2">
<Field label="精度 0-3">
<Input
type="number"
min={0}
max={3}
value={precision}
onChange={(event) => setPrecision(Number(event.target.value))}
className={inputClassName}
/>
</Field>
<Field label="单位">
<Input
maxLength={16}
value={unit}
onChange={(event) => setUnit(event.target.value)}
className={inputClassName}
/>
</Field>
</div>
</>
) : null}
<RangeField
label="字号"
min={10}
max={18}
step={1}
value={labelSize}
onChange={setLabelSize}
/>
<ColorField label="文字颜色" value={labelColor} onChange={setLabelColor} />
<div className="grid grid-cols-2 gap-2">
<Button
type="button"
disabled={!showValueLabels && !showFeatureIds}
onClick={applyLabels}
className="w-full text-xs"
>
应用标注
</Button>
<Button
type="button"
disabled={!hasActiveLabels}
onClick={() => {
setShowValueLabels(false);
setShowFeatureIds(false);
commands.clearValueLabel();
}}
className="w-full text-xs"
variant="outline"
>
清除全部标注
</Button>
</div>
{activeLabel ? (
<StatusText>
已标注全部 {GROUP_LABELS[groupId]} Feature · {activeLabel.showId ? "ID + " : ""}
{activeLabel.showValue ? activeLabel.property : "仅 ID"}
</StatusText>
) : null}
</PanelSection>
</div>
<div className="shrink-0 border-t border-[var(--surface-divider)]/70 p-3">
@@ -631,7 +675,7 @@ export function MapDevPanel({
variant="outline"
>
<RotateCcw size={14} />
重置全部
重置地图调试效果
</Button>
</div>
</aside>
@@ -641,27 +685,43 @@ export function MapDevPanel({
function PanelSection({
title,
description,
initiallyOpen = false,
status,
children
}: {
title: string;
description: string;
initiallyOpen?: boolean;
status?: string;
children: React.ReactNode;
}) {
return (
<section className="space-y-2.5 border-b border-[var(--surface-divider)]/70 px-1 pb-4 last:border-b-0 last:pb-1">
<div>
<h3 className="text-xs font-semibold text-slate-900">{title}</h3>
<p className="mt-0.5 text-[11px] leading-4 text-[var(--ink-muted)]">{description}</p>
<Disclosure
initiallyOpen={initiallyOpen}
className="border-b border-[var(--surface-divider)]/70 py-1 last:border-b-0 [&>summary]:px-2 [&>summary]:text-foreground"
label={
<span className="flex min-w-0 items-center justify-between gap-2">
<span className="font-semibold">{title}</span>
{status ? (
<span className="shrink-0 text-[11px] font-normal text-[var(--ink-muted)]">
{status}
</span>
) : null}
</span>
}
>
<div className="space-y-3 px-2 pb-3 pt-1">
<p className="text-[11px] leading-5 text-[var(--ink-muted)]">{description}</p>
{children}
</div>
{children}
</section>
</Disclosure>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="block min-w-0">
<span className="mb-1 block text-[11px] font-medium text-slate-600">{label}</span>
<span className="mb-1.5 block text-xs font-medium text-[var(--ink-muted)]">{label}</span>
{children}
</label>
);
@@ -677,11 +737,10 @@ function ToggleField({
onChange: (checked: boolean) => void;
}) {
return (
<label className="flex h-9 cursor-pointer items-center gap-2 rounded-lg bg-[var(--surface-reading)]/80 px-2.5 text-[11px] font-medium text-slate-700 shadow-[inset_0_0_0_1px_rgba(203,213,225,0.9)]">
<label className="flex min-h-10 cursor-pointer items-center gap-2 rounded-lg border border-[var(--surface-divider)] bg-[var(--surface-reading)]/80 px-2.5 py-2 text-xs font-medium text-foreground">
<Checkbox
checked={checked}
onChange={(event) => onChange(event.target.checked)}
className="size-4 rounded"
/>
{label}
</label>
@@ -699,14 +758,15 @@ function ColorField({
}) {
return (
<Field label={label}>
<div className="flex h-9 items-center gap-2 rounded-lg border border-[var(--surface-divider)] bg-[var(--surface-reading)]/80 px-2">
<div className="flex min-h-10 items-center gap-2 rounded-lg border border-[var(--surface-divider)] bg-[var(--surface-reading)]/80 px-2">
<input
type="color"
value={value}
onChange={(event) => onChange(event.target.value.toUpperCase())}
className="h-5 w-7 cursor-pointer border-0 bg-transparent p-0"
aria-label={label}
className="h-10 w-10 shrink-0 cursor-pointer rounded border-0 bg-transparent p-1 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--action-blue)]"
/>
<span className="text-xs font-medium text-slate-600">{value}</span>
<span className="text-xs font-medium tabular-nums text-[var(--ink-muted)]">{value}</span>
</div>
</Field>
);
@@ -737,7 +797,7 @@ function RangeField({
step={step}
value={value}
onChange={(event) => onChange(Number(event.target.value))}
className="h-6 w-full accent-[var(--action-blue)]"
className="h-10 w-full rounded accent-[var(--action-blue)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--action-blue)]"
/>
</Field>
);
@@ -747,7 +807,7 @@ function StatusText({ children }: { children: React.ReactNode }) {
return (
<p
role="status"
className="status-tone-success rounded-lg border border-[var(--status-border)] bg-[var(--status-soft)] px-2.5 py-1.5 text-[11px] font-medium text-[var(--status-foreground)]"
className="status-tone-success break-words rounded-lg border border-[var(--status-border)] bg-[var(--status-soft)] px-2.5 py-1.5 text-[11px] font-medium leading-5 text-[var(--status-foreground)] [overflow-wrap:anywhere]"
>
{children}
</p>
@@ -755,4 +815,4 @@ function StatusText({ children }: { children: React.ReactNode }) {
}
const inputClassName =
"h-10 w-full rounded-lg border border-[var(--surface-divider)] bg-[var(--surface-reading)]/80 px-2.5 text-xs text-slate-800 transition-colors";
"h-10 w-full rounded-lg border border-[var(--surface-divider)] bg-[var(--surface-reading)]/80 px-2.5 text-xs text-foreground transition-colors";
@@ -25,6 +25,7 @@ import type {
OperationalTaskDataSnapshot
} from "../api/operational-task-client";
import { cn } from "@/shared/ui/cn";
import { formatDateTime } from "@/shared/utils/date-time";
import { Disclosure } from "@/shared/ui/disclosure";
import {
StatusBadge,
@@ -54,7 +55,7 @@ import {
getStatusActivity,
getStatusTone,
getRiskLabel,
statusLabels
getConditionStatusLabel
} from "./scheduled-condition-feed-utils";
import { getConditionReportRiskLevel } from "./condition-report-risk";
@@ -108,6 +109,7 @@ type ExecutionStep = {
};
type ConditionDetailPanelProps = {
onLocate?: (targets: import("../api/investigation-report").InvestigationMapTarget[]) => void;
condition: ScheduledConditionItem;
dataSource?: WorkbenchRuntimeDataSource;
workOrderDataSource?: WorkbenchWorkOrderDataSource;
@@ -155,12 +157,13 @@ export function StatusPill({
}
className={className}
>
{insufficient ? "已结束 · 证据不足" : isAgentEvent ? "事件调查" : externalStatus ? externalWorkOrderStatusLabels[externalStatus] : statusLabels[condition.status]}
{insufficient ? "已结束 · 证据不足" : isAgentEvent ? "事件调查" : externalStatus ? externalWorkOrderStatusLabels[externalStatus] : getConditionStatusLabel(condition)}
</StatusBadge>
);
}
export function ConditionDetailPanel({
onLocate,
condition,
dataSource = "mock",
workOrderDataSource = "mock",
@@ -188,6 +191,7 @@ export function ConditionDetailPanel({
/>
) : (
<ConditionRecordDetailPanel
onLocate={onLocate}
condition={condition}
dataSource={dataSource}
workOrderDataSource={workOrderDataSource}
@@ -223,6 +227,7 @@ export function ConditionDetailPanel({
}
function ConditionRecordDetailPanel({
onLocate,
condition,
dataSource,
workOrderDataSource,
@@ -239,6 +244,7 @@ function ConditionRecordDetailPanel({
onReportViewed,
focusActionId
}: {
onLocate?: ConditionDetailPanelProps["onLocate"];
condition: ScheduledConditionRecord;
dataSource: WorkbenchRuntimeDataSource;
workOrderDataSource: WorkbenchWorkOrderDataSource;
@@ -309,6 +315,7 @@ function ConditionRecordDetailPanel({
) : condition.operationalTask ? (
<>
<AgentExecutionPlanPanel
onLocate={onLocate}
task={condition.operationalTask}
onLoadReports={onLoadTaskReports ? (offset) => onLoadTaskReports(condition.operationalTask!.task_id, offset) : undefined}
onReportViewed={onReportViewed ? (generation, revision) => onReportViewed(condition.operationalTask!.task_id, generation, revision) : undefined}
@@ -1447,10 +1454,9 @@ function getWorkOrderSourceStatus(condition: ScheduledWorkOrderItem): NonNullabl
}
function formatWorkOrderDateTime(value: string) {
const timestamp = Date.parse(value);
return Number.isNaN(timestamp) ? value : new Intl.DateTimeFormat("zh-CN", {
year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false
}).format(timestamp);
return formatDateTime(value, {
year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit"
});
}
function formatWorkOrderScheduledAt(condition: ScheduledWorkOrderItem) {
@@ -3,11 +3,46 @@ import {
groupScheduledConditions,
getStatusActivity,
getStatusTone,
getConditionStatusLabel,
createStatusFilterOptions,
formatTime,
formatTimeRange,
sortConditionsNewestFirst
} from "./scheduled-condition-feed-utils";
import type { ScheduledConditionItem, ScheduledWorkOrderItem } from "../types";
import type { OperationalTask } from "../api/operational-task-client";
describe("workbench UTC+8 time display", () => {
it.each([
["2026-10-08T04:30:00Z", "12:30"],
["2026-10-08T04:30:00+00:00", "12:30"],
["2026-10-08T12:30:00+08:00", "12:30"],
["2026-10-08T16:00:00Z", "00:00"]
])("displays %s as %s without depending on the browser timezone", (value, expected) => {
expect(formatTime(value)).toBe(expected);
});
it("uses the same timezone for both ends of a range, including midnight", () => {
expect(formatTimeRange("2026-10-08T04:30:00Z", 60)).toBe("12:30 - 13:30");
expect(formatTimeRange("2026-10-08T15:30:00Z", 60)).toBe("23:30 - 00:30");
expect(formatTimeRange("2026-10-08T12:30:00+08:00", 0)).toBe("12:30 - 12:30");
});
it("preserves invalid display values without producing an invalid end date", () => {
expect(formatTimeRange("未安排", 60)).toBe("未安排");
});
});
describe("scheduled condition status presentation", () => {
it("labels queued investigations as waiting without changing appointment wording", () => {
const base = createCondition("investigation", "condition", "2026-10-10T10:29:47Z");
if (base.kind !== "condition") throw new Error("Expected investigation fixture");
const condition = { ...base, status: "pending" as const,
operationalTask: { task_type: "event_investigation" } as OperationalTask };
expect(getConditionStatusLabel(condition)).toBe("待调查");
expect(createStatusFilterOptions([condition]).find((option) => option.value === "pending")?.label).toBe("待调查");
expect(getConditionStatusLabel(createWorkOrder("appointment"))).toBe("预约");
});
it("maps lifecycle states to the shared semantic language", () => {
expect({
pending: getStatusTone("pending"),
@@ -6,6 +6,7 @@ import {
XCircle
} from "lucide-react";
import type { StatusActivity, StatusTone } from "@/shared/ui/status";
export { formatTime, formatTimeRange } from "@/shared/utils/date-time";
import type {
ScheduledConditionItem,
ScheduledConditionItemStatus
@@ -23,6 +24,12 @@ export const statusLabels: Record<ScheduledConditionItemStatus, string> = {
pending: "预约"
};
export function getConditionStatusLabel(condition: ScheduledConditionItem) {
return condition.status === "pending" && condition.kind === "condition"
&& condition.operationalTask?.task_type === "event_investigation"
? "待调查" : statusLabels[condition.status];
}
const statusTones = {
completed: "success",
running: "info",
@@ -111,7 +118,9 @@ export function createStatusFilterOptions(conditions: ScheduledConditionItem[]):
return conditionStatusOrder.map((status) => ({
value: status,
label: statusLabels[status],
label: status === "pending" && conditions.some((condition) =>
condition.kind === "condition" && condition.operationalTask?.task_type === "event_investigation"
) ? "待调查" : statusLabels[status],
count: statusCounts.get(status) ?? 0
}));
}
@@ -120,22 +129,6 @@ export function getConditionTime(condition: ScheduledConditionItem) {
return Date.parse(condition.scheduledAt);
}
export function formatTime(value: string) {
const match = value.match(/T(\d{2}:\d{2})/);
return match?.[1] ?? value;
}
export function formatTimeRange(value: string, durationMinutes: number | undefined) {
const startTime = formatTime(value);
if (typeof durationMinutes !== "number") {
return startTime;
}
const endDate = new Date(Date.parse(value) + durationMinutes * 60_000);
const endTime = `${endDate.getHours().toString().padStart(2, "0")}:${endDate.getMinutes().toString().padStart(2, "0")}`;
return `${startTime} - ${endTime}`;
}
export function formatDuration(durationMinutes: number | undefined, status: ScheduledConditionItemStatus) {
if (status === "running") {
return "执行中";
@@ -94,6 +94,7 @@ type ScheduledConditionFeedProps = {
onLoadTaskEvidenceSnapshot?: (taskId: string, evidenceId: string) => Promise<OperationalTaskDataSnapshot>;
onTaskRefresh?: () => void;
onLoadTaskReports?: (taskId: string, offset: number) => Promise<OperationalTaskReportPage>;
onLocate?: (targets: import("../api/investigation-report").InvestigationMapTarget[]) => void;
onReportViewed?: (taskId: string, generation: number, revision: number) => void;
onRefresh?: () => void | Promise<void>;
};
@@ -133,6 +134,7 @@ export function ScheduledConditionFeed({
onLoadTaskEvidenceSnapshot,
onTaskRefresh,
onLoadTaskReports,
onLocate,
onReportViewed,
onRefresh
}: ScheduledConditionFeedProps) {
@@ -140,10 +142,14 @@ export function ScheduledConditionFeed({
const [uncontrolledSelectedConditionId, setUncontrolledSelectedConditionId] = useState<
string | null
>(null);
const [retainedScheduledCondition, setRetainedScheduledCondition] = useState<ScheduledConditionItem | null>(null);
const [taskFilter, setTaskFilter] = useState<ConditionTaskFilterValue>(CONDITION_FILTER_ALL);
const [statusFilter, setStatusFilter] =
useState<ConditionStatusFilterValue>(CONDITION_FILTER_ALL);
const [taskCategory, setTaskCategory] = useState<WorkbenchTaskCategory>("scheduled");
const [taskCategory, setTaskCategory] = useState<WorkbenchTaskCategory>(() => {
const selected = conditions.find((condition) => condition.id === controlledSelectedConditionId);
return selected ? getWorkbenchTaskCategory(selected) ?? "scheduled" : "scheduled";
});
const [refreshing, setRefreshing] = useState(false);
const refreshingRef = useRef(false);
const mobileSheet = presentation === "mobile-sheet";
@@ -156,7 +162,14 @@ export function ScheduledConditionFeed({
() => groupConditionsByTaskCategory(conditions),
[conditions]
);
const categoryConditions = conditionsByCategory[taskCategory];
const categoryConditions = useMemo(() => {
const current = conditionsByCategory[taskCategory];
return taskCategory === "scheduled"
&& retainedScheduledCondition?.id === selectedConditionId
&& !current.some((condition) => condition.id === selectedConditionId)
? [...current, retainedScheduledCondition]
: current;
}, [conditionsByCategory, retainedScheduledCondition, selectedConditionId, taskCategory]);
const activeLoading = taskCategory === "scheduled" ? loading : backendLoading;
const { recentConditions: allRecentConditions, futureWorkOrders: allFutureWorkOrders } = useMemo(
() => groupScheduledConditions(categoryConditions),
@@ -244,11 +257,18 @@ export function ScheduledConditionFeed({
return;
}
// A controlled task selection survives panel remounts and detail-loading windows.
const selected = conditions.find((condition) => condition.id === controlledSelectedConditionId);
if (selected) {
const category = getWorkbenchTaskCategory(selected);
if (category && category !== taskCategory) { setTaskCategory(category); return; }
} else if (controlledSelectedConditionId && (loading || backendLoading)) return;
const nextSelectedConditionId = selectedCondition?.id ?? null;
if (selectedConditionId !== nextSelectedConditionId) {
updateSelectedConditionId(nextSelectedConditionId);
}
}, [expanded, selectedCondition?.id, selectedConditionId, updateSelectedConditionId]);
}, [backendLoading, conditions, controlledSelectedConditionId, expanded, loading, selectedCondition?.id, selectedConditionId, taskCategory, updateSelectedConditionId]);
useEffect(() => {
if (!focusConditionId || focusRequestId === undefined) {
@@ -268,6 +288,7 @@ export function ScheduledConditionFeed({
setTaskCategory(targetCategory);
setTaskFilter(CONDITION_FILTER_ALL);
setStatusFilter(CONDITION_FILTER_ALL);
setRetainedScheduledCondition(targetCategory === "scheduled" ? targetCondition : null);
updateSelectedConditionId(targetCondition.id);
setFocusedAction(focusRequest?.actionId ? { conditionId: targetCondition.id, actionId: focusRequest.actionId } : null);
updateExpanded(true);
@@ -293,6 +314,7 @@ export function ScheduledConditionFeed({
setTaskCategory(nextCategory);
setTaskFilter(CONDITION_FILTER_ALL);
setStatusFilter(CONDITION_FILTER_ALL);
setRetainedScheduledCondition(null);
updateSelectedConditionId(
sortConditionsNewestFirst(conditionsByCategory[nextCategory])[0]?.id ?? null
);
@@ -329,6 +351,11 @@ export function ScheduledConditionFeed({
}
function handleSelectTimelineCondition(conditionId: string) {
if (taskCategory === "scheduled") {
setRetainedScheduledCondition(
categoryConditions.find((condition) => condition.id === conditionId) ?? null
);
}
updateSelectedConditionId(conditionId);
if (!expanded) {
updateExpanded(true);
@@ -521,6 +548,7 @@ export function ScheduledConditionFeed({
<ConditionDetailSkeleton />
) : expanded && selectedCondition ? (
<ConditionDetailPanel
onLocate={onLocate}
condition={selectedCondition}
dataSource={dataSource}
workOrderDataSource={workOrderDataSource}
@@ -46,8 +46,10 @@ import {
type WorkbenchMeasureUnit
} from "../hooks/use-workbench-measurement";
import { waterNetworkToolbarItems } from "../map/toolbar-config";
import type { WorkbenchGeocoding } from "../hooks/use-workbench-geocoding";
import { GeocodingPanel } from "./geocoding-panel";
export type ToolbarToolId = "layers" | "measure" | "analysis" | "annotation" | "more";
export type ToolbarToolId = "geocoding" | "layers" | "measure" | "analysis" | "annotation" | "more";
export type ExportViewPreset = "current" | "4k";
type MeasureModeOption = {
@@ -95,6 +97,7 @@ const DRAW_TOOL_OPTIONS: Array<{
];
export type ToolbarPanelProps = {
geocoding: WorkbenchGeocoding;
activeToolId: ToolbarToolId | null;
activeMeasureModeId: WorkbenchMeasureMode;
activeMeasureUnitId: WorkbenchMeasureUnit;
@@ -136,6 +139,7 @@ export type ToolbarPanelProps = {
};
export function ToolbarPanel({
geocoding,
activeToolId,
activeMeasureModeId,
activeMeasureUnitId,
@@ -225,6 +229,19 @@ export function ToolbarPanel({
const activeTool = waterNetworkToolbarItems.find((item) => item.id === renderedToolId);
const Icon = activeTool?.icon ?? Layers3;
if (renderedToolId === "geocoding") {
return (
<MapControlPanel
key="geocoding" title="地址定位" description="奉贤区 · 天地图地名搜索" icon={Icon}
widthClassName={`${panelWidthClassName ?? "w-[324px] max-h-[calc(100dvh-13.25rem)]"} flex flex-col overflow-hidden`}
contentClassName="flex min-h-0 flex-1 flex-col"
visible={panelVisible}
>
<GeocodingPanel geocoding={geocoding} />
</MapControlPanel>
);
}
if (renderedToolId === "layers") {
return (
<MapControlPanel
@@ -152,7 +152,7 @@ export function AlertMenu({
"border-transparent bg-[color-mix(in_oklch,var(--status-soft),var(--status-border)_45%)] text-[var(--status-foreground)] [@media(hover:hover)]:hover:border-[var(--status-border)] [@media(hover:hover)]:hover:bg-[color-mix(in_oklch,var(--status-soft),var(--status-border)_75%)] [@media(hover:hover)]:hover:text-[var(--status-foreground)] data-[state=open]:border-transparent data-[state=open]:bg-[color-mix(in_oklch,var(--status-soft),var(--status-border)_75%)] data-[state=open]:text-[var(--status-foreground)]",
compact && compactHeaderControlButtonClassName
)}
aria-label={`查看异常处置面板,当前 ${alerts.length} 条待复核工况`}
aria-label={`查看待办与告警,当前 ${alerts.length} 条待处理事项`}
>
<span
className={cn(
@@ -164,7 +164,7 @@ export function AlertMenu({
>
<Bell size={14} aria-hidden="true" />
</span>
<span className={compact ? "sr-only" : compactHeaderTextClassName}>异常处置</span>
<span className={compact ? "sr-only" : compactHeaderTextClassName}>待办与告警</span>
{compact ? (
<StatusBadge
tone={alertSummaryTone}
@@ -193,7 +193,7 @@ export function AlertMenu({
sourceLabel={sourceLabel}
tone={hasAlerts ? alertSummaryTone : "success"}
/>
<MenuSectionLabel>待复核工况</MenuSectionLabel>
<MenuSectionLabel>待处理事项</MenuSectionLabel>
<div className="scheduled-feed-scroll max-h-[min(52dvh,420px)] overflow-y-auto pr-1">
{hasAlerts ? alerts.map((alert) => {
const tone = alert.severity === "warning" ? "warning" : "danger";
@@ -228,8 +228,8 @@ export function AlertMenu({
<SemanticStatusIcon tone="success" size="md" className="mx-auto">
<CheckCircle2 size={16} />
</SemanticStatusIcon>
<p className="mt-1 text-xs font-semibold text-slate-700">当前无待复核工况</p>
<p className="mt-0.5 text-xs leading-4 text-[var(--ink-muted)]">新的工况提醒会进入这里。</p>
<p className="mt-1 text-xs font-semibold text-slate-700">当前无待处理事项</p>
<p className="mt-0.5 text-xs leading-4 text-[var(--ink-muted)]">新的待办和告警会进入这里。</p>
</div>
</div>
)}
@@ -349,15 +349,15 @@ function AlertQueueSummary({
{hasAlerts ? <Bell size={16} /> : <CheckCircle2 size={16} />}
</SemanticStatusIcon>
<div className="min-w-0">
<h2 className="truncate text-sm font-semibold leading-5 text-slate-950">异常处置</h2>
<h2 className="truncate text-sm font-semibold leading-5 text-slate-950">待办与告警</h2>
<p className="mt-0.5 text-xs leading-5 text-[var(--ink-muted)]">
{hasAlerts ? `${count} 条需复核${latestAlertTime ? ` · 最近 ${latestAlertTime}` : ""}` : "当前无待复核工况"}
{hasAlerts ? `${count} 条待处理${latestAlertTime ? ` · 最近 ${latestAlertTime}` : ""}` : "当前无待处理事项"}
</p>
<p className="mt-0.5 text-xs leading-4 text-[var(--ink-muted)]">来源:{sourceLabel}</p>
<p className="mt-0.5 text-xs leading-4 text-[var(--ink-muted)]">业务告警来源:{sourceLabel}</p>
</div>
</div>
<StatusBadge tone={tone}>
{hasAlerts ? "需复核" : "正常"}
{hasAlerts ? "待处理" : "暂无待办"}
</StatusBadge>
</div>
<div className="mt-2 flex items-center gap-1.5 text-xs leading-4 text-[var(--ink-muted)]">
@@ -264,7 +264,7 @@ export function WorkbenchTopBar({
function getAlertSourceLabel(source: WorkbenchAlertDataSource) {
if (source === "backend") return "后端运行态";
if (source === "mock") return "工况演示回退";
return "未配置告警源";
return "未配置";
}
function getAnalysisOriginLabel(origin: AnalysisArtifact["origin"]) {
@@ -2,6 +2,19 @@ import { describe, expect, it } from "vitest";
import { applySessionStreamEvent, toAgentChatMessages, toAgentUiMessages } from "./agent-session-message-state";
describe("task context in persisted answers", () => {
it("inserts consumed user input once before the active assistant in live and resumed streams", () => {
const messages = toAgentUiMessages([
{ id: "original", role: "user", parts: [{ type: "text", text: "原问题" }] },
{ id: "answer", role: "assistant", parts: [{ type: "text", text: "处理中" }] }
]);
const data = { id: "new-input", text: "追加线索", delivery: "steer", status: "pending" };
expect(applySessionStreamEvent(messages, { type: "user_input", data })).toBe(messages);
const delivered = { type: "user_input" as const, data: { ...data, status: "delivered" } };
const next = applySessionStreamEvent(messages, delivered);
expect(next.map(({ id }) => id)).toEqual(["original", "new-input", "answer"]);
expect(applySessionStreamEvent(next, delivered)).toBe(next);
});
it("keeps the report reference of each answer when history is restored", () => {
const reference = {
task_id: "22222222-2222-4222-8222-222222222222",
@@ -19,10 +19,14 @@ import {
} from "@/features/agent";
type AgentUiDataParts = {
agent_message: Record<string, unknown>;
task_context: Record<string, unknown>;
execution_plan: Record<string, unknown>;
progress: Record<string, unknown>;
final_answer: Record<string, unknown>;
credential_refresh_required: Record<string, unknown>;
credential_refreshed: Record<string, unknown>;
credential_refresh_failed: Record<string, unknown>;
todo_update: Record<string, unknown>;
permission_request: Record<string, unknown>;
permission_response: Record<string, unknown>;
@@ -35,6 +39,7 @@ type AgentUiDataParts = {
answer_ui: Record<string, unknown>;
ui_envelope: Record<string, unknown>;
session_title: Record<string, unknown>;
user_input: Record<string, unknown>;
};
export type AgentUiMessage = UIMessage<unknown, AgentUiDataParts>;
@@ -200,6 +205,15 @@ export function applySessionStreamEvent(messages: AgentUiMessage[], event: Agent
return toAgentUiMessages(event.messages);
}
if (event.type === "user_input") {
const id = getDataString(event.data, "id");
const text = getDataString(event.data, "text");
if (event.data.status !== "delivered" || !id || !text || messages.some((message) => message.id === id)) return messages;
const input: AgentUiMessage = { id, role: "user", parts: [{ type: "text", text }] };
const tail = messages.at(-1);
return tail?.role === "assistant" ? [...messages.slice(0, -1), input, tail] : [...messages, input];
}
if (event.type === "token") {
const token = getDataString(event.data, "content");
return token ? updateLastAssistantUiMessage(messages, (message) => appendTextToUiMessage(message, token)) : messages;
@@ -0,0 +1,142 @@
import { describe, expect, it, vi } from "vitest";
import type { InvestigationPresentationCommand, InvestigationPresentationCursor } from "../api/investigation-presentation";
import type { WorkbenchEvent } from "../api/workbench-sync-client";
import { InvestigationPresentationFollower } from "./investigation-presentation-follower";
import { WorkbenchEventChannel } from "./workbench-event-channel";
const taskId = "50000000-0000-4000-8000-000000000001";
const runId = "55000000-0000-4000-8000-000000000001";
const nextRunId = "55000000-0000-4000-8000-000000000002";
const highlight: InvestigationPresentationCommand = { action: "highlight_features", layer: "scada", feature_ids: ["MP01"], effect: "pulse" };
const openChart: InvestigationPresentationCommand = {
action: "open_chart", chart_id: "pressure", evidence_refs: ["reading-1"],
chart: { title: "压力", chart_type: "line", x_data: ["10:00"], series: [{ name: "MP01", data: [0.4] }] }
};
const closeChart: InvestigationPresentationCommand = { action: "close_chart", chart_id: "pressure" };
function cursor(eventCursor: number, generation = 1): InvestigationPresentationCursor {
return { task_id: taskId, execution_generation: generation, event_cursor: eventCursor,
active_run: { run_id: generation === 1 ? runId : nextRunId, generation, status: "running" } };
}
function event(sequence: number, command = highlight, generation = 1): WorkbenchEvent {
return { schema: "workbench-event@1", event_seq: sequence, event_id: "60000000-0000-4000-8000-000000000001",
project_id: "10000000-0000-4000-8000-000000000001", topic: "operational.presentation.requested",
aggregate_type: "operational_presentation", aggregate_id: taskId, aggregate_revision: 1,
occurred_at: "2026-10-10T01:00:00Z", metadata: {
task_id: taskId, run_id: generation === 1 ? runId : nextRunId, execution_generation: generation, command
} };
}
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => { resolve = done; });
return { promise, resolve };
}
function setup(overrides: Partial<ConstructorParameters<typeof InvestigationPresentationFollower>[0]> = {}) {
const channel = new WorkbenchEventChannel();
const execute = vi.fn(async (_command: InvestigationPresentationCommand, _signal: AbortSignal) => {});
const fetchCursor = vi.fn(async (_signal: AbortSignal) => cursor(10));
const fetchIdentity = vi.fn(async (_signal: AbortSignal) => cursor(10));
const clear = vi.fn();
const options = { taskId, subscribe: channel.subscribe, execute, fetchCursor, fetchIdentity,
clear, stopPulse: vi.fn(), onStatus: vi.fn() };
const follower = new InvestigationPresentationFollower({ ...options, ...overrides });
return { channel, follower, ...options };
}
describe("live investigation presentation following", () => {
it("listens before fetching the baseline and preserves events arriving during cursor lookup", async () => {
const baseline = deferred<InvestigationPresentationCursor>();
const order: string[] = [];
const test = setup();
test.channel.publish({ type: "open" });
const fetchCursor = vi.fn(() => {
order.push("cursor");
test.channel.publish({ type: "events", events: [event(8), event(11)] });
return baseline.promise;
});
const follower = new InvestigationPresentationFollower({ ...test,
subscribe: (listener) => { order.push("listen"); return test.channel.subscribe(listener); }, fetchCursor });
follower.start();
try {
expect(order).toEqual(["listen", "cursor"]);
expect(test.execute).not.toHaveBeenCalled();
baseline.resolve(cursor(10));
await vi.waitFor(() => expect(test.execute).toHaveBeenCalledTimes(1));
expect(test.execute.mock.calls[0]?.[0]).toEqual(highlight);
} finally { follower.stop(); }
});
it("executes same-batch chart commands in sequence and waits for open before close", async () => {
const painted = deferred<void>();
const execute = vi.fn((command: InvestigationPresentationCommand) => command.action === "open_chart" ? painted.promise : Promise.resolve());
const test = setup({ execute });
test.follower.start();
test.channel.publish({ type: "open" });
try {
await vi.waitFor(() => expect(test.onStatus).toHaveBeenCalledWith("live"));
test.channel.publish({ type: "events", events: [event(12, closeChart), event(11, openChart)] });
expect(execute.mock.calls.map(([command]) => command.action)).toEqual(["open_chart"]);
painted.resolve();
await vi.waitFor(() => expect(execute.mock.calls.map(([command]) => command.action)).toEqual(["open_chart", "close_chart"]));
} finally { painted.resolve(); test.follower.stop(); }
});
it("verifies a new generation without losing its first command or fetching a new baseline", async () => {
const fetchIdentity = vi.fn(async () => cursor(10, 2));
const test = setup({ fetchIdentity });
test.follower.start();
test.channel.publish({ type: "open" });
try {
await vi.waitFor(() => expect(test.onStatus).toHaveBeenCalledWith("live"));
test.channel.publish({ type: "events", events: [event(11, highlight, 2)] });
await vi.waitFor(() => expect(test.execute).toHaveBeenCalledTimes(1));
expect(fetchIdentity).toHaveBeenCalledTimes(1);
expect(test.fetchCursor).toHaveBeenCalledTimes(1);
expect(test.execute.mock.calls[0]?.[0]).toEqual(highlight);
} finally { test.follower.stop(); }
});
it("retains a command while identity lookup is stale and applies it once the task snapshot catches up", async () => {
const test = setup();
test.follower.start();
test.channel.publish({ type: "open" });
try {
await vi.waitFor(() => expect(test.onStatus).toHaveBeenCalledWith("live"));
test.channel.publish({ type: "events", events: [event(11, highlight, 2)] });
await vi.waitFor(() => expect(test.fetchIdentity).toHaveBeenCalledTimes(1));
expect(test.execute).not.toHaveBeenCalled();
test.follower.updateIdentity(cursor(10, 2));
await vi.waitFor(() => expect(test.execute).toHaveBeenCalledTimes(1));
expect(test.fetchCursor).toHaveBeenCalledTimes(1);
} finally { test.follower.stop(); }
});
it("aborts on disconnect and uses a fresh reconnect baseline without replaying old commands", async () => {
const oldDisplay = deferred<void>();
const execute = vi.fn(async (_command: InvestigationPresentationCommand, _signal: AbortSignal) => {})
.mockImplementationOnce(() => oldDisplay.promise);
const fetchCursor = vi.fn(async () => cursor(10)).mockResolvedValueOnce(cursor(10)).mockResolvedValueOnce(cursor(20));
const test = setup({ execute, fetchCursor });
test.follower.start();
test.channel.publish({ type: "open" });
try {
await vi.waitFor(() => expect(test.onStatus).toHaveBeenCalledWith("live"));
test.channel.publish({ type: "events", events: [event(11)] });
const oldSignal = execute.mock.calls[0]?.[1];
test.channel.publish({ type: "disconnect" });
expect(oldSignal?.aborted).toBe(true);
const clearCount = test.clear.mock.calls.length;
test.channel.publish({ type: "events", events: [event(12, openChart)] });
test.channel.publish({ type: "open" });
await vi.waitFor(() => expect(fetchCursor).toHaveBeenCalledTimes(2));
test.channel.publish({ type: "events", events: [event(12, openChart), event(20, openChart), event(21, closeChart)] });
oldDisplay.resolve();
await vi.waitFor(() => expect(execute.mock.calls.map(([command]) => command.action)).toEqual(["highlight_features", "close_chart"]));
expect(test.clear.mock.calls.length).toBeGreaterThan(clearCount);
expect(execute.mock.calls[1]?.[1].aborted).toBe(false);
} finally { oldDisplay.resolve(); test.follower.stop(); }
});
});
@@ -0,0 +1,162 @@
import {
investigationPresentationEventSchema,
type InvestigationPresentationCommand,
type InvestigationPresentationCursor,
type InvestigationPresentationEvent
} from "../api/investigation-presentation";
import type { WorkbenchEvent } from "../api/workbench-sync-client";
import type { WorkbenchStreamNotification } from "./workbench-event-channel";
export type InvestigationIdentity = Pick<InvestigationPresentationCursor, "task_id" | "execution_generation" | "active_run">;
type Options = {
taskId: string;
subscribe: (listener: (notification: WorkbenchStreamNotification) => void) => () => void;
fetchCursor: (signal: AbortSignal) => Promise<InvestigationPresentationCursor>;
fetchIdentity: (signal: AbortSignal) => Promise<InvestigationIdentity>;
execute: (command: InvestigationPresentationCommand, signal: AbortSignal) => Promise<void>;
clear: () => void;
stopPulse: () => void;
onStatus: (status: "connecting" | "live" | "disconnected" | "error", error?: string) => void;
};
type Pending = { sequence: number; event: InvestigationPresentationEvent };
/** No replay: a fresh authorized baseline is taken after every connection, with the listener already attached. */
export class InvestigationPresentationFollower {
private unsubscribe?: () => void;
private binding: AbortController | null = null;
private work = new AbortController();
private identity: InvestigationIdentity | null = null;
private baseline: number | null = null;
private lastSeen = 0;
private pending: Pending[] = [];
private draining = false;
private epoch = 0;
private lookupKey: string | null = null;
constructor(private options: Options) {}
start() { this.unsubscribe = this.options.subscribe((notification) => this.receive(notification)); }
stop() {
this.unsubscribe?.();
this.unsubscribe = undefined;
this.disconnect();
}
updateIdentity(identity: InvestigationIdentity) {
if (identity.task_id !== this.options.taskId || this.identity && identity.execution_generation < this.identity.execution_generation) return;
const changed = this.identity && (identity.execution_generation !== this.identity.execution_generation
|| identity.active_run && this.identity.active_run && identity.active_run.run_id !== this.identity.active_run.run_id);
if (changed) {
this.work.abort();
this.work = new AbortController();
this.options.clear();
}
// A finished run may no longer be active; retain its identity to finish already received close commands.
this.identity = identity.active_run || !this.identity || identity.execution_generation !== this.identity.execution_generation
? identity : { ...identity, active_run: this.identity.active_run ? { ...this.identity.active_run, status: "succeeded" } : null };
if (!identity.active_run || identity.active_run.status !== "running") this.options.stopPulse();
void this.drain();
}
private receive(notification: WorkbenchStreamNotification) {
if (notification.type === "open") { void this.connect(); return; }
if (notification.type !== "events") { this.disconnect(); return; }
if (!this.binding) return;
for (const event of notification.events) this.add(event);
void this.drain();
}
private add(event: WorkbenchEvent) {
if (!this.binding) return;
if (event.topic !== "operational.presentation.requested" || event.metadata.task_id !== this.options.taskId) return;
if (event.event_seq <= (this.baseline ?? -1) || event.event_seq <= this.lastSeen || this.pending.some((item) => item.sequence === event.event_seq)) return;
const parsed = investigationPresentationEventSchema.safeParse(event.metadata);
if (!parsed.success) { this.options.onStatus("error", "调查展示参数未通过校验"); return; }
if (this.pending.length >= 1000) { this.disconnect(); this.options.onStatus("error", "调查展示积压,请关闭并重新开启跟随"); return; }
this.pending.push({ sequence: event.event_seq, event: parsed.data });
this.pending.sort((a, b) => a.sequence - b.sequence);
}
private async connect() {
this.disconnect();
const epoch = this.epoch;
const controller = new AbortController();
this.binding = controller;
this.options.onStatus("connecting");
try {
const cursor = await this.options.fetchCursor(controller.signal);
if (controller.signal.aborted || epoch !== this.epoch) return;
if (cursor.task_id !== this.options.taskId) throw new Error("调查游标与所选任务不一致");
this.baseline = cursor.event_cursor;
this.lastSeen = cursor.event_cursor;
this.pending = this.pending.filter((item) => item.sequence > cursor.event_cursor);
this.updateIdentity(cursor);
this.options.onStatus("live");
void this.drain();
} catch (error) {
if (!controller.signal.aborted) { this.disconnect(); this.options.onStatus("error", error instanceof Error ? error.message : "调查展示连接失败"); }
}
}
private disconnect() {
this.epoch += 1;
this.binding?.abort();
this.binding = null;
this.work.abort();
this.work = new AbortController();
this.identity = null;
this.baseline = null;
this.lastSeen = 0;
this.lookupKey = null;
this.pending = [];
this.options.clear();
this.options.onStatus("disconnected");
}
private async resolveIdentity(event: InvestigationPresentationEvent) {
const key = `${event.execution_generation}:${event.run_id}`;
if (this.lookupKey === key || !this.binding) return;
this.lookupKey = key;
const binding = this.binding;
try {
const identity = await this.options.fetchIdentity(binding.signal);
if (!binding.signal.aborted) this.updateIdentity(identity);
} catch (error) {
if (!binding.signal.aborted) this.options.onStatus("error", error instanceof Error ? error.message : "调查运行状态核验失败");
}
}
private async drain() {
if (this.draining || this.baseline === null || !this.identity) return;
this.draining = true;
const epoch = this.epoch;
try {
while (this.pending.length && epoch === this.epoch) {
const next = this.pending[0];
if (next.sequence <= this.lastSeen || next.event.execution_generation < this.identity!.execution_generation) { this.pending.shift(); continue; }
if (next.event.execution_generation !== this.identity!.execution_generation || next.event.run_id !== this.identity!.active_run?.run_id) {
await this.resolveIdentity(next.event);
if (epoch !== this.epoch) break;
if (next.event.execution_generation !== this.identity!.execution_generation || next.event.run_id !== this.identity!.active_run?.run_id) break;
}
this.pending.shift();
this.lastSeen = next.sequence;
const signal = this.work.signal;
try {
const command = next.event.command.action === "highlight_features" && this.identity!.active_run?.status !== "running"
? { ...next.event.command, effect: "steady" as const } : next.event.command;
await this.options.execute(command, signal);
if (!signal.aborted && epoch === this.epoch) this.options.onStatus("live");
if (this.identity!.active_run?.status !== "running") this.options.stopPulse();
} catch (error) {
if (!signal.aborted && epoch === this.epoch) this.options.onStatus("error", error instanceof Error ? error.message : "调查展示失败");
}
}
} finally {
this.draining = false;
// A reconnect can establish a new baseline while an old asynchronous command is aborting.
if (epoch !== this.epoch && this.baseline !== null && this.pending.length) void this.drain();
}
}
}
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { collectRecentAgentActivities } from "./use-agent-prompt-suggestions";
import type { AgentChatSessionSummary } from "@/features/agent";
describe("recent activities for Agent suggestions", () => {
it("uses updated ordinary conversations, excludes stale/private task sessions, and preserves real versus demo origins", () => {
const now = Date.parse("2026-10-09T08:00:00Z");
const session = (id: string, updatedAt: number, purpose: AgentChatSessionSummary["purpose"]): AgentChatSessionSummary => ({
id, title: id, purpose, createdAt: now - 10 * 24 * 60 * 60_000, updatedAt,
});
const activities = collectRecentAgentActivities({
alerts: [
{ id: "demo", title: "演示压力告警", description: "演示", time: "15:00", occurredAt: "2026-10-09T07:00:00Z" },
{ id: "operational-task-notification:real", title: "现场核查待办", description: "真实后台任务", time: "15:05", occurredAt: "2026-10-09T07:05:00Z" },
],
alertDataSource: "mock", conditions: [], runtimeDataSource: "mock", artifact: null,
sessions: [session("updated-old-conversation", now - 60_000, "ordinary_chat"), session("stale", now - 8 * 24 * 60 * 60_000, "ordinary_chat"), session("task-private", now, "task_collaboration")],
}, now);
expect(activities.find((item) => item.id === "alert:demo")?.origin).toBe("demo");
expect(activities.find((item) => item.id === "alert:operational-task-notification:real")?.origin).toBe("backend");
expect(activities.filter((item) => item.kind === "session").map((item) => item.title)).toEqual(["updated-old-conversation"]);
});
});
@@ -0,0 +1,81 @@
import { useEffect, useMemo, useRef, useState } from "react";
import type { AgentApiClient, AgentChatSessionSummary, AgentPromptSuggestion, AgentRecentActivity } from "@/features/agent";
import type { WorkbenchAlert, ScheduledConditionItem, WorkbenchAlertDataSource, WorkbenchRuntimeDataSource } from "../types";
import type { AnalysisArtifact } from "../workspace/workspace-model";
type ActivityInputs = {
alerts: WorkbenchAlert[];
alertDataSource: WorkbenchAlertDataSource;
conditions: ScheduledConditionItem[];
runtimeDataSource: WorkbenchRuntimeDataSource;
artifact: AnalysisArtifact | null;
sessions: AgentChatSessionSummary[];
};
const activityLabels = { alert: "告警", task: "任务", analysis: "分析成果", session: "近期会话" } as const;
const timeFormatter = new Intl.DateTimeFormat("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false });
type DisplaySuggestion = AgentPromptSuggestion & { sourceLabel: string };
export function collectRecentAgentActivities(input: ActivityInputs, now = Date.now()): AgentRecentActivity[] {
const origin = (source: string) => source === "mock" || source === "demo" ? "demo" as const : "backend" as const;
const validDate = (time: string | number) => Number.isFinite(new Date(time).getTime());
const alerts: AgentRecentActivity[] = input.alerts.filter((item) => item.occurredAt && validDate(item.occurredAt))
.sort((a, b) => Date.parse(b.occurredAt!) - Date.parse(a.occurredAt!)).slice(0, 4).map((item) => ({
id: `alert:${item.id}`, kind: "alert", title: item.title.slice(0, 160), occurred_at: new Date(item.occurredAt!).toISOString(),
summary: item.description.slice(0, 600), status: item.severity,
origin: item.id.startsWith("operational-task-notification:") ? "backend" : origin(input.alertDataSource),
}));
const tasks: AgentRecentActivity[] = input.conditions.filter((item) => validDate(item.updatedAt))
.sort((a, b) => b.updatedAt - a.updatedAt).slice(0, 3).map((item) => ({
id: `task:${item.id}`, kind: "task", title: item.title.slice(0, 160), occurred_at: new Date(item.updatedAt).toISOString(),
summary: item.summary.slice(0, 600), status: item.status,
origin: origin(item.kind === "condition" ? item.dataSource ?? input.runtimeDataSource : input.runtimeDataSource),
}));
const artifact = input.artifact;
const analyses: AgentRecentActivity[] = artifact && validDate(artifact.generatedAt) ? [{
id: `analysis:${artifact.id}`, kind: "analysis", title: artifact.title.slice(0, 160), occurred_at: new Date(artifact.generatedAt).toISOString(),
summary: artifact.summary.slice(0, 600), origin: artifact.origin ?? "backend",
}] : [];
const sessions: AgentRecentActivity[] = input.sessions.filter((item) => item.purpose === "ordinary_chat" && item.title.trim() !== "新对话" &&
validDate(item.updatedAt) && item.updatedAt >= now - 7 * 24 * 60 * 60_000 && item.updatedAt <= now)
.sort((a, b) => b.updatedAt - a.updatedAt).slice(0, 4).map((item) => ({
id: `session:${item.id}`, kind: "session", title: item.title.slice(0, 160), occurred_at: new Date(item.updatedAt).toISOString(), origin: "backend",
}));
return [...new Map([...alerts, ...tasks, ...analyses, ...sessions].map((item) => [item.id, item])).values()];
}
export function useAgentPromptSuggestions({ enabled, model, sessionId, request, alerts, alertDataSource, conditions, runtimeDataSource, artifact, sessions }: ActivityInputs & {
enabled: boolean;
model: string;
sessionId: string | null;
request: AgentApiClient["generatePromptSuggestions"];
}) {
const activities = useMemo(() => collectRecentAgentActivities({ alerts, alertDataSource, conditions, runtimeDataSource, artifact, sessions }),
[alerts, alertDataSource, conditions, runtimeDataSource, artifact, sessions]);
const snapshot = useRef(activities);
useEffect(() => { snapshot.current = activities; }, [activities]);
const key = JSON.stringify([enabled, model, sessionId]);
const [state, setState] = useState<{ key: string; suggestions: DisplaySuggestion[]; loading: boolean; unavailable: boolean }>({
key: "", suggestions: [], loading: false, unavailable: false,
});
useEffect(() => {
if (!enabled) return;
const controller = new AbortController();
const context = snapshot.current;
setState({ key, suggestions: [], loading: true, unavailable: false });
void request(context, model, controller.signal).then((suggestions) => {
if (controller.signal.aborted) return;
const sourceById = new Map(context.map((item) => [item.id, item]));
setState({ key, loading: false, unavailable: false, suggestions: suggestions.map((item) => {
const source = item.activityId ? sourceById.get(item.activityId) : undefined;
return { ...item, sourceLabel: source
? `${source.origin === "demo" ? "演示" : ""}${activityLabels[source.kind]} · ${timeFormatter.format(new Date(source.occurred_at))}`
: "供水任务建议" };
}) });
}).catch(() => {
if (!controller.signal.aborted) setState({ key, suggestions: [], loading: false, unavailable: true });
});
return () => controller.abort();
}, [enabled, key, model, request]);
return state.key === key ? state : { suggestions: [], loading: enabled, unavailable: false };
}
@@ -0,0 +1,149 @@
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
import type { FeatureCollection } from "geojson";
import type { Map as MapLibreMap, PaddingOptions } from "maplibre-gl";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import { fetchOperationalTask, fetchOperationalTaskPresentationCursor, type OperationalTask } from "../api/operational-task-client";
import type { InvestigationMapTarget } from "../api/investigation-report";
import type { InvestigationOverlay, InvestigationPresentationCommand } from "../api/investigation-presentation";
import { InvestigationMapPresentation } from "../map/investigation-map-presentation";
import type { WorkbenchMapController } from "../map/workbench-map-controller";
import type { WorkbenchSyncState } from "./use-workbench-sync";
import { InvestigationPresentationFollower } from "./investigation-presentation-follower";
export type InvestigationChart = Extract<InvestigationPresentationCommand, { action: "open_chart" }>;
type Options = {
enabled: boolean; task: OperationalTask | null; projectId: string | null;
sync: WorkbenchSyncState; getAccessToken?: AccessTokenProvider;
mapRef: RefObject<MapLibreMap | null>; mapReady: boolean;
controller: WorkbenchMapController; getPadding: () => PaddingOptions;
onClearLayers: () => void;
};
function frame(signal: AbortSignal) {
return new Promise<void>((resolve, reject) => {
signal.throwIfAborted();
const abort = () => { cancelAnimationFrame(id); reject(signal.reason); };
const id = requestAnimationFrame(() => { signal.removeEventListener("abort", abort); resolve(); });
signal.addEventListener("abort", abort, { once: true });
});
}
/** Separate commands need a rendered frame; an open and close in one SSE batch must not collapse into one React update. */
async function waitForChart(chartId: string, signal: AbortSignal) {
const deadline = Date.now() + 5000;
while (Date.now() < deadline) {
await frame(signal);
const element = [...document.querySelectorAll<HTMLElement>("[data-investigation-chart]")].find((item) => item.dataset.investigationChart === chartId);
if (element && element.getBoundingClientRect().height > 0) {
const visibleUntil = Date.now() + 500;
while (Date.now() < visibleUntil) await frame(signal);
return;
}
}
throw new Error("图表当前不可见");
}
export function useInvestigationPresentation(options: Options) {
const valuesRef = useRef(options);
valuesRef.current = options;
const [charts, setCharts] = useState<InvestigationChart[]>([]);
const [overlay, setOverlay] = useState<InvestigationOverlay | null>(null);
const [cameraPaused, setCameraPaused] = useState(false);
const cameraPausedRef = useRef(false);
const [status, setStatus] = useState<"connecting" | "live" | "disconnected" | "error">("disconnected");
const [error, setError] = useState<string | null>(null);
const [highlightLayers, setHighlightLayers] = useState<string[]>([]);
const followerRef = useRef<InvestigationPresentationFollower | null>(null);
const manualRequestRef = useRef<AbortController | null>(null);
const scene = useMemo(() => new InvestigationMapPresentation(() => options.mapRef.current, () => valuesRef.current.getPadding()), [options.mapRef]);
const taskId = options.task?.task_id ?? null;
const subscribe = options.sync.subscribePresentationEvents;
const clear = useCallback(() => {
scene.clear(); setCharts([]); setOverlay(null); setHighlightLayers([]);
valuesRef.current.onClearLayers();
}, [scene]);
useEffect(() => {
manualRequestRef.current?.abort();
cameraPausedRef.current = false;
setCameraPaused(false);
if (!options.enabled || !taskId) { clear(); return; }
const follower = new InvestigationPresentationFollower({
taskId, subscribe,
fetchCursor: (signal) => fetchOperationalTaskPresentationCursor(taskId, options.getAccessToken, signal),
fetchIdentity: (signal) => fetchOperationalTask(taskId, options.getAccessToken, signal),
clear,
stopPulse: () => scene.stopPulse(),
onStatus: (next, message) => { setStatus(next); setError(message ?? null); },
execute: async (command, signal) => {
signal.throwIfAborted();
if (command.action === "highlight_features") {
const collection = await valuesRef.current.controller.queryPresentationFeatures(command.layer, command.feature_ids, signal);
signal.throwIfAborted();
scene.highlight(collection, command.effect === "pulse", !cameraPausedRef.current);
setHighlightLayers([command.layer]);
} else if (command.action === "clear_highlights") {
scene.clearHighlights(); setHighlightLayers([]);
} else if (command.action === "open_chart") {
setCharts((current) => [...current.filter((item) => item.chart_id !== command.chart_id).slice(-5), command]);
await waitForChart(command.chart_id, signal);
} else if (command.action === "close_chart") {
setCharts((current) => current.filter((item) => item.chart_id !== command.chart_id));
await frame(signal);
} else if (command.action === "set_overlay") {
const next = { ...command, visible: true };
scene.setOverlay(next, !cameraPausedRef.current); setOverlay(next);
} else {
scene.clearOverlay(command.overlay_id);
setOverlay((current) => current?.overlay_id === command.overlay_id ? null : current);
}
}
});
followerRef.current = follower;
follower.start();
return () => { follower.stop(); if (followerRef.current === follower) followerRef.current = null; };
}, [clear, options.enabled, options.getAccessToken, options.projectId, scene, subscribe, taskId]);
useEffect(() => {
if (options.task) followerRef.current?.updateIdentity(options.task);
}, [options.task]);
const pauseCamera = useCallback(() => {
cameraPausedRef.current = true; setCameraPaused(true);
}, []);
useEffect(() => {
const map = options.mapRef.current;
if (!map || !options.mapReady || !options.enabled) return;
const canvas = map.getCanvasContainer();
// Canvas input excludes programmatic fitBounds/easeTo and also covers touch/keyboard navigation.
canvas.addEventListener("pointerdown", pauseCamera);
canvas.addEventListener("wheel", pauseCamera, { passive: true });
canvas.addEventListener("keydown", pauseCamera);
return () => { canvas.removeEventListener("pointerdown", pauseCamera); canvas.removeEventListener("wheel", pauseCamera); canvas.removeEventListener("keydown", pauseCamera); };
}, [options.enabled, options.mapReady, options.mapRef, pauseCamera]);
useEffect(() => () => { manualRequestRef.current?.abort(); scene.destroy(); }, [scene]);
const locateTargets = useCallback(async (targets: InvestigationMapTarget[]) => {
manualRequestRef.current?.abort();
const controller = new AbortController(); manualRequestRef.current = controller;
pauseCamera();
const groups = new Map<InvestigationMapTarget["source_id"], string[]>();
for (const target of targets) groups.set(target.source_id, [...groups.get(target.source_id) ?? [], target.feature_id]);
const collections = await Promise.all([...groups].map(([source, ids]) => valuesRef.current.controller.queryPresentationFeatures(source, ids, controller.signal)));
controller.signal.throwIfAborted();
const data: FeatureCollection = { type: "FeatureCollection", features: collections.flatMap((collection) => collection.features) };
if (!data.features.length) throw new Error("未找到关联地图要素");
scene.highlight(data, false, true); setHighlightLayers([...groups.keys()]);
}, [pauseCamera, scene]);
return {
charts, overlay, cameraPaused, status, error, highlightLayers,
pauseCamera,
resumeCamera: () => { cameraPausedRef.current = false; setCameraPaused(false); },
locateTargets,
closeChart: (id: string) => setCharts((current) => current.filter((item) => item.chart_id !== id)),
toggleOverlay: () => { if (overlay) { scene.showOverlay(!overlay.visible); setOverlay({ ...overlay, visible: !overlay.visible }); } },
fitOverlay: () => { pauseCamera(); scene.fitOverlay(); }
};
}
@@ -40,6 +40,7 @@ export function collectOperationalTaskEventChanges(events: WorkbenchEvent[]) {
const deletedTaskIds = new Set<string>();
let notificationsChanged = false;
for (const event of events) {
if (event.topic === "operational.presentation.requested") continue;
if (event.topic === "operational.notification.changed") {
notificationsChanged = true;
continue;
@@ -0,0 +1,95 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { animate } from "motion/mini";
import { useReportUpdateMotion } from "./use-report-update-motion";
vi.mock("motion/mini", () => ({ animate: vi.fn() }));
function Reader({ revision = "1", scope = "task-1", live = true }) {
const ref = useReportUpdateMotion(scope, revision, live);
return <article ref={ref} aria-label="report"><header>调查报告</header><section><p>{revision}</p><details open><summary>依据</summary>{revision}</details></section></article>;
}
afterEach(() => { cleanup(); vi.unstubAllGlobals(); vi.restoreAllMocks(); });
function motion(reduced = false) {
const cancel = vi.fn();
vi.mocked(animate).mockReset();
vi.mocked(animate).mockReturnValue({ cancel } as unknown as ReturnType<typeof animate>);
vi.stubGlobal("matchMedia", vi.fn(() => ({ matches: reduced, addEventListener: vi.fn(), removeEventListener: vi.fn() })));
// jsdom does not provide Web Animations.
const attach = (element: HTMLElement) => Array.from(element.children)
.forEach((part) => Object.defineProperty(part, "animate", { value: vi.fn() }));
return { animate, cancel, attach };
}
describe("report update motion", () => {
it("animates only a new revision and keeps the reading DOM", () => {
const { animate, cancel, attach } = motion();
const { rerender, unmount } = render(<Reader />);
const section = screen.getByRole("article", { name: "report" });
attach(section);
const details = section.querySelector("details")!;
expect(animate).not.toHaveBeenCalled();
rerender(<Reader />);
expect(animate).not.toHaveBeenCalled();
rerender(<Reader revision="2" />);
expect(animate).toHaveBeenCalledTimes(2);
expect(animate).toHaveBeenNthCalledWith(1,
section.querySelector("header"),
{ opacity: [0, 1], translate: ["0 18px", "0 0"], scale: [0.98, 1] },
{ duration: 0.4, delay: 0, ease: [0.22, 1, 0.36, 1] }
);
expect(animate).toHaveBeenNthCalledWith(2, section.querySelector("section"), expect.any(Object), expect.objectContaining({ delay: 0.045 }));
expect(section.querySelector("details")).toBe(details);
expect(details.open).toBe(true);
rerender(<Reader revision="3" />);
expect(cancel).toHaveBeenCalledTimes(2);
unmount();
expect(cancel).toHaveBeenCalledTimes(4);
});
it("skips history, returning to live content, and task switches", () => {
const { animate, attach } = motion();
const { rerender } = render(<Reader />);
attach(screen.getByRole("article", { name: "report" }));
rerender(<Reader revision="2" live={false} />);
rerender(<Reader revision="3" />);
rerender(<Reader scope="task-2" revision="4" />);
expect(animate).not.toHaveBeenCalled();
});
it("respects reduced motion", () => {
const { animate, attach } = motion(true);
const { rerender } = render(<Reader />);
attach(screen.getByRole("article", { name: "report" }));
rerender(<Reader revision="2" />);
expect(animate).not.toHaveBeenCalled();
});
it("keeps report updates readable when matchMedia is unavailable", () => {
const { animate, attach } = motion();
vi.stubGlobal("matchMedia", undefined);
const { rerender } = render(<Reader />);
const section = screen.getByRole("article", { name: "report" });
attach(section);
rerender(<Reader revision="2" />);
expect(section).toHaveTextContent("2");
expect(animate).not.toHaveBeenCalled();
});
it("lets interaction interrupt the group and leaves focused content still", () => {
const { animate, cancel, attach } = motion();
const { rerender } = render(<Reader />);
const reader = screen.getByRole("article", { name: "report" });
attach(reader);
rerender(<Reader revision="2" />);
fireEvent.pointerDown(reader);
expect(cancel).toHaveBeenCalledTimes(2);
reader.querySelector("summary")!.focus();
vi.mocked(animate).mockClear();
rerender(<Reader revision="3" />);
expect(animate).toHaveBeenCalledTimes(1);
expect(animate).toHaveBeenCalledWith(reader.querySelector("header"), expect.any(Object), expect.any(Object));
});
});
@@ -0,0 +1,43 @@
import { useLayoutEffect, useRef } from "react";
import { animate } from "motion/mini";
/** Reveal new published content without remounting the reader or animating heartbeats. */
export function useReportUpdateMotion(scope: string, revision: string, live: boolean) {
const ref = useRef<HTMLElement>(null);
const previous = useRef({ scope, revision, live });
useLayoutEffect(() => {
const before = previous.current;
previous.current = { scope, revision, live };
if (!live || !before.live || before.scope !== scope || before.revision === revision) return;
const reader = ref.current;
if (!reader || typeof window.matchMedia !== "function") return;
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
if (reducedMotion.matches) return;
// Replay the Bento reveal across the report, not a browser/page reload.
// Direct children only: portal details and Mermaid's internal DOM stay intact.
const parts = Array.from(reader.querySelectorAll<HTMLElement>(":scope > header, :scope > section, :scope > p"))
.filter((part) => typeof part.animate === "function" && !part.contains(document.activeElement));
const animations = parts.map((part, index) => {
const original = { opacity: part.style.opacity, translate: part.style.translate, scale: part.style.scale };
// Individual transforms leave the expand-card shared-layout transform alone.
const animation = animate(part, { opacity: [0, 1], translate: ["0 18px", "0 0"], scale: [0.98, 1] }, {
duration: 0.4, delay: Math.min(index, 6) * 0.045, ease: [0.22, 1, 0.36, 1]
});
return () => { animation.cancel(); Object.assign(part.style, original); };
});
const cancel = () => animations.forEach((restore) => restore());
// Interaction wins over the reveal, including expanding a shared-layout card.
reader.addEventListener("pointerdown", cancel, true);
reader.addEventListener("focusin", cancel);
reducedMotion.addEventListener("change", cancel);
return () => {
reader.removeEventListener("pointerdown", cancel, true);
reader.removeEventListener("focusin", cancel);
reducedMotion.removeEventListener("change", cancel);
cancel();
};
}, [scope, revision, live]);
return ref;
}
@@ -1,13 +1,13 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { ResponsesChatTransport, createResponseRequest } from "@/features/agent";
import useSWR from "swr";
import useSWRImmutable from "swr/immutable";
import type { PersonaState } from "@/shared/ai-elements/persona";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import { env } from "@/shared/config/env";
import { showMapNotice } from "@/features/map/core";
import { findOwnedWorkbenchProject } from "../api/project-client";
import type { InvestigationDiscussion } from "../api/investigation-report";
import {
FrontendActionExecutor,
agentTaskDiscussionContextSchema,
@@ -15,8 +15,10 @@ import {
agentBootstrapKey,
agentSessionsKey,
createAgentApiClient,
useAgentMessageInbox,
fetchAgentBootstrap,
fetchAgentSessions,
resolveAgentModelSelection,
hasPendingAgentInput,
isUiEnvelopeAllowed,
parseUiEnvelopePayload,
@@ -25,6 +27,7 @@ import {
type AgentConnectionStatus,
type AgentEventTriggerContext,
type AgentModelOption,
type AgentInputDelivery,
type AgentPermissionReply,
type AgentPermissionRequest,
type AgentQuestionRequest,
@@ -68,6 +71,8 @@ export function useWorkbenchAgent({
const mobileCollapseTimerRef = useRef<number | null>(null);
const sessionStreamAbortRef = useRef<AbortController | null>(null);
const projectIdPromiseRef = useRef<Promise<string> | null>(null);
const [conversationScope, setConversationScope] = useState<"personal" | "project">("project");
const conversationScopeRef = useRef<"personal" | "project">("project");
const getProjectId = useCallback(() => {
projectIdPromiseRef.current ??= findOwnedWorkbenchProject(getAccessToken).then(
({ project }) => project.project_id,
@@ -78,7 +83,15 @@ export function useWorkbenchAgent({
);
return projectIdPromiseRef.current;
}, [getAccessToken]);
const clientRef = useRef(createAgentApiClient(undefined, { getAccessToken, getProjectId }));
const getConversationProjectId = useCallback(() =>
conversationScopeRef.current === "personal" ? Promise.resolve(undefined) : getProjectId(), [getProjectId]);
const clientRef = useRef(createAgentApiClient(undefined, { getAccessToken, getProjectId: getConversationProjectId }));
const scopeClient = useMemo(() => createAgentApiClient(undefined, {
getAccessToken,
getProjectId: conversationScope === "project" ? getProjectId : undefined
}), [conversationScope, getAccessToken, getProjectId]);
const generatePromptSuggestions = useCallback((...args: Parameters<typeof clientRef.current.generatePromptSuggestions>) =>
clientRef.current.generatePromptSuggestions(...args), []);
const sessionIdRef = useRef<string | null>(null);
const approvalModeRef = useRef<AgentApprovalMode>("request");
const registryRef = useRef<UIRegistry | null>(null);
@@ -92,7 +105,7 @@ export function useWorkbenchAgent({
const [sessionId, setSessionId] = useState<string | null>(null);
const [sessionPurpose, setSessionPurpose] = useState<AgentSessionPurpose>("ordinary_chat");
const [sessionTaskId, setSessionTaskId] = useState<string | null>(null);
const taskDiscussionRef = useRef<{ sessionId: string; context: AgentTaskDiscussionContext } | null>(null);
const [taskDiscussion, setTaskDiscussion] = useState<{ sessionId: string; context: AgentTaskDiscussionContext; reportGeneration?: number | null } | null>(null);
const [registry, setRegistry] = useState<UIRegistry | null>(null);
const [sessionTitle, setSessionTitle] = useState("新对话");
const [statusLabel, setStatusLabel] = useState("Agent 后端连接中");
@@ -101,11 +114,17 @@ export function useWorkbenchAgent({
const [resumedStreaming, setResumedStreaming] = useState(false);
const [modelOptions, setModelOptions] = useState<AgentModelOption[]>([]);
const [selectedModel, setSelectedModel] = useState("");
const [inputDelivery, setInputDelivery] = useState<AgentInputDelivery>("steer");
const selectedModelRef = useRef("");
useEffect(() => { selectedModelRef.current = selectedModel; }, [selectedModel]);
const [promptDraft, setPromptDraft] = useState("");
const [promptFocusRequest, setPromptFocusRequest] = useState(0);
const [approvalMode, setApprovalModeState] = useState<AgentApprovalMode>("request");
const [streamRenderState, setStreamRenderState] = useState<AgentStreamRenderState>({});
const [permissionOverrides, setPermissionOverrides] = useState<Record<string, PermissionOverride>>({});
const [questionOverrides, setQuestionOverrides] = useState<Record<string, QuestionOverride>>({});
const agentMessageInbox = useAgentMessageInbox(clientRef.current, sessionPurpose === "ordinary_chat" && conversationScope === "project" ? sessionId : null);
const refreshAgentMessageInbox = agentMessageInbox.refresh;
useEffect(() => {
sessionIdRef.current = sessionId;
@@ -154,6 +173,19 @@ export function useWorkbenchAgent({
[frontendActionExecutor]
);
const credentialRequestsRef = useRef(new Set<string>());
const handleCredentialRefresh = useCallback((data: unknown) => {
const requestId = getDataString(data, "request_id");
const activeSessionId = getDataString(data, "session_id") ?? sessionIdRef.current;
if (!requestId || !activeSessionId || activeSessionId !== sessionIdRef.current) return;
if (credentialRequestsRef.current.has(requestId)) return;
credentialRequestsRef.current.add(requestId);
void clientRef.current.refreshCredential(activeSessionId, requestId).catch(() => {
credentialRequestsRef.current.delete(requestId);
setStatusLabel("会话凭据续期失败,请重新登录后重试");
});
}, []);
const claimEnvelope = useCallback((payload: UIEnvelopePayload, activeSessionId: string) => {
if (payload.session_id !== activeSessionId) return false;
const key = `${activeSessionId}:${payload.envelope_id}`;
@@ -183,6 +215,11 @@ export function useWorkbenchAgent({
);
return;
}
if (part.type === "data-agent_message") { refreshAgentMessageInbox(); return; }
if (part.type === "data-user_input" && part.data.status === "delivered") {
chatRef.current.setMessages((current) => applySessionStreamEvent(current, { type: "user_input", data: part.data }));
return;
}
if (part.type === "data-progress") {
const title = getDataString(part.data, "title");
@@ -191,6 +228,7 @@ export function useWorkbenchAgent({
}
return;
}
if (part.type === "data-credential_refresh_required") { handleCredentialRefresh(part.data); return; }
if (part.type === "data-frontend_action") { void handleFrontendAction(part.data); return; }
if (part.type === "data-session_title") {
@@ -225,37 +263,37 @@ export function useWorkbenchAgent({
});
});
},
[applySessionId, claimEnvelope, handleFrontendAction, onUiEnvelope]
[refreshAgentMessageInbox, applySessionId, claimEnvelope, handleCredentialRefresh, handleFrontendAction, onUiEnvelope]
);
const transport = useMemo(
() =>
new DefaultChatTransport<AgentUiMessage>({
api: `${env.TJWATER_AGENT_API_BASE_URL.replace(/\/$/, "")}/api/v1/agent/chat/stream`,
new ResponsesChatTransport<AgentUiMessage>({
api: `${env.TJWATER_AGENT_URL.replace(/\/$/, "")}/api/v1/responses`,
headers: async (): Promise<Record<string, string>> => {
const accessToken = await getAccessToken?.();
const projectId = await getProjectId();
const projectId = await getConversationProjectId();
return {
...(projectId ? { "X-Project-Id": projectId } : {}),
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {})
};
},
prepareSendMessagesRequest({ id, messages, body, trigger, messageId }) {
return {
body: {
...body,
id,
messages,
trigger,
messageId,
session_id: readBodyString(body, "session_id") ?? sessionIdRef.current ?? undefined,
approval_mode: readBodyString(body, "approval_mode") ?? approvalModeRef.current,
capabilities: frontendActionEnabledRef.current ? ["frontend-action@1"] : []
}
};
async prepareSendMessagesRequest({ messages, body }) {
const catalog = await scopeClient.getModels();
const model = resolveAgentModelSelection(catalog, readBodyString(body, "model") ?? selectedModelRef.current);
setModelOptions(catalog.models);
setSelectedModel(model);
selectedModelRef.current = model;
if (!model) throw new Error("Agent 后端当前没有可用模型,请检查模型配置。");
return { body: createResponseRequest(messages, {
conversation: readBodyString(body, "session_id") ?? sessionIdRef.current ?? undefined,
model, approvalMode: readBodyString(body, "approval_mode") ?? approvalModeRef.current,
capabilities: frontendActionEnabledRef.current ? ["frontend-action@1"] : [],
triggerContext: body?.trigger_context, taskContext: body?.task_context
}) };
}
}),
[getAccessToken, getProjectId]
[getAccessToken, getConversationProjectId, scopeClient]
);
const chat = useChat<AgentUiMessage>({
@@ -287,7 +325,7 @@ export function useWorkbenchAgent({
const {
data: backendConnection,
error: backendConnectionError
} = useSWRImmutable(agentBootstrapKey, () => fetchAgentBootstrap(clientRef.current), {
} = useSWR([...agentBootstrapKey, getAccessToken, conversationScope], () => fetchAgentBootstrap(scopeClient), {
shouldRetryOnError: false
});
const {
@@ -295,7 +333,7 @@ export function useWorkbenchAgent({
isLoading: sessionHistoryLoading,
isValidating: sessionHistoryValidating,
mutate: mutateSessionHistory
} = useSWR(agentSessionsKey, () => fetchAgentSessions(clientRef.current), {
} = useSWR([...agentSessionsKey, getAccessToken, conversationScope], () => fetchAgentSessions(scopeClient), {
revalidateOnFocus: false,
shouldRetryOnError: false
});
@@ -339,7 +377,7 @@ export function useWorkbenchAgent({
frontendActionEnabledRef.current = Boolean(backendConnection.frontendActionRegistry);
setRegistry(backendConnection.registry);
setModelOptions(backendConnection.models);
setSelectedModel((current) => current || backendConnection.defaultModel || backendConnection.models[0]?.id || "");
setSelectedModel((current) => resolveAgentModelSelection(backendConnection, current));
if (chatRef.current.status === "ready") {
setConnectionStatus("online");
setStatusLabel("准备就绪");
@@ -402,6 +440,20 @@ export function useWorkbenchAgent({
}, [clearMobileCollapseTimer]);
const streaming = chat.status === "submitted" || chat.status === "streaming" || resumedStreaming;
const { data: pendingInputs = [], mutate: refreshPendingInputs } = useSWR(
sessionId ? ["agent", "response-inputs", getAccessToken, conversationScope, sessionId] : null,
() => clientRef.current.listResponseInputs(sessionId!),
{ refreshInterval: streaming ? 1000 : 0, shouldRetryOnError: false }
);
const updateInputDelivery = useCallback(async (id: string, delivery: AgentInputDelivery) => {
if (!sessionIdRef.current) return;
try {
const updated = await clientRef.current.updateResponseInput(sessionIdRef.current, id, delivery);
await refreshPendingInputs((current = []) => current.map((input) => input.id === id ? updated : input), { revalidate: false });
} catch {
showMapNotice({ tone: "warning", message: "消息可能已开始处理,无法修改发送方式。" });
} finally { void refreshPendingInputs().catch(() => {}); }
}, [refreshPendingInputs]);
const messages = useMemo(
() => toAgentChatMessages(chat.messages, permissionOverrides, questionOverrides),
[chat.messages, permissionOverrides, questionOverrides]
@@ -446,6 +498,8 @@ export function useWorkbenchAgent({
return;
}
if (event.type === "credential_refresh_required") { handleCredentialRefresh(event.data); return; }
if (event.type === "agent_message") { refreshAgentMessageInbox(); return; }
if (event.type === "session_title") {
const title = getDataString(event.data, "title");
if (title) {
@@ -497,7 +551,7 @@ export function useWorkbenchAgent({
chatRef.current.setMessages((current) => applySessionStreamEvent(current, event));
},
[claimEnvelope, handleFrontendAction, onUiEnvelope, refreshSessionHistory]
[refreshAgentMessageInbox, claimEnvelope, handleCredentialRefresh, handleFrontendAction, onUiEnvelope, refreshSessionHistory]
);
const startSessionStreamSubscription = useCallback(
@@ -557,6 +611,7 @@ export function useWorkbenchAgent({
}
applySessionId(loadedSession.sessionId);
setTaskDiscussion(null);
setSessionPurpose(loadedSession.purpose);
setSessionTaskId(loadedSession.taskId ?? null);
setSessionTitle(loadedSession.title);
@@ -570,6 +625,7 @@ export function useWorkbenchAgent({
startSessionStreamSubscription(loadedSession.sessionId);
}
await refreshSessionHistory();
return true;
} catch (error) {
showMapNotice({
id: "agent-history-status",
@@ -582,20 +638,34 @@ export function useWorkbenchAgent({
[applySessionId, chat, refreshSessionHistory, startSessionStreamSubscription, stopSessionStreamSubscription, streaming]
);
const openTaskCollaboration = useCallback(async (taskId: string, context?: AgentTaskDiscussionContext) => {
const openTaskCollaboration = useCallback(async (taskId: string, discussion?: InvestigationDiscussion) => {
const normalizedTaskId = taskId.trim();
if (!normalizedTaskId) {
throw new Error("工况任务 ID 不能为空。");
}
if (streaming) {
throw new Error("Agent 正在处理上一条指令,完成后再打开任务协作。");
}
const normalizedContext = agentTaskDiscussionContextSchema.parse(discussion?.context ?? {});
conversationScopeRef.current = "project";
setConversationScope("project");
const collaboration = await clientRef.current.createTaskCollaborationSession(normalizedTaskId);
const collaborationSessionId = collaboration.session_id || collaboration.id;
if (!collaborationSessionId) {
throw new Error("Agent 未返回任务协作会话。");
}
await loadHistorySession(collaborationSessionId);
taskDiscussionRef.current = { sessionId: collaborationSessionId, context: agentTaskDiscussionContextSchema.parse(context ?? {}) };
const loaded = await loadHistorySession(collaborationSessionId);
if (!loaded || sessionIdRef.current !== collaborationSessionId) {
throw new Error("任务协作会话未能打开,请重试。");
}
setTaskDiscussion({ sessionId: collaborationSessionId, context: normalizedContext, reportGeneration: discussion?.reportGeneration });
const draft = discussion?.prompt;
if (draft?.trim()) {
setPromptDraft((current) => current.trim() ? `${current}\n\n${draft.trim()}` : draft.trim());
}
setPromptFocusRequest((current) => current + 1);
return collaborationSessionId;
}, [loadHistorySession]);
}, [loadHistorySession, streaming]);
const renameHistorySession = useCallback(
async (nextSessionId: string, title: string) => {
@@ -644,6 +714,7 @@ export function useWorkbenchAgent({
setSessionTitle("新对话");
setSessionPurpose("ordinary_chat");
setSessionTaskId(null);
setTaskDiscussion(null);
setStatusLabel(nextStatusLabel);
setConnectionStatus("online");
setPermissionOverrides({});
@@ -697,6 +768,13 @@ export function useWorkbenchAgent({
resetDraftSession();
}, [chat, resetDraftSession, stopSessionStreamSubscription]);
const changeConversationScope = useCallback(async (scope: "personal" | "project") => {
if (scope === conversationScopeRef.current || streaming) return;
await startNewSession();
conversationScopeRef.current = scope;
setConversationScope(scope);
}, [startNewSession, streaming]);
const submitPrompt = useCallback(
async (prompt: string) => {
const normalizedPrompt = prompt.trim();
@@ -705,7 +783,19 @@ export function useWorkbenchAgent({
}
if (streaming) {
showMapNotice({ tone: "warning", message: "Agent 正在处理上一条指令。" });
try {
if (!sessionIdRef.current) throw new Error("Agent 正在建立会话,请稍后追加消息。");
const receipt = await clientRef.current.submitResponseInput(sessionIdRef.current, normalizedPrompt, inputDelivery, `msg_${crypto.randomUUID().replaceAll("-", "")}`);
// A failed list refresh must not turn an accepted message into a
// failed send and restore a draft that the user might submit twice.
void refreshPendingInputs((current = []) => receipt.status === "pending"
? [...current.filter((input) => input.id !== receipt.id), receipt]
: current.filter((input) => input.id !== receipt.id), { revalidate: false })
.then(() => refreshPendingInputs()).catch(() => {});
} catch (error) {
showMapNotice({ tone: "error", title: "追加消息失败", message: error instanceof Error ? error.message : "请稍后重试。" });
throw error;
}
return;
}
@@ -718,12 +808,12 @@ export function useWorkbenchAgent({
session_id: sessionIdRef.current ?? undefined,
model: selectedModel || undefined,
approval_mode: approvalMode,
task_context: taskDiscussionRef.current?.sessionId === sessionIdRef.current ? taskDiscussionRef.current.context : undefined
task_context: taskDiscussion?.sessionId === sessionIdRef.current ? taskDiscussion.context : undefined
}
}
);
},
[approvalMode, chat, selectedModel, streaming]
[approvalMode, chat, inputDelivery, refreshPendingInputs, selectedModel, streaming, taskDiscussion]
);
const triggerEvent = useCallback(
@@ -795,10 +885,10 @@ export function useWorkbenchAgent({
stopSessionStreamSubscription();
const activeSessionId = sessionIdRef.current;
if (activeSessionId) {
void clientRef.current.abort(activeSessionId).catch(() => undefined);
void clientRef.current.abort(activeSessionId).then(() => refreshPendingInputs()).catch(() => undefined);
}
markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState);
}, [chat, stopSessionStreamSubscription]);
}, [chat, refreshPendingInputs, stopSessionStreamSubscription]);
const replyPermission = useCallback(async (permission: AgentPermissionRequest, reply: AgentPermissionReply) => {
setPermissionOverrides((current) => ({
@@ -907,6 +997,11 @@ export function useWorkbenchAgent({
return {
apiClient: clientRef.current,
inputDelivery,
setInputDelivery,
pendingInputs,
updateInputDelivery,
agentMessageInbox,
approvalMode,
collapsePanel,
expandPanel,
@@ -917,6 +1012,8 @@ export function useWorkbenchAgent({
openMobilePanel,
closeMobilePanel,
connectionStatus,
conversationScope,
changeConversationScope,
panelCollapsing,
panelOpen,
personaState,
@@ -927,15 +1024,19 @@ export function useWorkbenchAgent({
renameHistorySession,
deleteHistorySession,
sessionHistory,
generatePromptSuggestions,
sessionHistoryLoading: sessionHistoryLoading || sessionHistoryValidating,
sessionId,
sessionPurpose,
sessionTaskId,
taskDiscussionContext: taskDiscussion?.sessionId === sessionId ? taskDiscussion.context : undefined,
taskDiscussionReportGeneration: taskDiscussion?.sessionId === sessionId ? taskDiscussion.reportGeneration : undefined,
sessionTitle,
statusLabel,
streamRenderState,
selectedModel,
promptDraft,
promptFocusRequest,
setApprovalMode,
setPromptDraft,
setSelectedModel,
@@ -0,0 +1,86 @@
import { act, cleanup, renderHook } from "@testing-library/react";
import type { Map as MapLibreMap } from "maplibre-gl";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { searchPlaces, type PlaceSearchItem, type PlaceSearchResults } from "../api/place-search-client";
import { locateGeocodingResult, markGeocodingLocation } from "../map/geocoding-location";
import { useWorkbenchGeocoding } from "./use-workbench-geocoding";
vi.mock("../api/place-search-client", () => ({ searchPlaces: vi.fn() }));
vi.mock("../map/geocoding-location", () => ({ locateGeocodingResult: vi.fn(), markGeocodingLocation: vi.fn() }));
const location: PlaceSearchItem = { id: "place-1", name: "南桥路", address: "奉贤区南桥路", longitude: 121.44, latitude: 31.22, category: "道路名", district: "奉贤区" };
const other = { ...location, id: "place-2", address: "奉贤区南桥路", longitude: 121.35, latitude: 30.77 };
const results: PlaceSearchResults = { items: [location, other], total: 2, truncated: false, region: "奉贤区" };
const remove = vi.fn();
const options = {
active: true, projectId: "project-1", mapRef: { current: {} as MapLibreMap }, mapReady: true,
getPadding: () => ({ top: 48, right: 400, bottom: 48, left: 48 }), reducedMotion: true, onLocate: vi.fn()
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(markGeocodingLocation).mockReturnValue({ remove } as unknown as ReturnType<typeof markGeocodingLocation>);
});
afterEach(cleanup);
describe("place search selection lifecycle", () => {
it("does not move until selection and replaces the previous marker", async () => {
vi.mocked(searchPlaces).mockResolvedValue(results);
const { result, rerender } = renderHook(useWorkbenchGeocoding, { initialProps: options });
act(() => result.current.changeAddress("南桥路"));
await act(() => result.current.search());
expect(result.current.results).toEqual(results);
expect(result.current.result).toBeNull();
expect(result.current.choosing).toBe(true);
expect(markGeocodingLocation).not.toHaveBeenCalled();
act(() => result.current.select(location.id));
expect(result.current.choosing).toBe(false);
expect(locateGeocodingResult).toHaveBeenCalledWith(options.mapRef.current, location, options.getPadding(), true);
act(() => result.current.select(other.id));
expect(result.current.result).toEqual(other);
expect(remove).toHaveBeenCalledOnce();
expect(markGeocodingLocation).toHaveBeenLastCalledWith(options.mapRef.current, other);
act(() => result.current.changeSelection());
expect(result.current.choosing).toBe(true);
expect(result.current.result).toEqual(other);
const moves = vi.mocked(locateGeocodingResult).mock.calls.length;
act(() => result.current.select(other.id));
expect(result.current.choosing).toBe(false);
expect(locateGeocodingResult).toHaveBeenCalledTimes(moves + 1);
rerender({ ...options, active: false });
expect(result.current.result).toEqual(other);
act(() => result.current.clear());
expect(remove).toHaveBeenCalledTimes(2);
expect(result.current.results).toBeNull();
});
it.each(["edit", "close", "project"])("ignores a late search after %s", async (action) => {
let resolve!: (value: PlaceSearchResults) => void;
vi.mocked(searchPlaces).mockReturnValue(new Promise((done) => { resolve = done; }));
const { result, rerender } = renderHook(useWorkbenchGeocoding, { initialProps: options });
act(() => result.current.changeAddress("旧地址"));
let pending!: Promise<void>;
act(() => { pending = result.current.search(); });
const signal = vi.mocked(searchPlaces).mock.calls[0][3];
if (action === "edit") act(() => result.current.changeAddress("新地址"));
if (action === "close") rerender({ ...options, active: false });
if (action === "project") rerender({ ...options, projectId: "project-2" });
expect(signal?.aborted).toBe(true);
await act(async () => { resolve(results); await pending; });
expect(result.current.results).toBeNull();
expect(result.current.result).toBeNull();
expect(result.current.pending).toBe(false);
});
it("recovers from errors and clears the map when editing the query", async () => {
vi.mocked(searchPlaces).mockRejectedValueOnce(new Error("查询暂不可用")).mockResolvedValueOnce(results);
const { result, unmount } = renderHook(() => useWorkbenchGeocoding(options));
act(() => result.current.changeAddress("南桥路"));
await act(() => result.current.search());
expect(result.current.error).toBe("查询暂不可用");
await act(() => result.current.search());
expect(result.current.error).toBeNull();
act(() => result.current.select(location.id));
act(() => result.current.changeAddress("新地址"));
expect(remove).toHaveBeenCalledOnce();
expect(result.current.result).toBeNull();
expect(result.current.results).toBeNull();
unmount();
});
});
@@ -0,0 +1,104 @@
import type { Map as MapLibreMap, PaddingOptions } from "maplibre-gl";
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import { searchPlaces, type PlaceSearchItem, type PlaceSearchResults } from "../api/place-search-client";
import { locateGeocodingResult, markGeocodingLocation } from "../map/geocoding-location";
type GeocodingOptions = {
active: boolean;
projectId: string | null;
getAccessToken?: AccessTokenProvider;
mapRef: RefObject<MapLibreMap | null>;
mapReady: boolean;
getPadding: () => PaddingOptions;
reducedMotion: boolean;
onLocate: () => void;
};
export function useWorkbenchGeocoding(options: GeocodingOptions) {
const [address, setAddress] = useState("");
const [result, setResult] = useState<PlaceSearchItem | null>(null);
const [results, setResults] = useState<PlaceSearchResults | null>(null);
const [choosing, setChoosing] = useState(true);
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const requestRef = useRef<AbortController | null>(null);
const optionsRef = useRef(options);
optionsRef.current = options;
const cancel = useCallback(() => {
requestRef.current?.abort();
requestRef.current = null;
setPending(false);
}, []);
const clear = useCallback(() => {
cancel();
setResult(null);
setResults(null);
setChoosing(true);
setError(null);
}, [cancel]);
useEffect(() => {
clear();
setAddress("");
return () => requestRef.current?.abort();
}, [options.projectId, options.getAccessToken, clear]);
useEffect(() => {
if (!options.active) cancel();
}, [options.active, cancel]);
const locate = useCallback(() => {
const current = optionsRef.current;
const map = current.mapRef.current;
if (!result || !current.mapReady || !map) return;
current.onLocate();
locateGeocodingResult(map, result, current.getPadding(), current.reducedMotion);
}, [result]);
useEffect(() => {
const map = options.mapRef.current;
if (!map || !options.mapReady || !result) return;
const marker = markGeocodingLocation(map, result);
locate();
return () => { marker.remove(); };
}, [options.mapRef, options.mapReady, result, locate]);
async function search() {
clear();
if (!address.trim()) { setError("请输入需要搜索的地址或地名"); return; }
const controller = new AbortController();
requestRef.current = controller;
setPending(true);
try {
const value = await searchPlaces(address, options.projectId, options.getAccessToken, controller.signal);
if (controller.signal.aborted || requestRef.current !== controller) return;
setResults(value);
} catch (failure) {
if (controller.signal.aborted || requestRef.current !== controller) return;
setError(failure instanceof Error && failure.name === "TimeoutError"
? "地名搜索超时,请稍后重试"
: failure instanceof Error ? failure.message : "地名搜索失败,请稍后重试");
} finally {
if (requestRef.current === controller) { requestRef.current = null; setPending(false); }
}
}
return {
address, result, results, choosing, pending, error, locate, search,
select: (id: string) => {
if (pending) return;
const item = results?.items.find((candidate) => candidate.id === id);
if (item) {
if (result?.id === id) locate();
else setResult(item);
setChoosing(false);
}
},
changeSelection: () => setChoosing(true),
enabled: options.mapReady,
changeAddress: (value: string) => { clear(); setAddress(value); },
clear: () => { clear(); setAddress(""); }
};
}
export type WorkbenchGeocoding = ReturnType<typeof useWorkbenchGeocoding>;
@@ -0,0 +1,77 @@
import { act, cleanup, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { dismissMapNotice, showMapNotice } from "@/features/map/core";
import { useWorkbenchSyncNotice } from "./use-workbench-sync-notice";
vi.mock("@/features/map/core", () => ({
dismissMapNotice: vi.fn(),
showMapNotice: vi.fn()
}));
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
describe("workbench reconnect notice", () => {
it("keeps repeated brief reconnects silent without leaving a pending warning", () => {
const { rerender } = renderHook((reconnecting) => useWorkbenchSyncNotice(reconnecting), {
initialProps: false
});
for (let cycle = 0; cycle < 3; cycle += 1) {
rerender(true);
act(() => vi.advanceTimersByTime(1_000));
rerender(false);
act(() => vi.advanceTimersByTime(300_000));
}
expect(showMapNotice).not.toHaveBeenCalled();
});
it("warns once after a sustained disconnect and dismisses on recovery", () => {
const { rerender } = renderHook((reconnecting) => useWorkbenchSyncNotice(reconnecting), {
initialProps: true
});
act(() => vi.advanceTimersByTime(14_999));
expect(showMapNotice).not.toHaveBeenCalled();
// Snapshot refreshes and failed retries must not restart the waiting period.
rerender(true);
act(() => vi.advanceTimersByTime(1));
expect(showMapNotice).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({
id: "workbench-sync-reconnecting", title: "业务同步正在重连", duration: Infinity
}));
act(() => vi.advanceTimersByTime(60_000));
expect(showMapNotice).toHaveBeenCalledOnce();
vi.mocked(dismissMapNotice).mockClear();
rerender(false);
expect(dismissMapNotice).toHaveBeenCalledWith("workbench-sync-reconnecting");
});
it("starts a fresh waiting period for a new outage", () => {
const { rerender } = renderHook((reconnecting) => useWorkbenchSyncNotice(reconnecting), {
initialProps: true
});
act(() => vi.advanceTimersByTime(10_000));
rerender(false);
rerender(true);
act(() => vi.advanceTimersByTime(5_000));
expect(showMapNotice).not.toHaveBeenCalled();
act(() => vi.advanceTimersByTime(10_000));
expect(showMapNotice).toHaveBeenCalledOnce();
});
it("cancels a pending warning when leaving the workbench", () => {
const { unmount } = renderHook(() => useWorkbenchSyncNotice(true));
act(() => vi.advanceTimersByTime(10_000));
unmount();
act(() => vi.advanceTimersByTime(20_000));
expect(showMapNotice).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,30 @@
import { useEffect } from "react";
import { dismissMapNotice, showMapNotice } from "@/features/map/core";
const RECONNECT_NOTICE_DELAY_MS = 15_000;
const RECONNECT_NOTICE_ID = "workbench-sync-reconnecting";
export function useWorkbenchSyncNotice(reconnecting: boolean) {
useEffect(() => {
if (!reconnecting) {
dismissMapNotice(RECONNECT_NOTICE_ID);
return;
}
// Scheduled SSE renewal and brief outages normally recover within this window.
const timer = window.setTimeout(() => {
showMapNotice({
id: RECONNECT_NOTICE_ID,
tone: "warning",
title: "业务同步正在重连",
message: "当前数据可能陈旧,连接恢复后将自动更新。",
duration: Infinity
});
}, RECONNECT_NOTICE_DELAY_MS);
return () => {
window.clearTimeout(timer);
dismissMapNotice(RECONNECT_NOTICE_ID);
};
}, [reconnecting]);
}
@@ -6,6 +6,7 @@ import {
type WorkbenchEvent,
type WorkbenchSyncSnapshot
} from "../api/workbench-sync-client";
import { WorkbenchEventChannel } from "./workbench-event-channel";
const RECONCILE_INTERVAL_MS = 60_000;
const RUNTIME_TOPICS = new Set<WorkbenchEvent["topic"]>([
@@ -30,6 +31,7 @@ export type WorkbenchSyncState = {
detailResetGeneration: number;
acknowledgeEventsThrough: (eventSequence: number) => void;
refresh: () => Promise<void>;
subscribePresentationEvents: WorkbenchEventChannel["subscribe"];
};
export function shouldApplyWorkbenchSnapshot(input: {
@@ -44,6 +46,7 @@ export function shouldApplyWorkbenchSnapshot(input: {
}
export function useWorkbenchSync(getAccessToken?: AccessTokenProvider): WorkbenchSyncState {
const channelRef = useRef(new WorkbenchEventChannel());
const [snapshot, setSnapshot] = useState<WorkbenchSyncSnapshot | null>(null);
const [events, setEvents] = useState<WorkbenchEvent[]>([]);
const [status, setStatus] = useState<WorkbenchSyncState["status"]>("connecting");
@@ -63,6 +66,7 @@ export function useWorkbenchSync(getAccessToken?: AccessTokenProvider): Workbenc
}, []);
useEffect(() => {
const channel = channelRef.current;
const controller = new AbortController();
let reconnectTimer: number | undefined;
let reconcileTimer: number | undefined;
@@ -135,13 +139,18 @@ export function useWorkbenchSync(getAccessToken?: AccessTokenProvider): Workbenc
getAccessToken,
signal: controller.signal,
onOpen: () => {
channel.publish({ type: "open" });
statusRef.current = "live";
setStatus("live");
setError(null);
retryMs = 1_000;
},
onReset: async () => { await loadSnapshot(true, true); },
onReset: async () => {
channel.publish({ type: "reset" });
await loadSnapshot(true, true);
},
onEvents: (nextEvents) => {
channel.publish({ type: "events", events: nextEvents });
const unseen = nextEvents.filter((event) => event.event_seq > cursorRef.current);
if (unseen.length === 0) return;
cursorRef.current = unseen.at(-1)!.event_seq;
@@ -155,11 +164,13 @@ export function useWorkbenchSync(getAccessToken?: AccessTokenProvider): Workbenc
}
});
if (!controller.signal.aborted) {
channel.publish({ type: "disconnect" });
statusRef.current = "reconnecting";
setStatus("reconnecting");
}
} catch (nextError) {
if (controller.signal.aborted) return;
channel.publish({ type: "disconnect" });
statusRef.current = "reconnecting";
setStatus("reconnecting");
setError(nextError instanceof Error ? nextError.message : "工作台事件连接中断");
@@ -175,6 +186,7 @@ export function useWorkbenchSync(getAccessToken?: AccessTokenProvider): Workbenc
if (statusRef.current !== "live") void loadSnapshot().catch(() => undefined);
}, RECONCILE_INTERVAL_MS);
return () => {
channel.publish({ type: "disconnect" });
controller.abort();
refreshSnapshotRef.current = null;
if (reconnectTimer !== undefined) window.clearTimeout(reconnectTimer);
@@ -190,6 +202,7 @@ export function useWorkbenchSync(getAccessToken?: AccessTokenProvider): Workbenc
error,
detailResetGeneration,
acknowledgeEventsThrough,
refresh
refresh,
subscribePresentationEvents: channelRef.current.subscribe
};
}
@@ -0,0 +1,33 @@
import { describe, expect, it, vi } from "vitest";
import type { WorkbenchEvent } from "../api/workbench-sync-client";
import { WorkbenchEventChannel, type WorkbenchStreamNotification } from "./workbench-event-channel";
describe("workbench wire event channel", () => {
it("keeps independent observers live across business consumption, another observer failure and unsubscribe", () => {
const channel = new WorkbenchEventChannel();
const first = vi.fn();
const unsubscribeFirst = channel.subscribe(first);
expect(first).toHaveBeenCalledWith({ type: "disconnect" });
channel.publish({ type: "open" });
channel.subscribe((notification) => { if (notification.type === "events") throw new Error("observer failed"); });
const second = vi.fn();
channel.subscribe(second);
expect(second).toHaveBeenCalledWith({ type: "open" });
const events = [{ event_seq: 11 }, { event_seq: 12 }] as WorkbenchEvent[];
const businessPending = [...events];
const notification: WorkbenchStreamNotification = { type: "events", events };
channel.publish(notification);
businessPending.splice(0, businessPending.length);
expect(first).toHaveBeenLastCalledWith(notification);
expect(second).toHaveBeenLastCalledWith(notification);
expect(events.map((event) => event.event_seq)).toEqual([11, 12]);
const firstCallCount = first.mock.calls.length;
unsubscribeFirst();
const next: WorkbenchStreamNotification = { type: "events", events: [{ event_seq: 13 }] as WorkbenchEvent[] };
channel.publish(next);
expect(first).toHaveBeenCalledTimes(firstCallCount);
expect(second).toHaveBeenLastCalledWith(next);
channel.publish({ type: "disconnect" });
expect(second).toHaveBeenLastCalledWith({ type: "disconnect" });
});
});
@@ -0,0 +1,25 @@
import type { WorkbenchEvent } from "../api/workbench-sync-client";
export type WorkbenchStreamNotification =
| { type: "open" | "disconnect" | "reset" }
| { type: "events"; events: WorkbenchEvent[] };
/** Presentation observes wire events before business acknowledgements or snapshot filtering. */
export class WorkbenchEventChannel {
private listeners = new Set<(notification: WorkbenchStreamNotification) => void>();
private live = false;
subscribe = (listener: (notification: WorkbenchStreamNotification) => void) => {
this.listeners.add(listener);
listener({ type: this.live ? "open" : "disconnect" });
return () => { this.listeners.delete(listener); };
};
publish(notification: WorkbenchStreamNotification) {
if (notification.type !== "events") this.live = notification.type === "open";
for (const listener of this.listeners) {
// One presentation observer must not interrupt authoritative business synchronization.
try { listener(notification); } catch { /* Observers own their error presentation. */ }
}
}
}
+150 -31
View File
@@ -13,6 +13,7 @@ import { CalendarClock, Clock3 } from "lucide-react";
import {
AgentCommandPanel,
AgentPersona,
parseFrontendChart,
toTrustedMapAction,
type FrontendActionRequest,
type UIEnvelopePayload,
@@ -26,18 +27,19 @@ import {
MapSourceStatusNotice,
MapToolbar,
MapZoom,
dismissMapNotice,
showMapNotice,
type MapToolbarItem,
type MapSourceStatus
} from "@/features/map/core";
import { env } from "@/shared/config/env";
import { waitForAgentPresentation } from "./map/agent-presentation";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import { AgentTaskTicker } from "./components/agent-task-ticker";
import { AnalysisResultsPanel } from "./components/analysis-results-panel";
import { PeripheralSamplePanel } from "./peripheral-sample/peripheral-sample-panel";
import type { PeripheralWorkOrder } from "./peripheral-sample/client";
import { MapDevPanel } from "./components/map-dev-panel";
import { InvestigationPresentationPanel } from "./components/investigation-presentation-panel";
import { MobileWorkbenchSheet } from "./components/mobile-workbench-sheet";
import { FeaturePopover } from "./components/feature-popover";
import { ScheduledConditionFeed } from "./components/scheduled-condition-feed";
@@ -68,11 +70,16 @@ import {
} from "./workspace/workspace-context";
import { WORKBENCH_USER } from "./data/workbench-session";
import { useWorkbenchAgent } from "./hooks/use-workbench-agent";
import { useAgentPromptSuggestions } from "./hooks/use-agent-prompt-suggestions";
import { operationalTaskConditionId, useOperationalTasks } from "./hooks/use-operational-tasks";
import { useWorkbenchSync } from "./hooks/use-workbench-sync";
import { useWorkbenchSyncNotice } from "./hooks/use-workbench-sync-notice";
import { useInvestigationPresentation } from "./hooks/use-investigation-presentation";
import type { InvestigationMapTarget } from "./api/investigation-report";
import { useWorkbenchDrawing, type WorkbenchDrawMode } from "./hooks/use-workbench-drawing";
import { useWorkbenchMap } from "./hooks/use-workbench-map";
import { useWorkbenchMapController } from "./hooks/use-workbench-map-controller";
import { useWorkbenchGeocoding } from "./hooks/use-workbench-geocoding";
import { useWorkbenchProjectPresentation } from "./hooks/use-workbench-project-presentation";
import { useWorkbenchResponsiveLayout } from "./hooks/use-workbench-responsive-layout";
import { formatHeaderDataTime, useWorkbenchRuntimeData } from "./hooks/use-workbench-runtime-data";
@@ -88,7 +95,7 @@ import {
getWorkbenchLayoutCssVariables,
resolveMobileWorkbenchOverlayLayout
} from "./layout/workbench-layout";
import { getResponsiveWorkbenchPadding } from "./map/camera";
import { getResponsivePadding, getResponsiveWorkbenchPadding } from "./map/camera";
import { exportMapViewImage } from "./map/export-view";
import { parseScadaAnalysisItems } from "./map/scada-analysis";
import {
@@ -135,6 +142,8 @@ export function MapWorkbenchPage({
const mapContainerRef = useRef<HTMLDivElement | null>(null);
const [detailFeature, setDetailFeature] = useState<DetailFeature | null>(null);
const [devPanelOpen, setDevPanelOpen] = useState(false);
const [investigationFollow, setInvestigationFollow] = useState(true);
const presentationLayerChangesRef = useRef(new Map<string, boolean>());
const [impactVisible, setImpactVisible] = useState(false);
const [activeToolId, setActiveToolId] = useState<ToolbarToolId | null>(null);
const prefersReducedMotion = useReducedMotion();
@@ -158,25 +167,7 @@ export function MapWorkbenchPage({
useState<Record<string, boolean>>(INITIAL_LAYER_VISIBILITY);
const [agentUiResults, setAgentUiResults] = useState<AgentUiResult[]>([]);
const workbenchSync = useWorkbenchSync(getAccessToken);
const workbenchSyncReconnecting =
workbenchSync.status === "reconnecting" && workbenchSync.snapshot !== null;
useEffect(() => {
const noticeId = "workbench-sync-reconnecting";
if (!workbenchSyncReconnecting) {
dismissMapNotice(noticeId);
return;
}
showMapNotice({
id: noticeId,
tone: "warning",
title: "业务同步正在重连",
message: "当前数据可能陈旧,连接恢复后将自动更新。",
duration: Infinity
});
return () => dismissMapNotice(noticeId);
}, [workbenchSyncReconnecting]);
useWorkbenchSyncNotice(workbenchSync.status === "reconnecting" && workbenchSync.snapshot !== null);
const {
headerDataTime,
scheduledConditions: runtimeScheduledConditions,
@@ -192,6 +183,8 @@ export function MapWorkbenchPage({
operationalCursorTime
} = useWorkbenchRuntimeData(getAccessToken, workbenchSync);
const operationalTasks = useOperationalTasks(getAccessToken, workbenchSync);
const selectedInvestigationTask = operationalTasks.tasks.find((task) =>
task.task_type === "event_investigation" && operationalTaskConditionId(task.task_id) === selectedConditionId) ?? null;
const baseLayerOptions = useMemo(
() =>
BASE_LAYER_OPTIONS.map((option) => ({
@@ -464,6 +457,36 @@ export function MapWorkbenchPage({
void mapController.clearPipeFlowCalculations();
}, [mapController, mapReady, realtimePipeFlows]);
const presentation = useInvestigationPresentation({
enabled: investigationFollow, task: selectedInvestigationTask, projectId: projectPresentation.projectId,
sync: workbenchSync, getAccessToken, mapRef, mapReady, controller: mapController,
onClearLayers: restorePresentationLayers,
getPadding: () => mapRef.current ? getResponsiveWorkbenchPadding(mapRef.current, leftPanelOpen, rightPanelOpen, rightPanelExpanded, agentPanelWidth) : { top: 48, right: 48, bottom: 48, left: 48 }
});
const geocoding = useWorkbenchGeocoding({
active: activeToolId === "geocoding",
projectId: projectPresentation.projectId,
getAccessToken, mapRef, mapReady,
reducedMotion: Boolean(prefersReducedMotion),
onLocate: presentation.pauseCamera,
getPadding: () => {
const map = mapRef.current;
if (!map) return { top: 48, right: 48, bottom: 48, left: 48 };
const padding = getResponsiveWorkbenchPadding(map, leftPanelOpen, rightPanelOpen, rightPanelExpanded, agentPanelWidth);
return getResponsivePadding(map, isLargeScreen
? { ...padding, right: Math.max(padding.right, 400) }
: { ...padding, bottom: Math.max(padding.bottom, 420) });
}
});
function handleLocateInvestigationTargets(targets: InvestigationMapTarget[]) {
void presentation.locateTargets(targets).catch((error) => showMapNotice({ tone: "warning", title: "关联要素暂不可定位", message: error instanceof Error ? error.message : "地图当前不可用" }));
}
useEffect(() => () => {
restorePresentationLayers();
// Layer restoration belongs to the followed task, not each detail refresh.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedInvestigationTask?.task_id, investigationFollow]);
const activeAgentUiResults = useMemo(
() => agentUiResults.filter((result) => result.sessionId === agent.sessionId),
[agent.sessionId, agentUiResults]
@@ -622,6 +645,7 @@ export function MapWorkbenchPage({
[layerControlItems]
);
function handleToggleLayer(layerControlId: string, visible: boolean) {
presentationLayerChangesRef.current.delete(layerControlId);
const map = mapRef.current;
setLayerVisibility((current) => ({ ...current, [layerControlId]: visible }));
@@ -637,6 +661,20 @@ export function MapWorkbenchPage({
map.setLayoutProperty(layerId, "visibility", visible ? "visible" : "none");
});
}
function handleTogglePresentationLayer(id: string, visible: boolean) {
const before = presentationLayerChangesRef.current.get(id) ?? (layerVisibility[id] !== false);
handleToggleLayer(id, visible);
presentationLayerChangesRef.current.set(id, before);
}
function restorePresentationLayers() {
for (const [id, visible] of presentationLayerChangesRef.current) handleToggleLayer(id, visible);
presentationLayerChangesRef.current.clear();
}
const presentationLayerIds = new Set([
...presentation.highlightLayers,
...(selectedInvestigationTask?.latest_progress?.progress.diagram?.node_refs.flatMap((node) => node.map_targets?.map((target) => target.source_id) ?? []) ?? [])
].flatMap((id) => id === "scada" ? ["scada-pressure", "scada-flow"] : [id]));
const presentationRelatedLayers = layerControlItems.filter((item) => presentationLayerIds.has(item.id)).map((item) => ({ id: item.id, label: item.label, visible: item.visible }));
async function handleExportView(preset: ExportViewPreset) {
const map = mapRef.current;
@@ -731,8 +769,8 @@ export function MapWorkbenchPage({
if (workbenchAlerts.length === 0) {
showMapNotice({
tone: "info",
title: "当前无待复核工况",
message: "当前没有需要汇总的工况提醒。"
title: "当前无待处理事项",
message: "当前没有需要汇总的待办或告警。"
});
return;
}
@@ -756,13 +794,13 @@ export function MapWorkbenchPage({
showMapNotice({
tone: "success",
title: "已发起工况汇总",
message: "已将待复核工况发送到 Agent。"
message: "已将待办与告警发送给 Agent。"
});
} catch (error) {
showMapNotice({
tone: "error",
title: "工况汇总发起失败",
message: error instanceof Error ? error.message : "无法将待复核工况发送到 Agent。"
message: error instanceof Error ? error.message : "无法将待办与告警发送给 Agent。"
});
}
}
@@ -837,6 +875,34 @@ export function MapWorkbenchPage({
}
async function handleFrontendAction(request: FrontendActionRequest, signal: AbortSignal) {
signal.throwIfAborted();
if (request.name === "locate_features") {
const action = toTrustedMapAction("locate_features", request.params);
if (!action || action.type !== "locate_features" || !action.layer) throw new Error("INVALID_MAP_TARGET");
const result = await mapController.locateFeatures(action.layer as Parameters<typeof mapController.locateFeatures>[0], action.featureIds, signal);
return result;
}
if (request.name === "zoom_to_map") {
const action = toTrustedMapAction("zoom_to_map", request.params);
if (!action || action.type !== "zoom_to_map") throw new Error("INVALID_MAP_CAMERA");
const map = mapRef.current;
if (!map || !mapReady) throw new Error("MAP_NOT_READY");
map.jumpTo({ center: action.center, zoom: action.zoom });
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
signal.throwIfAborted();
return { fitted: true };
}
if (request.name === "show_chart" || request.name === "view_history") {
const chart = parseFrontendChart(request.name === "view_history" ? request.params.chart : request.params);
if (!chart) throw new Error("INVALID_CHART_DATA");
const data = { x_data: chart.x_data, series: chart.series };
const envelope: AgentUiResult["envelope"] = request.name === "view_history"
? { kind: "registered_component", schemaVersion: "agent-ui@1", component: "HistoryPanel", surface: "side_panel", props: { feature_id: request.params.element_id, metric: chart.title, start_time: request.params.start, end_time: request.params.end }, data }
: { kind: "chart", schemaVersion: "agent-ui@1", grammar: "echarts-safe-subset", surface: "side_panel", spec: { title: chart.title, chart_type: chart.chart_type }, data };
setAgentUiResults((current) => [...current.slice(-5), { id: request.actionId, sessionId: request.sessionId, createdAt: Date.now(), envelope }]);
await waitForAgentPresentation(request.actionId, signal);
return { displayed: true, series_count: chart.series.length };
}
if (request.name === "render_scada_analysis") {
const items = parseScadaAnalysisItems(request.params.items);
if (!items) throw new Error("INVALID_SCADA_ANALYSIS");
@@ -875,8 +941,7 @@ export function MapWorkbenchPage({
async function handleOpenTaskCollaboration(taskId: string, discussion?: InvestigationDiscussion) {
mapController.clearScadaAnalysis();
await agent.openTaskCollaboration(taskId, discussion?.context);
if (discussion?.prompt) await agent.submitPrompt(discussion.prompt);
await agent.openTaskCollaboration(taskId, discussion);
}
async function handleDeleteAgentHistorySession(sessionId: string) {
@@ -995,6 +1060,7 @@ export function MapWorkbenchPage({
}
const toolbarPanelProps: ToolbarPanelProps = {
geocoding,
activeToolId,
activeMeasureModeId,
activeMeasureUnitId,
@@ -1067,6 +1133,20 @@ export function MapWorkbenchPage({
const collaborationReviewTask = agent.sessionPurpose !== "ordinary_chat" && agent.sessionTaskId
? operationalTasks.tasks.find((item) => item.task_id === agent.sessionTaskId)
: undefined;
const promptSuggestions = useAgentPromptSuggestions({
enabled: agent.conversationScope === "project" && (isLargeScreen ? agent.panelOpen : mobileSheet === "agent") && agent.messages.length === 0 && !agent.streaming &&
agent.sessionPurpose === "ordinary_chat" && agent.connectionStatus === "online" && !agent.sessionHistoryLoading &&
!scheduledConditionsLoading && !operationalTasks.loading,
model: agent.selectedModel,
sessionId: agent.sessionId,
request: agent.generatePromptSuggestions,
alerts: workbenchAlerts,
alertDataSource,
conditions: scheduledConditions,
runtimeDataSource,
artifact: latestAnalysisArtifact ?? null,
sessions: agent.sessionHistory,
});
const agentPanelProps: Omit<
ComponentProps<typeof AgentCommandPanel>,
"collapsing" | "onCollapse"
@@ -1075,15 +1155,24 @@ export function MapWorkbenchPage({
sessionTitle: agent.sessionTitle,
sessionHistory: agent.sessionHistory,
sessionHistoryLoading: agent.sessionHistoryLoading,
promptSuggestions: promptSuggestions.suggestions,
promptSuggestionsLoading: promptSuggestions.loading,
promptSuggestionsUnavailable: promptSuggestions.unavailable,
activeSessionId: agent.sessionId,
qaOnly: agent.sessionPurpose === "task_collaboration",
statusLabel: agent.statusLabel,
connectionStatus: agent.connectionStatus,
streaming: agent.streaming,
inputDelivery: agent.inputDelivery,
onInputDeliveryChange: agent.setInputDelivery,
pendingInputs: agent.pendingInputs,
onPendingInputDeliveryChange: agent.updateInputDelivery,
messages: agent.messages,
streamRenderState: agent.streamRenderState,
modelOptions: agent.modelOptions,
selectedModel: agent.selectedModel,
conversationScope: agent.conversationScope,
onConversationScopeChange: agent.changeConversationScope,
approvalMode: agent.approvalMode,
uiResults: activeAgentUiResults,
reviewPanel: collaborationReviewTask ? <TaskConversationReview task={collaborationReviewTask}
@@ -1096,6 +1185,17 @@ export function MapWorkbenchPage({
onRefresh={operationalTasks.refresh} /> : undefined,
workspaceContext: agentWorkspaceContext,
prompt: agent.promptDraft,
promptReference: collaborationReviewTask ? {
title: collaborationReviewTask.title,
placeholder: "可询问调查进展、讨论报告或补充信息",
detail: [
agent.taskDiscussionContext?.report_revision && agent.taskDiscussionReportGeneration
? `第 ${agent.taskDiscussionReportGeneration} 轮` : null,
agent.taskDiscussionContext?.report_revision ? `报告第 ${agent.taskDiscussionContext.report_revision} 版` : null,
agent.taskDiscussionContext?.evidence_id ? "已引用证据" : null,
agent.taskDiscussionContext?.gap_id ? "已引用数据缺口" : null
].filter(Boolean).join(" · ")
} : undefined,
onRefreshHistory: agent.refreshSessionHistory,
onStartNewSession: handleStartNewAgentSession,
onLoadHistorySession: handleLoadAgentHistorySession,
@@ -1104,6 +1204,12 @@ export function MapWorkbenchPage({
onSelectModel: agent.setSelectedModel,
onApprovalModeChange: agent.setApprovalMode,
onPromptChange: agent.setPromptDraft,
promptFocusRequest: agent.promptFocusRequest,
messageInbox: agent.sessionPurpose === "ordinary_chat" ? {
...agent.agentMessageInbox,
onLoadMore: agent.agentMessageInbox.loadMore,
onRefresh: agent.agentMessageInbox.refresh
} : undefined,
onSubmitPrompt: agent.submitPrompt,
onLocateAnswerFeatures: (target) => mapController.locateFeatures(target.layer, target.feature_ids, new AbortController().signal),
onStopPrompt: agent.stopPrompt,
@@ -1259,6 +1365,13 @@ export function MapWorkbenchPage({
}
mapOverlay={
<div className="pointer-events-none absolute inset-x-0 bottom-0 top-14">
{investigationFollow && selectedInvestigationTask && !devPanelOpen ? <InvestigationPresentationPanel
task={selectedInvestigationTask} charts={presentation.charts} overlay={presentation.overlay} cameraPaused={presentation.cameraPaused}
status={presentation.status} error={presentation.error}
width={Math.min(380, Math.max(180, businessWorkspaceWidth - (rightPanelOpen ? (rightPanelExpanded ? (viewportWidth >= 1536 ? 960 : 880) : 432) + 64 : 64) - 24))}
relatedLayers={presentationRelatedLayers} onToggleLayer={handleTogglePresentationLayer}
onResumeCamera={presentation.resumeCamera} onCloseChart={presentation.closeChart} onToggleOverlay={presentation.toggleOverlay} onFitOverlay={presentation.fitOverlay}
/> : null}
{!devPanelOpen ? (
<>
<div className="pointer-events-auto absolute right-3 top-10 hidden lg:block">
@@ -1278,7 +1391,9 @@ export function MapWorkbenchPage({
} as CSSProperties
}
>
<div className="flex items-end gap-2 lg:pr-2">
<div className="flex items-end gap-2 lg:pr-2"
onPointerDownCapture={() => { if (investigationFollow) presentation.pauseCamera(); }}
onKeyDownCapture={() => { if (investigationFollow) presentation.pauseCamera(); }}>
<MapZoom
mapRef={mapRef}
mapReady={mapReady}
@@ -1305,8 +1420,8 @@ export function MapWorkbenchPage({
onWidthCommit={setAgentPanelWidth}
/>
{conditionFeedMounted && !devPanelOpen ? (
<div className="absolute bottom-[calc(var(--workbench-timeline-height)+1.25rem)] right-16 top-24 z-50 hidden lg:block 2xl:[--workbench-condition-width:var(--workbench-condition-width-wide)]">
{conditionFeedMounted ? (
<div className="absolute bottom-[calc(var(--workbench-timeline-height)+1.25rem)] right-16 top-24 z-50 hidden lg:block 2xl:[--workbench-condition-width:var(--workbench-condition-width-wide)]" style={devPanelOpen ? { display: "none" } : undefined}>
<ScheduledConditionFeed
presentation="desktop-floating"
conditions={scheduledConditions}
@@ -1322,6 +1437,7 @@ export function MapWorkbenchPage({
onExpandedChange={handleConditionExpandedChange}
onFocusRequestHandled={handleConditionFocusRequestHandled}
onSelectedConditionChange={setSelectedConditionId}
onLocate={handleLocateInvestigationTargets}
onExpandAgent={openAgentWorkspaceForViewport}
onLoadHistorySession={handleLoadAgentHistorySession}
onSubmitPrompt={agent.submitPrompt}
@@ -1347,6 +1463,8 @@ export function MapWorkbenchPage({
controllerState={mapControllerState}
detailFeature={detailFeature}
flowDataSource={flowDataSource}
investigationFollow={investigationFollow}
onInvestigationFollowChange={setInvestigationFollow}
onClose={() => setDevPanelOpen(false)}
/>
) : null}
@@ -1409,7 +1527,7 @@ export function MapWorkbenchPage({
<MapToolbar
items={toolbarItems}
orientation="horizontal"
className="h-11 justify-center"
className="h-11 min-w-0 flex-1 justify-center [&>div]:min-w-0 [&>div]:flex-1 [&>div>div]:hidden [&_button]:w-full"
/>
) : null}
<button
@@ -1473,6 +1591,7 @@ export function MapWorkbenchPage({
onExpandedChange={handleConditionExpandedChange}
onFocusRequestHandled={handleConditionFocusRequestHandled}
onSelectedConditionChange={setSelectedConditionId}
onLocate={handleLocateInvestigationTargets}
onExpandAgent={openAgentPanelForViewport}
onClose={closeMobileSheet}
onLoadHistorySession={handleLoadAgentHistorySession}
@@ -0,0 +1,10 @@
export const waitForAgentPresentation = async (id: string, signal: AbortSignal) => {
const deadline = Date.now() + 5000;
while (Date.now() < deadline) {
signal.throwIfAborted();
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
const element = [...document.querySelectorAll<HTMLElement>("[data-agent-ui-result]")].find((item) => item.dataset.agentUiResult === id);
if (element && element.getBoundingClientRect().height > 0) return;
}
throw new Error("PRESENTATION_NOT_VISIBLE");
};
@@ -0,0 +1,22 @@
import { Marker, type Map as MapLibreMap, type PaddingOptions } from "maplibre-gl";
import type { PlaceSearchItem } from "../api/place-search-client";
export function markGeocodingLocation(map: MapLibreMap, result: PlaceSearchItem) {
const marker = new Marker({ color: "var(--action-blue)" })
.setLngLat([result.longitude, result.latitude]).addTo(map);
const element = marker.getElement();
element.dataset.geocodingMarker = "true";
element.setAttribute("role", "img");
element.setAttribute("aria-label", `地址定位:${result.name} ${result.address}`);
element.title = `${result.name} · ${result.address}`;
return marker;
}
export function locateGeocodingResult(map: MapLibreMap, result: PlaceSearchItem, padding: PaddingOptions, reducedMotion: boolean) {
map.easeTo({
center: [result.longitude, result.latitude],
zoom: 16,
padding,
duration: reducedMotion ? 0 : 500
});
}
@@ -0,0 +1,142 @@
import type { FeatureCollection, Geometry, Position } from "geojson";
import type { GeoJSONSource, Map as MapLibreMap, PaddingOptions, LayerSpecification } from "maplibre-gl";
import type { InvestigationOverlay } from "../api/investigation-presentation";
import { MAP_STYLE_TOKENS } from "./map-colors";
export const INVESTIGATION_HIGHLIGHT_SOURCE = "investigation-presentation-highlights";
export const INVESTIGATION_OVERLAY_SOURCE = "investigation-presentation-overlay";
const empty = (): FeatureCollection => ({ type: "FeatureCollection", features: [] });
const color = MAP_STYLE_TOKENS.state.selected;
const layers: LayerSpecification[] = [
{ id: `${INVESTIGATION_HIGHLIGHT_SOURCE}-fill`, source: INVESTIGATION_HIGHLIGHT_SOURCE, type: "fill", filter: ["==", ["geometry-type"], "Polygon"], paint: { "fill-color": color, "fill-opacity": 0.18 } },
{ id: `${INVESTIGATION_HIGHLIGHT_SOURCE}-line`, source: INVESTIGATION_HIGHLIGHT_SOURCE, type: "line", filter: ["!=", ["geometry-type"], "Point"], paint: { "line-color": color, "line-width": 5, "line-opacity": 0.85 } },
{ id: `${INVESTIGATION_HIGHLIGHT_SOURCE}-point`, source: INVESTIGATION_HIGHLIGHT_SOURCE, type: "circle", filter: ["==", ["geometry-type"], "Point"], paint: { "circle-color": color, "circle-radius": 11, "circle-opacity": 0.65, "circle-stroke-color": MAP_STYLE_TOKENS.canvas.casing, "circle-stroke-width": 2 } },
{ id: `${INVESTIGATION_OVERLAY_SOURCE}-fill`, source: INVESTIGATION_OVERLAY_SOURCE, type: "fill", filter: ["==", ["geometry-type"], "Polygon"], paint: { "fill-color": MAP_STYLE_TOKENS.supply.waterQuality, "fill-opacity": 0.16 } },
{ id: `${INVESTIGATION_OVERLAY_SOURCE}-outline`, source: INVESTIGATION_OVERLAY_SOURCE, type: "line", filter: ["==", ["geometry-type"], "Polygon"], paint: { "line-color": MAP_STYLE_TOKENS.supply.waterQuality, "line-width": 2, "line-dasharray": [2, 2] } },
{ id: `${INVESTIGATION_OVERLAY_SOURCE}-line`, source: INVESTIGATION_OVERLAY_SOURCE, type: "line", filter: ["==", ["geometry-type"], "LineString"], paint: { "line-color": MAP_STYLE_TOKENS.supply.waterQuality, "line-width": 3 } },
{ id: `${INVESTIGATION_OVERLAY_SOURCE}-point`, source: INVESTIGATION_OVERLAY_SOURCE, type: "circle", filter: ["==", ["geometry-type"], "Point"], paint: { "circle-color": MAP_STYLE_TOKENS.supply.waterQuality, "circle-radius": 6, "circle-stroke-color": MAP_STYLE_TOKENS.canvas.casing, "circle-stroke-width": 2 } },
{ id: `${INVESTIGATION_OVERLAY_SOURCE}-heatmap`, source: INVESTIGATION_OVERLAY_SOURCE, type: "heatmap", paint: {
"heatmap-weight": ["coalesce", ["get", "weight"], 1], "heatmap-radius": 28, "heatmap-opacity": 0.65,
"heatmap-color": ["interpolate", ["linear"], ["heatmap-density"], 0, "rgba(15,118,110,0)", 0.3, MAP_STYLE_TOKENS.supply.waterQuality, 0.65, MAP_STYLE_TOKENS.state.risk, 1, MAP_STYLE_TOKENS.state.incident]
} }
];
export function investigationGeometryCoordinates(geometry: Geometry): Position[] {
if (geometry.type === "GeometryCollection") return geometry.geometries.flatMap(investigationGeometryCoordinates);
const flatten = (value: unknown): Position[] => {
if (!Array.isArray(value)) return [];
if (typeof value[0] === "number" && typeof value[1] === "number") return [value as Position];
return value.flatMap(flatten);
};
return flatten(geometry.coordinates);
}
/** All sources and animation state are owned by the currently followed investigation. */
export class InvestigationMapPresentation {
private pulseFrame: number | null = null;
private pulseDeadline: number | null = null;
private highlighted = empty();
private overlay: InvestigationOverlay | null = null;
constructor(private getMap: () => MapLibreMap | null, private getPadding: () => PaddingOptions) {}
private ensure() {
const map = this.getMap();
if (!map) throw new Error("MAP_NOT_READY");
for (const id of [INVESTIGATION_HIGHLIGHT_SOURCE, INVESTIGATION_OVERLAY_SOURCE]) {
if (!map.getSource(id)) map.addSource(id, { type: "geojson", data: empty() });
}
for (const layer of layers) {
if (!map.getLayer(layer.id)) map.addLayer({ ...layer, layout: { visibility: "none" } });
}
return map;
}
highlight(data: FeatureCollection, pulse: boolean, fit: boolean) {
this.stopPulse();
const map = this.ensure();
this.highlighted = data;
(map.getSource(INVESTIGATION_HIGHLIGHT_SOURCE) as GeoJSONSource).setData(data);
this.visibility(INVESTIGATION_HIGHLIGHT_SOURCE, data.features.length > 0);
if (fit) this.fit(data);
if (pulse && !window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
const animate = (time: number) => {
if (!map.getLayer(`${INVESTIGATION_HIGHLIGHT_SOURCE}-point`)) return;
const wave = (Math.sin(time / 300) + 1) / 2;
map.setPaintProperty(`${INVESTIGATION_HIGHLIGHT_SOURCE}-point`, "circle-radius", 9 + wave * 5);
map.setPaintProperty(`${INVESTIGATION_HIGHLIGHT_SOURCE}-line`, "line-opacity", 0.45 + wave * 0.4);
this.pulseFrame = requestAnimationFrame(animate);
};
this.pulseFrame = requestAnimationFrame(animate);
this.pulseDeadline = window.setTimeout(() => this.stopPulse(), 2500);
}
}
stopPulse() {
if (this.pulseFrame !== null) cancelAnimationFrame(this.pulseFrame);
this.pulseFrame = null;
if (this.pulseDeadline !== null) window.clearTimeout(this.pulseDeadline);
this.pulseDeadline = null;
const map = this.getMap();
if (map?.getLayer(`${INVESTIGATION_HIGHLIGHT_SOURCE}-point`)) map.setPaintProperty(`${INVESTIGATION_HIGHLIGHT_SOURCE}-point`, "circle-radius", 11);
if (map?.getLayer(`${INVESTIGATION_HIGHLIGHT_SOURCE}-line`)) map.setPaintProperty(`${INVESTIGATION_HIGHLIGHT_SOURCE}-line`, "line-opacity", 0.85);
}
clearHighlights() {
this.stopPulse();
this.highlighted = empty();
(this.getMap()?.getSource(INVESTIGATION_HIGHLIGHT_SOURCE) as GeoJSONSource | undefined)?.setData(empty());
this.visibility(INVESTIGATION_HIGHLIGHT_SOURCE, false);
}
setOverlay(overlay: InvestigationOverlay, fit: boolean) {
const map = this.ensure();
this.overlay = overlay;
(map.getSource(INVESTIGATION_OVERLAY_SOURCE) as GeoJSONSource).setData(overlay.data);
this.showOverlay(overlay.visible);
if (fit) this.fit(overlay.data);
}
showOverlay(visible: boolean) {
if (!this.overlay) return;
this.overlay = { ...this.overlay, visible };
const map = this.getMap();
for (const layer of layers.filter((item) => "source" in item && item.source === INVESTIGATION_OVERLAY_SOURCE)) {
if (map?.getLayer(layer.id)) map.setLayoutProperty(layer.id, "visibility", visible && (this.overlay.mode === "heatmap") === (layer.type === "heatmap") ? "visible" : "none");
}
}
clearOverlay(id?: string) {
if (id && id !== this.overlay?.overlay_id) return;
this.overlay = null;
(this.getMap()?.getSource(INVESTIGATION_OVERLAY_SOURCE) as GeoJSONSource | undefined)?.setData(empty());
this.visibility(INVESTIGATION_OVERLAY_SOURCE, false);
}
fitOverlay() { if (this.overlay) this.fit(this.overlay.data); }
fitHighlights() { this.fit(this.highlighted); }
private visibility(source: string, visible: boolean) {
const map = this.getMap();
for (const layer of layers.filter((item) => "source" in item && item.source === source)) {
if (map?.getLayer(layer.id)) map.setLayoutProperty(layer.id, "visibility", visible ? "visible" : "none");
}
}
private fit(data: FeatureCollection) {
const positions = data.features.flatMap((feature) => feature.geometry ? investigationGeometryCoordinates(feature.geometry) : []);
if (!positions.length) return;
const bounds = positions.reduce((value, point) => [Math.min(value[0], point[0]), Math.min(value[1], point[1]), Math.max(value[2], point[0]), Math.max(value[3], point[1])], [Infinity, Infinity, -Infinity, -Infinity]);
this.getMap()?.fitBounds([[bounds[0], bounds[1]], [bounds[2], bounds[3]]], { padding: this.getPadding(), maxZoom: 18, duration: 0 });
}
clear() { this.clearHighlights(); this.clearOverlay(); }
destroy() {
this.stopPulse();
const map = this.getMap();
if (!map) return;
for (const layer of [...layers].reverse()) if (map.getLayer(layer.id)) map.removeLayer(layer.id);
for (const id of [INVESTIGATION_HIGHLIGHT_SOURCE, INVESTIGATION_OVERLAY_SOURCE]) if (map.getSource(id)) map.removeSource(id);
}
}
+7 -1
View File
@@ -1,7 +1,13 @@
import { Layers3, ListTree, MapPinned, MoreHorizontal, Ruler } from "lucide-react";
import { Layers3, ListTree, MapPinned, MoreHorizontal, Ruler, Search } from "lucide-react";
import type { MapToolbarItem } from "@/features/map/core";
export const waterNetworkToolbarItems: MapToolbarItem[] = [
{
id: "geocoding",
label: "地址定位",
description: "查询地址并定位到地图",
icon: Search
},
{
id: "layers",
label: "图层",
@@ -168,6 +168,17 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
return { located_ids: found.map(({ id }) => id), missing_ids: missing, fitted: true as const };
}
/** Resolve a task-owned overlay without changing the user's selection or controller highlight. */
async queryPresentationFeatures(sourceId: WaterNetworkSourceId, featureIds: string[], signal: AbortSignal) {
const map = this.options.getMap();
if (!map || !this.options.isReady()) throw new Error("MAP_NOT_READY");
if (!map.getSource(sourceId) || this.availableSourceIds && !this.availableSourceIds.has(sourceId)) throw new Error("FEATURE_NOT_FOUND");
const ids = new Set(featureIds);
const collection = await this.fetchFeatures(sourceId, [...ids], signal);
signal.throwIfAborted();
return { ...collection, features: collection.features.filter((feature) => ids.has(String(feature.properties?.id ?? feature.properties?.device_id ?? feature.id))) };
}
private moveCameraToFeature(map: MapLibreMap, feature: Feature, durationOverride?: number) {
const coordinates = collectCoordinates(feature.geometry);
if (!coordinates.length) {
@@ -12,12 +12,13 @@ import {
} from "lucide-react";
import { Button } from "@/shared/ui/button";
import { DateTimeField } from "@/shared/ui/date-time-field";
import { Disclosure } from "@/shared/ui/disclosure";
import { FieldSelect } from "@/shared/ui/field-select";
import { Input } from "@/shared/ui/input";
import { StatusBadge, type StatusTone } from "@/shared/ui/status";
import { Textarea } from "@/shared/ui/textarea";
import { WorkOrderReportPanel } from "../components/scheduled-condition-detail-panel";
import { WorkbenchEmptyState, WorkbenchNotice, WorkbenchSection } from "../components/workbench-panel-ui";
import { WorkbenchNotice, WorkbenchSection } from "../components/workbench-panel-ui";
import type { ScheduledWorkOrderItem } from "../types";
import type {
PeripheralContext,
@@ -68,7 +69,7 @@ export function ManagedTestResources({ client, canWrite, capabilities, refreshGe
return (
<WorkbenchSection
title="工单接口测试"
description="验证单层预约工单、现场反馈与后端同步。"
description="分别验证预约工单、投诉与爆管事件工单及后端同步。"
icon={<ClipboardList size={15} />}
tone="teal"
bodyClassName="space-y-3"
@@ -220,6 +221,7 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
const canSave = canWrite && !busy && title.trim() && summary.trim();
const editorInputClassName = "mt-1 h-9 rounded-lg border-[var(--surface-divider)] bg-[var(--surface-reading)]";
const editingEvent = editing?.work_order_type !== undefined && editing.work_order_type !== "general";
const orderEditor = editorOpen ? (
<section
@@ -242,13 +244,13 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
<FieldSelect label="优先级" value={priority} onValueChange={(value) => setPriority(value as typeof priority)} disabled={!canWrite || Boolean(busy)} options={PRIORITY_OPTIONS} />
<label className="block text-xs font-medium text-slate-600">负责人<Input className={editorInputClassName} value={assignee} onChange={(event) => setAssignee(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
<label className="block text-xs font-medium text-slate-600">位置<Input className={editorInputClassName} value={location} onChange={(event) => setLocation(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
<DateTimeField label="预约时间" value={scheduledAt} onChange={setScheduledAt} disabled={!canWrite || Boolean(busy)} />
<DateTimeField label={editingEvent ? "发生时间" : "预约时间"} value={scheduledAt} onChange={setScheduledAt} disabled={!canWrite || Boolean(busy)} />
<label className="block text-xs font-medium text-slate-600">场景编号<Input className={editorInputClassName} value={scenarioCode} onChange={(event) => setScenarioCode(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
</div>
{editing ? <p className="mt-2 text-[11px] text-slate-500">工单类型:{workOrderTypeLabel(editing.work_order_type)},创建后不可修改。</p> : null}
<label className="mt-3 block text-xs font-medium text-slate-600">回执要求(每行一项)<Textarea className="mt-1 min-h-20" value={feedbackRequirements} onChange={(event) => setFeedbackRequirements(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
{editing ? <label className="mt-3 block text-xs font-medium text-slate-600">现场反馈(可选)<Textarea className="mt-1 min-h-20" value={progressNote} onChange={(event) => setProgressNote(event.target.value)} disabled={!canWrite || Boolean(busy)} placeholder="记录实际处置、复核或回执;留空则只更新工单信息" /></label> : null}
{editing ? <label className="mt-3 block text-xs font-medium text-slate-600">{editingEvent ? "处置反馈(可选)" : "现场反馈(可选)"}<Textarea className="mt-1 min-h-20" value={progressNote} onChange={(event) => setProgressNote(event.target.value)} disabled={!canWrite || Boolean(busy)} placeholder="记录实际处置、复核或回执;留空则只更新工单信息" /></label> : null}
<div className="mt-3 flex gap-2">
<Button size="sm" disabled={!canSave} onClick={() => void mutate("save-order", async () => {
@@ -295,12 +297,33 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
</section>
) : null;
const orderGroups = [
{
id: "appointment",
title: "预约工单",
description: "按预约时间记录派发、执行、复令和现场反馈。",
items: orders.filter((item) => item.work_order_type === "general")
},
{
id: "complaint",
title: "投诉工单",
description: "记录水质投诉的发生信息,并触发独立的调查任务。",
items: orders.filter((item) => item.work_order_type === "water_quality_complaint")
},
{
id: "burst",
title: "爆管上报工单",
description: "记录爆管事件信息,并触发独立的调查任务。",
items: orders.filter((item) => item.work_order_type === "burst_report")
}
];
return (
<div>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<h4 className="text-xs font-semibold text-slate-800">测试工单</h4>
<p className="mt-0.5 text-[11px] leading-4 text-slate-500">每张预约工单对应一条任务;修改和现场反馈按时间记录。</p>
<h4 className="text-xs font-semibold text-slate-800">分类工单</h4>
<p className="mt-0.5 text-[11px] leading-4 text-slate-500">预约工单进入预约任务;投诉与爆管上报进入事件调查。</p>
</div>
<div className="flex flex-wrap gap-2">
<Button size="sm" disabled={!canWrite || Boolean(busy)} onClick={() => setEventEditorOpen((current) => !current)}>
@@ -341,35 +364,53 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
{editorOpen && !editing ? orderEditor : null}
<section aria-label="工单列表" className="mt-3">
{orders.length ? (
<ol className="space-y-2">
{orders.map((item, index) => (
<li key={item.work_order_id}>
<WorkOrderCard
item={item}
index={index}
canWrite={canWrite}
busy={busy}
editing={editing?.work_order_id === item.work_order_id}
editor={editing?.work_order_id === item.work_order_id ? orderEditor : null}
onEdit={(trigger) => edit(item, trigger)}
confirmingDelete={deleteCandidateId === item.work_order_id}
onRequestDelete={() => setDeleteCandidateId(item.work_order_id)}
onCancelDelete={() => setDeleteCandidateId(null)}
onConfirmDelete={() => void mutate(`delete-order-${item.work_order_id}`, async () => {
await client.deleteWorkOrder(item.work_order_id, item.revision);
setDeleteCandidateId(null);
if (editing?.work_order_id === item.work_order_id) clear();
})}
/>
</li>
))}
</ol>
) : (
<WorkbenchEmptyState icon={<ClipboardList size={15} />} title="暂无测试工单" description="创建预约工单或事件工单后,可在这里核对数据库返回结构。" />
)}
</section>
<div aria-label="分类工单列表" className="mt-3 space-y-3">
{orderGroups.map((group) => (
<Disclosure
key={group.id}
aria-label={`${group.title}列表`}
initiallyOpen
className="rounded-xl border border-slate-200/90 bg-white px-3"
label={(
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<h5 className="text-xs font-semibold text-slate-800">{group.title}</h5>
<p className="mt-0.5 text-[11px] font-normal leading-4 text-slate-500">{group.description}</p>
</div>
<StatusBadge tone="neutral">{group.items.length}</StatusBadge>
</div>
)}
>
{group.items.length ? (
<ol className="space-y-2 border-t border-slate-200/80 py-3">
{group.items.map((item, index) => (
<li key={item.work_order_id}>
<WorkOrderCard
item={item}
index={index}
canWrite={canWrite}
busy={busy}
editing={editing?.work_order_id === item.work_order_id}
editor={editing?.work_order_id === item.work_order_id ? orderEditor : null}
onEdit={(trigger) => edit(item, trigger)}
confirmingDelete={deleteCandidateId === item.work_order_id}
onRequestDelete={() => setDeleteCandidateId(item.work_order_id)}
onCancelDelete={() => setDeleteCandidateId(null)}
onConfirmDelete={() => void mutate(`delete-order-${item.work_order_id}`, async () => {
await client.deleteWorkOrder(item.work_order_id, item.revision);
setDeleteCandidateId(null);
if (editing?.work_order_id === item.work_order_id) clear();
})}
/>
</li>
))}
</ol>
) : (
<p className="mb-3 border-t border-slate-200/80 bg-slate-50 px-3 py-2 text-[11px] text-slate-500">暂无{group.title}</p>
)}
</Disclosure>
))}
</div>
</div>
);
}
@@ -387,7 +428,7 @@ function WorkOrderCard({ item, index, canWrite, busy, editing, editor, onEdit, c
onCancelDelete: () => void;
onConfirmDelete: () => void;
}) {
const workOrder = toScheduledWorkOrder(item);
const workOrder = item.work_order_type === "general" ? toScheduledWorkOrder(item) : null;
const deleteButtonRef = useRef<HTMLButtonElement>(null);
const cancelDelete = () => {
onCancelDelete();
@@ -398,7 +439,7 @@ function WorkOrderCard({ item, index, canWrite, busy, editing, editor, onEdit, c
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-[10px] font-semibold text-slate-400">工单 {String(index + 1).padStart(2, "0")}</span>
<span className="text-[10px] font-semibold text-slate-400">{workOrderTypeLabel(item.work_order_type)} {String(index + 1).padStart(2, "0")}</span>
<StatusBadge tone={orderStatusTone(item.status)}>{orderStatus(item.status)}</StatusBadge>
<StatusBadge tone={item.work_order_type === "burst_report" ? "danger" : item.work_order_type === "water_quality_complaint" ? "warning" : "neutral"}>{workOrderTypeLabel(item.work_order_type)}</StatusBadge>
</div>
@@ -440,16 +481,47 @@ function WorkOrderCard({ item, index, canWrite, busy, editing, editor, onEdit, c
<span>{priorityLabel(item.priority)}优先级</span>
{item.location ? <span>位置:{item.location}</span> : null}
{item.assignee ? <span>负责人:{item.assignee}</span> : null}
{item.scheduled_at ? <span className="inline-flex items-center gap-1"><CalendarClock size={11} />{formatTimestamp(item.scheduled_at)}</span> : null}
{item.scheduled_at ? <span className="inline-flex items-center gap-1"><CalendarClock size={11} />{item.work_order_type === "general" ? "预约" : "发生"}:{formatTimestamp(item.scheduled_at)}</span> : null}
<span className="font-mono">r{item.revision}</span>
</div>
<div className="mt-3">
<WorkOrderReportPanel condition={workOrder} />
{workOrder ? <WorkOrderReportPanel condition={workOrder} /> : <EventWorkOrderDetails item={item} />}
</div>
</article>
);
}
function EventWorkOrderDetails({ item }: { item: PeripheralWorkOrder }) {
return (
<section aria-label={`${workOrderTypeLabel(item.work_order_type)}信息`} className="rounded-xl border border-slate-200 bg-white px-3 py-3">
<p className="text-[10px] font-semibold text-slate-500">事件信息</p>
<dl className="mt-2 grid grid-cols-2 gap-1.5 sm:grid-cols-3">
<EventWorkOrderField label="事件类型" value={workOrderTypeLabel(item.work_order_type)} />
<EventWorkOrderField label="发生时间" value={item.scheduled_at ? formatTimestamp(item.scheduled_at) : "未记录"} />
<EventWorkOrderField label="位置" value={item.location ?? "未填写"} />
<EventWorkOrderField label="场景编号" value={item.scenario_code ?? "未填写"} />
<EventWorkOrderField label="处理人" value={item.assignee ?? "待分配"} />
<EventWorkOrderField label="活动记录" value={`${item.activity?.length ?? 0} 条`} />
</dl>
{item.activity?.length ? (
<ol aria-label="事件活动记录" className="mt-3 space-y-1.5 border-t border-slate-200 pt-3">
{item.activity.map((entry) => (
<li key={entry.sequence} className="rounded-lg bg-slate-50 px-2.5 py-2 text-[11px] leading-4 text-slate-600">
<span className="font-semibold text-slate-800">{entry.title}</span>
<span className="ml-2">{entry.detail}</span>
<span className="mt-1 block text-[10px] text-slate-400">{formatTimestamp(entry.occurred_at)} · {entry.actor}</span>
</li>
))}
</ol>
) : <p className="mt-3 border-t border-slate-200 pt-3 text-[11px] text-slate-500">暂无事件活动记录</p>}
</section>
);
}
function EventWorkOrderField({ label, value }: { label: string; value: string }) {
return <div className="min-w-0 rounded-lg bg-slate-50 px-2.5 py-2"><dt className="text-[10px] font-semibold text-slate-500">{label}</dt><dd className="mt-0.5 break-words text-[11px] font-medium text-slate-800">{value}</dd></div>;
}
function toScheduledWorkOrder(item: PeripheralWorkOrder): ScheduledWorkOrderItem {
return {
id: item.work_order_id,
@@ -501,4 +573,4 @@ function message(error: unknown) { return error instanceof Error ? error.message
function orderStatus(value: PeripheralWorkOrder["status"]) { return ({ draft: "草稿", open: "待处理", in_progress: "处理中", awaiting_reply: "待复令", completed: "已完成", cancelled: "已取消" } satisfies Record<typeof value, string>)[value]; }
function orderStatusTone(value: PeripheralWorkOrder["status"]): StatusTone { return ({ draft: "neutral", open: "info", in_progress: "warning", awaiting_reply: "warning", completed: "success", cancelled: "neutral" } satisfies Record<typeof value, StatusTone>)[value]; }
function priorityLabel(value: PeripheralWorkOrder["priority"]) { return ({ low: "低", normal: "普通", high: "高", urgent: "紧急" } satisfies Record<typeof value, string>)[value]; }
function workOrderTypeLabel(value: PeripheralWorkOrder["work_order_type"]) { return ({ general: "预约/维修", water_quality_complaint: "水质投诉", burst_report: "爆管上报" } satisfies Record<typeof value, string>)[value]; }
function workOrderTypeLabel(value: PeripheralWorkOrder["work_order_type"]) { return ({ general: "预约工单", water_quality_complaint: "投诉工单", burst_report: "爆管上报工单" } satisfies Record<typeof value, string>)[value]; }
@@ -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 "需重点复核";
+5 -5
View File
@@ -14,7 +14,7 @@ export type AuthenticatedUser = {
role: string;
};
export type AccessTokenProvider = () => Promise<string | null>;
export type AccessTokenProvider = (forceRefresh?: boolean) => Promise<string | null>;
export type Authentication = {
enabled: boolean;
@@ -68,9 +68,9 @@ export async function initializeAuthentication(
throw new Error("Keycloak authentication was not completed");
}
const refreshAccessToken = async () => {
const refreshAccessToken = async (forceRefresh = false) => {
try {
await keycloak.updateToken(TOKEN_MIN_VALIDITY_SECONDS);
await keycloak.updateToken(forceRefresh ? -1 : TOKEN_MIN_VALIDITY_SECONDS);
} catch (error) {
await login();
throw error;
@@ -84,8 +84,8 @@ export async function initializeAuthentication(
return {
enabled: true,
user: toAuthenticatedUser(keycloak.idTokenParsed ?? keycloak.tokenParsed),
getAccessToken: async () => {
await refreshAccessToken();
getAccessToken: async (forceRefresh) => {
await refreshAccessToken(forceRefresh);
if (!keycloak.token) {
throw new Error("Keycloak access token is unavailable");
}
+4 -4
View File
@@ -14,7 +14,7 @@ describe("runtime frontend configuration", () => {
TJWATER_MAP_URL: "https://maps.example.test/geoserver",
TJWATER_GEOSERVER_WORKSPACE: "project-a",
TJWATER_SERVER_API_BASE_URL: "https://server.example.test",
TJWATER_AGENT_API_BASE_URL: "https://agent.example.test",
TJWATER_AGENT_URL: "https://agent.example.test",
TJWATER_WORKBENCH_RUNTIME_MODE: "backend",
TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL: "https://peripheral.example.test/",
TJWATER_PERIPHERAL_SAMPLE_TIME_MODE: "recent",
@@ -33,7 +33,7 @@ describe("runtime frontend configuration", () => {
TJWATER_MAP_URL: "https://maps.example.test/geoserver",
TJWATER_GEOSERVER_WORKSPACE: "project-a",
TJWATER_SERVER_API_BASE_URL: "https://server.example.test",
TJWATER_AGENT_API_BASE_URL: "https://agent.example.test",
TJWATER_AGENT_URL: "https://agent.example.test",
TJWATER_WORKBENCH_RUNTIME_MODE: "backend",
TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL: "https://peripheral.example.test",
TJWATER_PERIPHERAL_SAMPLE_TIME_MODE: "recent",
@@ -80,8 +80,8 @@ describe("runtime frontend configuration", () => {
it.each([
["TJWATER_MAP_URL", "file:///etc/passwd"],
["TJWATER_AGENT_API_BASE_URL", "https://user:secret@agent.example.test"],
["TJWATER_AGENT_API_BASE_URL", "https://agent.example.test/#secret"],
["TJWATER_AGENT_URL", "https://user:secret@agent.example.test"],
["TJWATER_AGENT_URL", "https://agent.example.test/#secret"],
["TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL", "https://user:secret@peripheral.example.test"]
])("rejects unsafe browser runtime address %s", (key, value) => {
expect(() => parseRuntimeConfig({ TJWATER_AUTH_MODE: "disabled", [key]: value })).toThrow();
+1 -1
View File
@@ -69,7 +69,7 @@ const runtimeConfigSchema = z
TJWATER_MAP_URL: browserHttpUrl.default("https://geoserver.waternetwork.cn/geoserver"),
TJWATER_GEOSERVER_WORKSPACE: z.string().trim().min(1).default("tjwater_next"),
TJWATER_SERVER_API_BASE_URL: browserHttpUrl.default("http://127.0.0.1:8000"),
TJWATER_AGENT_API_BASE_URL: browserHttpUrl.default("http://127.0.0.1:8787"),
TJWATER_AGENT_URL: browserHttpUrl.default("http://127.0.0.1:8787"),
TJWATER_WORKBENCH_RUNTIME_MODE: z.enum(["auto", "demo", "history", "backend"]).default("auto"),
TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL: optionalBrowserHttpUrl,
TJWATER_PERIPHERAL_SAMPLE_TIME_MODE: z.enum(["demo", "recent"]).default("demo"),
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { formatDateTime, formatTime, formatTimeRange } from "./date-time";
describe("shared UTC+8 display formatting", () => {
it("converts UTC and explicit offsets to the same display time without shifting twice", () => {
expect(formatDateTime("2026-10-08T04:30:00Z")).toBe("2026/10/8 12:30:00");
expect(formatDateTime("2026-10-08T12:30:00+08:00")).toBe("2026/10/8 12:30:00");
expect(formatTime(new Date("2026-10-08T04:30:00Z"))).toBe("12:30");
expect(formatTime(Date.parse("2026-10-08T04:30:00Z"))).toBe("12:30");
});
it("rolls the calendar date forward and keeps midnight as 00:00", () => {
expect(formatDateTime("2026-12-31T16:00:00Z")).toBe("2027/1/1 00:00:00");
expect(formatTimeRange("2026-12-31T15:30:00Z", 60)).toBe("23:30 - 00:30");
});
it("supports compact display options and preserves unparseable labels", () => {
expect(formatDateTime("2026-10-08T04:30:00Z", {
month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit"
})).toBe("10/08 12:30");
expect(formatDateTime("未安排")).toBe("未安排");
});
});
+29
View File
@@ -0,0 +1,29 @@
/** Display only: API/storage timestamps keep their original UTC instant. */
export const DISPLAY_TIME_ZONE = "Asia/Shanghai";
type DateTimeValue = string | number | Date;
type DisplayOptions = Omit<Intl.DateTimeFormatOptions, "timeZone" | "hour12">;
export function formatDateTime(value: DateTimeValue, options: DisplayOptions = {
year: "numeric", month: "numeric", day: "numeric",
hour: "2-digit", minute: "2-digit", second: "2-digit"
}) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
return new Intl.DateTimeFormat("zh-CN", {
...options, timeZone: DISPLAY_TIME_ZONE, hour12: false
}).format(date);
}
export function formatTime(value: DateTimeValue) {
return formatDateTime(value, { hour: "2-digit", minute: "2-digit" });
}
export function formatTimeRange(value: string, durationMinutes: number | undefined) {
const startTime = formatTime(value);
const timestamp = Date.parse(value);
if (typeof durationMinutes !== "number" || !Number.isFinite(durationMinutes) || Number.isNaN(timestamp)) {
return startTime;
}
return `${startTime} - ${formatTime(timestamp + durationMinutes * 60_000)}`;
}
+362 -131
View File
@@ -135,7 +135,6 @@
--agent-user-message-surface: color-mix(in srgb, var(--action-selection-soft) 92%, transparent);
--agent-user-message-border: var(--action-selection-border);
--agent-white-acrylic-surface: color-mix(in srgb, var(--palette-paper) 86%, transparent);
--agent-operational-surface: color-mix(in srgb, var(--surface-subtle) 86%, transparent);
--acrylic-panel-blur: 24px;
--acrylic-control-blur: 18px;
--acrylic-backdrop-saturation: 1.08;
@@ -161,7 +160,6 @@
--shadow-agent-floating:
0 18px 42px rgba(15, 33, 55, 0.18), 0 4px 12px rgba(15, 33, 55, 0.1),
inset 0 1px 0 rgba(255, 255, 255, 0.72);
--shadow-agent-operational: 0 12px 32px rgba(15, 33, 55, 0.14), 0 3px 10px rgba(15, 33, 55, 0.08);
--surface-dock: var(--surface-subtle);
--surface-control: color-mix(in srgb, var(--surface-subtle) 92%, transparent);
--surface-well: color-mix(in srgb, var(--surface-panel) 66%, transparent);
@@ -239,13 +237,11 @@
--shadow-agent-floating:
0 20px 46px rgba(7, 18, 32, 0.26), 0 5px 14px rgba(7, 18, 32, 0.16),
inset 0 1px 0 rgba(255, 255, 255, 0.68);
--shadow-agent-operational: 0 14px 36px rgba(7, 18, 32, 0.2), 0 4px 12px rgba(7, 18, 32, 0.12);
--agent-floating-reading-item: color-mix(in srgb, var(--palette-paper) 78%, transparent);
--agent-floating-reading-item-hover: color-mix(in srgb, var(--palette-paper) 92%, transparent);
--agent-floating-reading-active: color-mix(in srgb, var(--action-selection-soft) 88%, transparent);
--agent-user-message-surface: color-mix(in srgb, var(--action-selection-soft) 94%, transparent);
--agent-white-acrylic-surface: color-mix(in srgb, var(--palette-paper) 90%, transparent);
--agent-operational-surface: color-mix(in srgb, var(--surface-subtle) 90%, transparent);
--surface-control: color-mix(in srgb, var(--surface-subtle) 96%, transparent);
--surface-well: color-mix(in srgb, var(--surface-panel) 78%, transparent);
--surface-reading: color-mix(in srgb, var(--palette-paper) 98.5%, transparent);
@@ -584,7 +580,6 @@ button:disabled {
--agent-floating-reading-active: var(--action-selection-soft);
--agent-user-message-surface: var(--action-selection-soft);
--agent-white-acrylic-surface: var(--surface-reading);
--agent-operational-surface: var(--surface-subtle);
--agent-floating-acrylic-backdrop: none;
}
@@ -614,7 +609,6 @@ button:disabled {
--agent-floating-reading-active: var(--action-selection-soft);
--agent-user-message-surface: var(--action-selection-soft);
--agent-white-acrylic-surface: var(--surface-reading);
--agent-operational-surface: var(--surface-subtle);
--agent-floating-acrylic-backdrop: none;
}
@@ -638,7 +632,6 @@ button:disabled {
--agent-user-message-surface: Canvas;
--agent-user-message-border: CanvasText;
--agent-white-acrylic-surface: Canvas;
--agent-operational-surface: Canvas;
--agent-floating-acrylic-backdrop: none;
}
@@ -723,7 +716,6 @@ button:disabled {
padding-top: 2rem;
}
.agent-panel-operational-float > .agent-panel-control,
.agent-panel-history-extension .agent-panel-control,
.agent-panel-composer > .agent-panel-control {
border: 0;
@@ -731,10 +723,6 @@ button:disabled {
box-shadow: none;
}
.agent-panel-operational-float > .agent-panel-control {
border-radius: inherit;
}
.agent-panel-history-extension {
border: 0;
border-radius: 0 0 var(--radius-panel) var(--radius-panel);
@@ -748,12 +736,10 @@ button:disabled {
background-color: var(--agent-white-acrylic-surface);
}
.agent-panel-operational-float,
.agent-panel-composer {
inset-inline: calc(1rem + var(--scrollbar-size) + 1px);
}
.agent-panel-operational-float,
.agent-panel-composer,
.agent-conversation-scroll-button,
.agent-speech-selection-action {
@@ -765,18 +751,8 @@ button:disabled {
outline-offset: -1px;
}
.agent-panel-operational-float {
top: 4.75rem;
background-color: var(--agent-operational-surface);
background-clip: padding-box;
box-shadow: var(--shadow-agent-operational);
backdrop-filter: var(--agent-floating-acrylic-backdrop);
-webkit-backdrop-filter: var(--agent-floating-acrylic-backdrop);
}
.agent-panel-history-extension .agent-history-item,
.agent-panel-history-extension .agent-history-state,
.agent-panel-operational-float .agent-brief-metric {
.agent-panel-history-extension .agent-history-state {
background-color: var(--agent-floating-reading-item);
}
@@ -820,10 +796,6 @@ button.agent-history-session-trigger:not(:disabled):active {
padding-bottom: 10.5rem;
}
.agent-panel-conversation-content-has-brief {
padding-top: 13rem;
}
.agent-panel-conversation-content-empty {
padding-bottom: 13.5rem;
}
@@ -852,8 +824,7 @@ button.agent-history-session-trigger:not(:disabled):active {
}
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
.agent-panel-floating-acrylic,
.agent-panel-operational-float {
.agent-panel-floating-acrylic {
background-image: none;
backdrop-filter: none;
-webkit-backdrop-filter: none;
@@ -861,8 +832,7 @@ button.agent-history-session-trigger:not(:disabled):active {
}
@media (prefers-reduced-transparency: reduce) {
.agent-panel-floating-acrylic,
.agent-panel-operational-float {
.agent-panel-floating-acrylic {
background-image: none;
backdrop-filter: none;
-webkit-backdrop-filter: none;
@@ -875,8 +845,7 @@ button.agent-history-session-trigger:not(:disabled):active {
box-shadow: none;
}
.agent-panel-floating-acrylic,
.agent-panel-operational-float {
.agent-panel-floating-acrylic {
forced-color-adjust: none;
border-color: CanvasText;
background-color: Canvas;
@@ -935,28 +904,16 @@ button.agent-history-session-trigger:not(:disabled):active {
-webkit-backdrop-filter: none;
}
.agent-ready-orbit,
.agent-ready-core {
transform-box: fill-box;
transform-origin: center;
}
.agent-ready-orbit {
animation: agent-ready-orbit 9s linear infinite;
}
.agent-ready-core {
animation: agent-ready-core 2.8s ease-in-out infinite;
}
.agent-panel-icon-button {
.agent-panel-icon-button,
.agent-project-trigger {
border: 1px solid var(--surface-divider);
background: var(--surface-control);
box-shadow: var(--action-shadow-secondary);
}
@media (hover: hover) {
.agent-panel-icon-button:hover {
.agent-panel-icon-button:hover,
.agent-project-trigger:hover {
border-color: var(--action-border-hover);
background: var(--action-soft);
color: var(--action-blue-hover);
@@ -967,7 +924,7 @@ button.agent-history-session-trigger:not(:disabled):active {
border: 0;
background: var(--action-blue);
color: #ffffff;
box-shadow: var(--action-shadow-primary);
box-shadow: 0 2px 6px rgba(70, 95, 145, 0.22);
}
@media (hover: hover) {
@@ -979,19 +936,72 @@ button.agent-history-session-trigger:not(:disabled):active {
}
.agent-panel-primary-icon:disabled {
background: var(--palette-brand);
background: var(--action-blue);
color: rgba(255, 255, 255, 0.86);
box-shadow: none;
}
.agent-panel-shell,
.agent-panel-overlay {
--agent-palette-shell: #E8ECF3;
--agent-palette-reading: #F8F9FC;
--agent-palette-accent: #465F91;
--agent-palette-accent-hover: #394E7C;
--agent-palette-accent-pressed: #2F416B;
--agent-palette-selection: #DDE4F1;
--agent-input-placeholder: #8791A3;
--surface-panel: #E0E5EF;
--surface-subtle: var(--agent-palette-shell);
--surface-control: color-mix(in srgb, var(--agent-palette-shell) 92%, transparent);
--surface-reading: var(--agent-palette-reading);
--action-blue: var(--agent-palette-accent);
--action-blue-hover: var(--agent-palette-accent-hover);
--action-blue-pressed: var(--agent-palette-accent-pressed);
--action-soft: #E4E8F1;
--action-soft-pressed: #D8DFEB;
--action-selection-soft: var(--agent-palette-selection);
--action-selection-soft-hover: #CFD9EA;
--action-selection-soft-pressed: #C2CEE2;
--action-selection-border: #7585AA;
--action-border-hover: var(--agent-palette-accent);
--action-border-pressed: var(--agent-palette-accent-hover);
--action-focus-ring: var(--agent-palette-accent-hover);
--field-focus-border: var(--agent-palette-accent);
--action-focus-offset: var(--agent-palette-reading);
--action-shadow-primary: 0 5px 12px rgba(70, 95, 145, 0.2);
--action-shadow-primary-pressed: 0 1px 3px rgba(47, 65, 107, 0.18);
--ink-muted: #4E596D;
--acrylic-navigation: color-mix(in srgb, #E4E9F2 72%, transparent);
--agent-floating-reading-surface: color-mix(in srgb, #E6EBF3 86%, transparent);
--agent-floating-reading-item: color-mix(in srgb, var(--agent-palette-reading) 76%, transparent);
--agent-floating-reading-item-hover: color-mix(in srgb, var(--agent-palette-reading) 92%, transparent);
--agent-floating-reading-active: color-mix(in srgb, var(--agent-palette-selection) 90%, transparent);
--agent-user-message-surface: var(--agent-palette-selection);
--agent-user-message-border: #C7D1E3;
--agent-white-acrylic-surface: color-mix(in srgb, #F0F3F8 88%, transparent);
}
.agent-panel-prompt-textarea::placeholder {
color: var(--agent-input-placeholder);
font-weight: 400;
opacity: 1;
}
.agent-panel-shell[data-agent-presentation="desktop-dock"],
.agent-panel-overlay[data-agent-presentation="desktop-dock"] {
--agent-dock-surface: var(--surface-subtle);
--agent-dock-band: color-mix(in srgb, var(--palette-paper) 55%, transparent);
--agent-dock-surface: var(--agent-palette-shell);
--agent-dock-reading: var(--agent-palette-reading);
--agent-dock-band: var(--agent-white-acrylic-surface);
--agent-dock-control-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.9), 0 1px 2px rgba(15, 23, 42, 0.04);
--agent-dock-overlay-surface: color-mix(in srgb, var(--palette-paper) 94%, transparent);
--agent-dock-overlay-backdrop: blur(12px) saturate(1.03);
--agent-dock-overlay-shadow: 0 6px 16px rgba(15, 23, 42, 0.08);
inset 0 1px 0 rgba(255, 255, 255, 0.9), 0 1px 2px rgba(43, 55, 87, 0.05);
--agent-dock-overlay-surface: var(--agent-floating-reading-surface);
--agent-dock-overlay-backdrop: var(--agent-floating-acrylic-backdrop);
--agent-dock-overlay-shadow: 0 6px 16px rgba(43, 55, 87, 0.1);
--agent-dock-acrylic-texture:
linear-gradient(var(--acrylic-exclusion), var(--acrylic-exclusion)),
linear-gradient(var(--acrylic-luminosity), var(--acrylic-luminosity)), var(--acrylic-noise);
--agent-dock-acrylic-shadow:
inset 0 1px 0 var(--acrylic-highlight), 0 2px 6px rgba(43, 55, 87, 0.07);
--agent-user-message-surface: var(--action-selection-soft);
--agent-user-message-border: transparent;
--agent-floating-reading-item: transparent;
@@ -1005,8 +1015,23 @@ button.agent-history-session-trigger:not(:disabled):active {
border-color: var(--surface-divider);
}
.agent-panel-shell[data-agent-presentation="desktop-dock"]:where([data-agent-starting="true"]) {
--agent-dock-surface: #EFF2F7;
--agent-dock-reading: #FCFCFE;
}
.agent-panel-shell[data-agent-presentation="desktop-dock"] :is(.agent-panel-header, .agent-panel-band),
.agent-panel-overlay[data-agent-presentation="desktop-dock"] {
background-image: var(--agent-dock-acrylic-texture);
background-blend-mode: exclusion, luminosity, soft-light;
background-clip: padding-box;
backdrop-filter: var(--agent-dock-overlay-backdrop);
-webkit-backdrop-filter: var(--agent-dock-overlay-backdrop);
}
.agent-panel-shell[data-agent-presentation="desktop-dock"] .agent-panel-header {
box-shadow: none;
background-color: var(--acrylic-navigation);
box-shadow: inset 0 -1px 0 var(--surface-divider);
}
.agent-panel-shell[data-agent-presentation="desktop-dock"] .agent-panel-header > div:first-child {
@@ -1014,18 +1039,16 @@ button.agent-history-session-trigger:not(:disabled):active {
}
.agent-panel-shell[data-agent-presentation="desktop-dock"] .agent-panel-band {
background: var(--agent-dock-band);
}
.agent-panel-shell[data-agent-presentation="desktop-dock"] .agent-panel-band {
background-color: var(--agent-dock-band);
border-color: var(--surface-divider);
box-shadow: inset 0 1px 0 var(--acrylic-highlight);
}
.agent-panel-shell[data-agent-presentation="desktop-dock"] .agent-panel-conversation {
background: transparent;
background: var(--agent-dock-reading);
}
.agent-panel-shell[data-agent-presentation="desktop-dock"] .agent-panel-band > .agent-panel-conversation {
.agent-panel-shell[data-agent-presentation="desktop-dock"] .agent-panel-conversation[data-agent-conversation-state="empty"] {
background: var(--agent-dock-surface);
}
@@ -1035,14 +1058,172 @@ button.agent-history-session-trigger:not(:disabled):active {
}
.agent-panel-shell[data-agent-presentation="desktop-dock"] .agent-panel-icon-button,
.agent-panel-shell[data-agent-presentation="desktop-dock"] form.agent-panel-control {
.agent-panel-shell[data-agent-presentation="desktop-dock"] .agent-project-trigger {
box-shadow: var(--agent-dock-control-shadow);
}
.agent-panel-shell[data-agent-presentation="desktop-dock"] form.agent-panel-control:focus-within {
.agent-panel-shell[data-agent-presentation="desktop-dock"] .agent-panel-input-form,
.agent-panel-shell[data-agent-presentation="desktop-dock"] .agent-panel-input-form > [data-slot="input-group"] {
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
outline: none;
}
.agent-panel-shell[data-agent-presentation="desktop-dock"] .agent-panel-input-form > [data-slot="input-group"] {
gap: 8px;
}
.agent-panel-shell[data-agent-presentation="desktop-dock"] .agent-panel-input-body {
display: block;
width: 100%;
overflow: hidden;
border: 1px solid var(--surface-border);
border-radius: 12px;
background: var(--agent-dock-reading);
transition: background-color 180ms ease;
}
.agent-panel-shell[data-agent-presentation="desktop-dock"] .agent-panel-input-body:focus-within {
border-color: var(--field-focus-border);
}
.agent-panel-shell[data-agent-presentation="desktop-dock"] .agent-panel-input-tools {
min-height: 40px;
padding: 0 4px;
background: transparent;
}
.agent-panel-shell[data-agent-presentation="desktop-dock"] .agent-panel-input-tools :where(button) {
min-height: 40px;
min-width: 40px;
}
.agent-panel-shell[data-agent-presentation="desktop-dock"][data-agent-starting="false"] .agent-panel-band {
padding: 8px 12px;
}
.agent-panel-shell[data-agent-presentation="desktop-dock"][data-agent-starting="false"] .agent-panel-input-form > [data-slot="input-group"] {
gap: 4px;
}
.agent-panel-shell[data-agent-presentation="desktop-dock"][data-agent-starting="false"] .agent-panel-input-form textarea {
min-height: 96px;
max-height: clamp(96px, 24dvh, 192px);
padding: 12px;
font-size: 14px;
line-height: 20px;
}
.agent-panel-shell[data-agent-presentation="desktop-dock"][data-agent-starting="false"] .agent-panel-input-tools {
padding-block: 0;
}
.agent-panel-shell[data-agent-starting="true"] .agent-panel-main-content {
display: none;
}
.agent-panel-shell[data-agent-starting="true"] .agent-panel-start-composer {
position: relative;
inset: auto;
z-index: auto;
align-self: center;
flex-shrink: 0;
width: calc(100% - 24px);
max-width: 600px;
margin-block: auto;
padding: 0;
border: 0;
background: transparent;
box-shadow: none;
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
.agent-panel-shell[data-agent-presentation="desktop-dock"] .agent-panel-start-composer .agent-panel-input-form {
padding: 16px;
border-radius: 16px;
background-color: var(--agent-dock-band);
background-image: var(--agent-dock-acrylic-texture);
background-blend-mode: exclusion, luminosity, soft-light;
box-shadow: var(--agent-dock-acrylic-shadow);
backdrop-filter: var(--agent-dock-overlay-backdrop);
-webkit-backdrop-filter: var(--agent-dock-overlay-backdrop);
}
.agent-panel-shell[data-agent-presentation="desktop-dock"] .agent-panel-composer-motion,
.agent-panel-shell[data-agent-presentation="desktop-dock"] .agent-panel-input-form,
.agent-panel-shell[data-agent-presentation="desktop-dock"] .agent-panel-input-form textarea {
transition-duration: 280ms;
transition-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
}
.agent-panel-shell[data-agent-presentation="desktop-dock"] .agent-panel-input-form {
transition-property: padding, border-radius, background-color, box-shadow;
}
.agent-panel-shell[data-agent-presentation="desktop-dock"] .agent-panel-input-form textarea {
transition-property: min-height, padding, font-size, line-height;
}
.agent-panel-shell[data-agent-presentation="desktop-dock"] .agent-panel-start-composer textarea {
min-height: 160px;
max-height: clamp(160px, calc(100dvh - 480px), 240px);
padding: 16px;
font-size: 16px;
line-height: 24px;
}
@media (prefers-reduced-motion: reduce) {
.agent-panel-shell[data-agent-presentation="desktop-dock"] .agent-panel-composer-motion,
.agent-panel-shell[data-agent-presentation="desktop-dock"] .agent-panel-input-form,
.agent-panel-shell[data-agent-presentation="desktop-dock"] .agent-panel-input-form textarea {
transition: none;
}
}
.agent-panel-start-heading {
margin-bottom: 20px;
text-align: center;
font-size: 22px;
font-weight: 600;
line-height: 32px;
color: var(--ink);
}
.agent-panel-start-suggestions {
min-height: 204px;
margin-top: 16px;
}
.agent-panel-start-suggestion {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
min-height: 68px;
padding: 8px 12px;
border-radius: 8px;
background: transparent;
text-align: left;
color: var(--ink-muted);
}
@media (hover: hover) {
.agent-panel-start-suggestion:hover {
background: var(--action-soft);
color: var(--action-blue-hover);
}
}
.agent-panel-suggestion-status {
padding: 12px;
font-size: 12px;
line-height: 20px;
color: var(--ink-muted);
}
.agent-panel-shell[data-agent-presentation="desktop-dock"] :where(button) {
border-radius: 10px;
}
@@ -1053,36 +1234,23 @@ button.agent-history-session-trigger:not(:disabled):active {
translate: var(--tw-translate-x, 0) 1px;
}
.agent-panel-shell[data-agent-presentation="desktop-dock"] .agent-panel-icon-button:active {
.agent-panel-shell[data-agent-presentation="desktop-dock"] .agent-panel-icon-button:active,
.agent-panel-shell[data-agent-presentation="desktop-dock"] .agent-project-trigger:active {
background: var(--action-soft-pressed);
color: var(--action-blue-pressed);
box-shadow: inset 0 1px 2px rgba(15, 23, 42, 0.08);
}
.agent-panel-shell[data-agent-presentation="desktop-dock"] .agent-panel-band .surface-reading {
border-color: var(--surface-border);
background: var(--agent-floating-reading-item);
box-shadow: none;
}
.agent-panel-shell[data-agent-presentation="desktop-dock"] .agent-panel-suggestions .surface-reading {
border-color: var(--surface-divider);
box-shadow: none;
}
.agent-panel-shell[data-agent-presentation="desktop-dock"] .agent-empty-mark {
background: var(--agent-dock-band);
box-shadow: inset 0 0 0 1px var(--surface-divider);
}
.agent-panel-shell[data-agent-presentation="desktop-dock"] .agent-capabilities {
border-radius: 0;
background: transparent;
box-shadow: none;
}
.agent-panel-shell[data-agent-presentation="desktop-dock"] .agent-capability {
min-height: 72px;
border: 0;
}
.agent-panel-shell[data-agent-presentation="desktop-dock"] .agent-capability-icon {
border-radius: 10px;
background: var(--agent-dock-band);
box-shadow: none;
background-color: var(--agent-white-acrylic-surface);
box-shadow: var(--agent-dock-acrylic-shadow);
}
.agent-panel-shell[data-agent-presentation="desktop-dock"] .agent-panel-message,
@@ -1096,40 +1264,38 @@ button.agent-history-session-trigger:not(:disabled):active {
background: transparent;
}
.agent-panel-shell[data-agent-presentation="desktop-dock"] [data-agent-ui-result] {
border: 1px solid var(--surface-divider);
background: var(--surface-reading);
}
.agent-panel-shell[data-agent-presentation="desktop-dock"] .agent-panel-operational-brief {
border: 0;
background: var(--agent-dock-band);
.agent-panel-shell[data-agent-presentation="desktop-dock"] [data-agent-progress] {
border-color: var(--surface-divider);
background: var(--agent-dock-surface);
box-shadow: none;
}
.agent-panel-shell[data-agent-presentation="desktop-dock"] .agent-brief-metric {
border: 0;
background: transparent;
.agent-panel-shell[data-agent-presentation="desktop-dock"] [data-agent-ui-result] {
border: 1px solid var(--surface-divider);
background: var(--surface-reading);
}
.agent-panel-overlay[data-agent-presentation="desktop-dock"] {
border: 0;
border-radius: 12px;
background-color: var(--agent-dock-overlay-surface);
background-image: none;
box-shadow: var(--agent-dock-overlay-shadow);
box-shadow: inset 0 1px 0 var(--acrylic-highlight), var(--agent-dock-overlay-shadow);
backdrop-filter: var(--agent-dock-overlay-backdrop);
-webkit-backdrop-filter: var(--agent-dock-overlay-backdrop);
}
.agent-panel-shell[data-agent-presentation="desktop-dock"] .agent-panel-header-expanded {
background-color: var(--agent-dock-overlay-surface);
border-color: var(--surface-divider);
box-shadow: var(--agent-dock-overlay-shadow);
box-shadow: var(--shadow-agent-history-bottom);
}
.agent-panel-shell[data-agent-presentation="desktop-dock"] .agent-panel-history-extension {
border-radius: 0 0 12px 12px;
background: transparent;
box-shadow: none;
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
.agent-panel-shell[data-agent-presentation="desktop-dock"] .agent-panel-history-extension .agent-panel-control {
@@ -1139,16 +1305,20 @@ button.agent-history-session-trigger:not(:disabled):active {
}
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
.agent-panel-shell[data-agent-presentation="desktop-dock"],
.agent-panel-overlay[data-agent-presentation="desktop-dock"] {
--agent-dock-overlay-surface: var(--palette-paper);
--agent-dock-overlay-backdrop: none;
--agent-dock-acrylic-texture: none;
}
}
@media (prefers-reduced-transparency: reduce) {
.agent-panel-shell[data-agent-presentation="desktop-dock"],
.agent-panel-overlay[data-agent-presentation="desktop-dock"] {
--agent-dock-overlay-surface: var(--palette-paper);
--agent-dock-overlay-backdrop: none;
--agent-dock-acrylic-texture: none;
}
}
@@ -1156,11 +1326,16 @@ button.agent-history-session-trigger:not(:disabled):active {
.agent-panel-shell[data-agent-presentation="desktop-dock"],
.agent-panel-overlay[data-agent-presentation="desktop-dock"] {
--agent-dock-surface: Canvas;
--agent-dock-reading: Canvas;
--agent-dock-band: Canvas;
--agent-dock-control-shadow: none;
--agent-dock-overlay-surface: Canvas;
--agent-dock-overlay-backdrop: none;
--agent-dock-overlay-shadow: none;
--agent-dock-acrylic-texture: none;
--agent-dock-acrylic-shadow: none;
--acrylic-highlight: transparent;
--acrylic-navigation: Canvas;
--surface-control: Canvas;
--surface-reading: Canvas;
--surface-border: CanvasText;
@@ -1281,6 +1456,57 @@ button.agent-history-session-trigger:not(:disabled):active {
border-top: 1px solid var(--surface-divider);
}
.investigation-report > .investigation-report-section {
margin: 0.75rem;
border: 1px solid var(--surface-divider);
border-radius: 0.75rem;
background: var(--palette-paper);
}
.investigation-card-heading,
.investigation-detail-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.investigation-detail-backdrop {
position: fixed;
inset: 0;
z-index: 90;
background: color-mix(in srgb, var(--palette-paper) 76%, transparent);
}
.investigation-detail-positioner {
position: fixed;
inset: 0;
z-index: 91;
display: grid;
place-items: center;
pointer-events: none;
}
.investigation-detail-panel {
display: flex;
flex-direction: column;
width: min(44rem, calc(100vw - 2rem));
max-height: min(86dvh, 56rem);
min-height: 0;
overflow: hidden;
pointer-events: auto;
border: 1px solid var(--surface-divider);
background: var(--palette-paper);
box-shadow: 0 16px 48px rgb(38 51 77 / 12%);
}
.investigation-detail-panel-wide { width: min(70rem, calc(100vw - 2rem)); }
.investigation-detail-heading { flex-shrink: 0; padding: 1rem; border-bottom: 1px solid var(--surface-divider); }
.investigation-detail-scroll { min-height: 0; overflow: auto; padding: 1rem; overscroll-behavior: contain; scrollbar-gutter: stable; overflow-wrap: anywhere; }
.investigation-detail-scroll > .investigation-report-section { padding: 0; border: 0; }
.investigation-evidence-preview { display: grid; gap: 0.75rem; margin-top: 0.5rem; }
.investigation-conclusion-preview { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.investigation-report-update {
display: flex;
flex-wrap: wrap;
@@ -1340,6 +1566,8 @@ button.agent-history-session-trigger:not(:disabled):active {
.investigation-diagram {
min-width: 0;
/* SVG replacement must not become the report reader's scroll anchor. */
overflow-anchor: none;
overflow: hidden;
border: 1px solid var(--surface-divider);
border-radius: 0.75rem;
@@ -1357,6 +1585,34 @@ button.agent-history-session-trigger:not(:disabled):active {
--diagram-control-rail-inset: 1.0625rem;
--diagram-control-group-gap: 0.5rem;
--diagram-zoom-rail-height: 7.625rem;
margin-block: 0;
}
/* Natural graph height, with only enough room for the existing control rail. */
.investigation-diagram [data-streamdown="mermaid"] > div {
min-block-size: calc(var(--diagram-zoom-rail-height) + 1rem);
}
.investigation-diagram [data-streamdown="mermaid"] [role="img"] > svg {
width: 100%;
height: auto;
animation: investigation-diagram-reveal 180ms cubic-bezier(0.22, 1, 0.36, 1);
}
.investigation-diagram [data-streamdown="mermaid"] [role="img"] {
/* Reserve the existing right-hand control rail when fitting the graph. */
padding-inline-end: 3rem;
}
@keyframes investigation-diagram-reveal {
from { opacity: 0.55; }
to { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.investigation-diagram [data-streamdown="mermaid"] [role="img"] > svg {
animation: none;
}
}
/* Match Button's compact icon-sm size, including controls portalled to body. */
@@ -1725,25 +1981,6 @@ button[type="button"]:has(> .investigation-diagram-control-icon):disabled {
}
}
@keyframes agent-ready-orbit {
to {
transform: rotate(360deg);
}
}
@keyframes agent-ready-core {
0%,
100% {
opacity: 0.72;
transform: scale(0.94);
}
50% {
opacity: 1;
transform: scale(1.04);
}
}
@media (prefers-reduced-motion: reduce) {
.agent-panel-enter,
.agent-panel-collapse,
@@ -1780,12 +2017,6 @@ button[type="button"]:has(> .investigation-diagram-control-icon):disabled {
transform: none;
}
.agent-ready-orbit,
.agent-ready-core {
animation: none;
opacity: 1;
transform: none;
}
}
details[open] > summary > .disclosure-accordion-indicator {
+63 -33
View File
@@ -42,7 +42,6 @@ test("desktop Agent docks beside the workspace with a solid reading surface", as
const workspace = page.locator("#main-workspace");
const agentPanel = page.locator('aside[aria-label="Agent 命令面板"]');
const agentHeader = agentPanel.locator(".agent-panel-header");
const agentConversation = agentPanel.getByRole("log");
const agentConversationScroll = agentPanel.locator(".agent-conversation-scroll");
const agentControls = agentPanel.locator(".agent-panel-band").last();
@@ -63,28 +62,24 @@ test("desktop Agent docks beside the workspace with a solid reading surface", as
await expect(agentPanel).toHaveCSS("border-right-width", "1px");
await expectThemeColor(agentPanel, "border-right-color", "--surface-divider");
await expect(agentPanel).toHaveCSS("box-shadow", "none");
await expect(agentControls).toHaveCSS("border-top-width", "1px");
await expectThemeColor(agentControls, "background-color", "--agent-dock-band");
await expectThemeColor(agentPanel.locator("form.agent-panel-control"), "background-color", "--surface-reading");
const capabilities = agentPanel.getByRole("list", { name: "Agent 分析能力" });
await expect(capabilities.getByRole("listitem")).toHaveCount(4);
await expect(capabilities).toHaveCSS("box-shadow", "none");
for (const item of await capabilities.getByRole("listitem").all()) {
await expect(item).toHaveCSS("border-right-width", "0px");
await expect(item).toHaveCSS("border-bottom-width", "0px");
}
await expect(
agentPanel.getByText("直接说出你想调查的问题,我会整理监测与空间证据,并将分析结果带回地图。")
).toHaveCSS("color", "rgb(51, 65, 85)");
await expect(agentPanel.getByText("水力瓶颈诊断")).toHaveCSS("color", "rgb(30, 41, 59)");
await expect(agentPanel.getByText("水力瓶颈诊断")).toHaveCSS("font-size", "14px");
await expect(agentPanel).toHaveAttribute("data-agent-starting", "true");
await expect(agentControls).toHaveCSS("border-top-width", "0px");
await expect(agentControls).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
await expectAcrylicSurface(agentPanel.locator("form.agent-panel-control"));
const inputBody = agentPanel.locator(".agent-panel-input-body");
const inputTools = agentPanel.locator(".agent-panel-input-tools");
await expectThemeColor(inputBody, "background-color", "--agent-dock-reading");
await expectThemeColor(inputBody, "border-color", "--surface-border");
await expect(inputBody).toHaveCSS("display", "block");
await expect(inputTools).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
const startComposer = agentPanel.locator(".agent-panel-start-composer");
const suggestions = agentPanel.getByRole("group", { name: "推荐问题" });
await expect(suggestions.getByRole("button")).toHaveCount(3);
await expect(agentPanel.getByRole("heading", { name: "我能帮你处理什么?" })).toBeVisible();
await expect(agentPanel.locator(".agent-panel-main-content")).toBeHidden();
const panelBox = await agentPanel.boundingBox();
const conversationBox = await agentConversation.boundingBox();
const scrollBox = await agentConversationScroll.boundingBox();
expect(panelBox).not.toBeNull();
expect(conversationBox).not.toBeNull();
expect(scrollBox).not.toBeNull();
expect(panelBox!.x).toBe(0);
const topBarBox = await topBar.boundingBox();
expect(panelBox!.y).toBe(topBarBox!.y + topBarBox!.height);
@@ -97,17 +92,27 @@ test("desktop Agent docks beside the workspace with a solid reading surface", as
expect(headerBox!.y).toBe(topBarBox!.y + topBarBox!.height);
expect(workspaceBox!.y).toBe(topBarBox!.y);
await expect(topBar).toHaveCSS("box-shadow", "none");
await expect(agentHeader).toHaveCSS("box-shadow", "none");
await expectAcrylicSurface(agentHeader);
await expect(agentHeader.locator(":scope > div").first()).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
expect(await topBar.evaluate((element) => getComputedStyle(element).borderBottomColor)).toBe(
await agentPanel.evaluate((element) => getComputedStyle(element).borderRightColor)
);
expect(Math.abs(conversationBox!.x - panelBox!.x)).toBeLessThanOrEqual(1);
await expect(agentConversationScroll).toHaveCSS("scrollbar-gutter", "stable both-edges");
const beforeFocus = await getSurfacePresentation(agentPanel);
await page.getByPlaceholder("输入调度问题,Agent 将通过后端会话流式响应").focus();
await page.getByPlaceholder("询问供水问题,或描述需要处理的任务").focus();
await expectThemeColor(inputBody, "border-color", "--field-focus-border");
await expect(inputBody).toHaveCSS("box-shadow", "none");
await expect(agentPanel.locator("form.agent-panel-control > [data-slot=input-group]")).toHaveCSS("border-width", "0px");
await expect.poll(() => getSurfacePresentation(agentPanel)).toEqual(beforeFocus);
await agentPanel.getByRole("button", { name: "选择对话归属" }).click();
const projectMenu = page.getByRole("menu");
await expect(projectMenu.getByRole("menuitem", { name: "个人会话" })).toBeVisible();
await expect(projectMenu.getByRole("menuitem", { name: "当前项目" })).toBeVisible();
await expect(projectMenu.getByRole("menuitem", { name: "新建项目" })).toBeVisible();
await expect(projectMenu.getByRole("menuitem", { name: "切换项目" })).toHaveCount(0);
await page.screenshot({ path: "test-results/agent-history-ui/project-menu.png" });
await page.keyboard.press("Escape");
await page.screenshot({ path: "test-results/agent-history-ui/desktop-dock.png" });
for (const viewport of [
@@ -125,6 +130,27 @@ test("desktop Agent docks beside the workspace with a solid reading surface", as
path: `test-results/agent-history-ui/desktop-dock-${viewport.width}.png`
});
}
await page.setViewportSize({ width: 1440, height: 640 });
const handle = page.getByRole("separator", { name: "调整 Agent 面板宽度" });
for (const width of [400, 640]) {
const box = (await handle.boundingBox())!;
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(width, box.y + box.height / 2);
await page.mouse.up();
await page.mouse.move(1000, 450);
await expect(agentPanel).toHaveCSS("width", `${width}px`);
expect(await startComposer.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true);
expect(await inputTools.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true);
const textarea = agentPanel.locator("textarea");
await textarea.fill("核对近期供水压力变化,说明监测证据和需要现场核验的事项。".repeat(20));
const composerBox = (await startComposer.boundingBox())!;
expect(composerBox.y).toBeGreaterThanOrEqual(topBarBox!.height * 2);
expect(composerBox.y + composerBox.height).toBeLessThanOrEqual(640);
await textarea.fill("");
await agentPanel.screenshot({ path: `test-results/agent-history-ui/desktop-material-${width}.png` });
}
});
test("desktop Agent portal menus keep local material and respect reduced transparency", async ({ page }) => {
@@ -137,11 +163,12 @@ test("desktop Agent portal menus keep local material and respect reduced transpa
const trigger = panel.getByRole("button", { name });
await trigger.click();
const menu = page.getByRole("menu");
await expect(menu).toBeVisible();
await waitForAnimations(menu);
await expect(menu).toHaveAttribute("data-agent-presentation", "desktop-dock");
expect(await menu.evaluate((element) => element.closest("aside"))).toBeNull();
await expectThemeColor(menu, "background-color", "--agent-dock-overlay-surface");
await expect(menu).toHaveCSS("backdrop-filter", "blur(12px) saturate(1.03)");
await expect(menu).toHaveCSS("background-image", "none");
await expectAcrylicSurface(menu);
await page.keyboard.press("Escape");
await expect(trigger).toBeFocused();
}
@@ -158,7 +185,8 @@ test("desktop Agent portal menus keep local material and respect reduced transpa
await page.keyboard.press("Escape");
await panel.getByRole("button", { name: "打开 Agent 历史记录" }).click();
const history = panel.getByRole("region", { name: "Agent 历史记录" });
await expectThemeColor(history, "background-color", "--palette-paper");
await expectThemeColor(panel.locator(".agent-panel-header"), "background-color", "--palette-paper");
await expect(panel.locator(".agent-panel-header")).toHaveCSS("backdrop-filter", "none");
await expect(history).toHaveCSS("backdrop-filter", "none");
});
@@ -178,8 +206,10 @@ test("desktop Agent history remains usable inside the docked navigation pane", a
await waitForAnimations(history);
await expect(historyButton).toHaveAttribute("aria-expanded", "true");
await expectUnifiedHistoryStructure(history);
await expectThemeColor(history, "background-color", "--agent-dock-overlay-surface");
await expect(history).toHaveCSS("backdrop-filter", "blur(12px) saturate(1.03)");
await expectThemeColor(agentPanel.locator(".agent-panel-header"), "background-color", "--agent-dock-overlay-surface");
await expectAcrylicSurface(agentPanel.locator(".agent-panel-header"));
await expect(history).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
await expect(history).toHaveCSS("backdrop-filter", "none");
await history.getByRole("button", { name: "重命名对话" }).first().click();
const renameInput = history.locator("input").first();
@@ -339,12 +369,12 @@ test("long history titles keep rename and explicit delete usable across viewport
}
})
);
await page.route("**/api/v1/agent/chat/session/**", async (route) => {
await page.route("**/api/v1/session/**", async (route) => {
const request = route.request();
const method = request.method();
if (method === "OPTIONS") return route.fallback();
const path = new URL(request.url()).pathname;
if (method === "PATCH" && path === `/api/v1/agent/chat/session/${sessionId}/title`) {
if (method === "PATCH" && path === `/api/v1/session/${sessionId}/title`) {
const body = request.postDataJSON();
title = body.title;
writes.push({ method, path, body });
@@ -353,7 +383,7 @@ test("long history titles keep rename and explicit delete usable across viewport
json: {}
});
}
if (method === "DELETE" && path === `/api/v1/agent/chat/session/${sessionId}`) {
if (method === "DELETE" && path === `/api/v1/session/${sessionId}`) {
deleted = true;
writes.push({ method, path, body: null });
return route.fulfill({
@@ -444,7 +474,7 @@ test("long history titles keep rename and explicit delete usable across viewport
expect(writes).toEqual([
{
method: "PATCH",
path: `/api/v1/agent/chat/session/${sessionId}/title`,
path: `/api/v1/session/${sessionId}/title`,
body: { title: "城南压力复核(已复核)", is_title_manually_edited: true }
}
]);
@@ -459,7 +489,7 @@ test("long history titles keep rename and explicit delete usable across viewport
expect(writes).toHaveLength(2);
expect(writes[1]).toEqual({
method: "DELETE",
path: `/api/v1/agent/chat/session/${sessionId}`,
path: `/api/v1/session/${sessionId}`,
body: null
});
await page.keyboard.press("Escape");
+7 -7
View File
@@ -15,7 +15,7 @@ test("real model publishes local components, restores them and receives form inp
test.setTimeout(300_000);
const target = new URL(realAgentUrl!);
expect(["127.0.0.1", "localhost"]).toContain(target.hostname);
const base = `${target.origin}/api/v1/agent/chat`;
const base = `${target.origin}/api/v1`;
const health = await request.get(`${target.origin}/health`);
expect(health.ok()).toBe(true);
expect(await health.json()).toMatchObject({ ready: true, warmed_up: true });
@@ -26,7 +26,7 @@ test("real model publishes local components, restores them and receives form inp
body: `globalThis.__TJWATER_CONFIG__ = ${JSON.stringify({
TJWATER_AUTH_MODE: "disabled",
TJWATER_SERVER_API_BASE_URL: "https://tjwater-api.invalid",
TJWATER_AGENT_API_BASE_URL: target.origin,
TJWATER_AGENT_URL: target.origin,
TJWATER_MAP_URL: "https://workbench-map.invalid/geoserver",
TJWATER_GEOSERVER_WORKSPACE: "tjwater_next",
TJWATER_ENABLE_MSW: "false"
@@ -66,7 +66,7 @@ test("real model publishes local components, restores them and receives form inp
await page.getByRole("menuitem", { name: new RegExp(requestedModelLabel) }).click();
}
const firstResponse = page.waitForResponse(
(response) => response.url() === `${base}/stream` && response.request().method() === "POST"
(response) => response.url() === `${base}/responses` && response.request().method() === "POST"
);
await panel.getByRole("button", { name: "发送 Agent 指令" }).click();
const stream = await firstResponse;
@@ -103,18 +103,18 @@ test("real model publishes local components, restores them and receives form inp
.getByLabel("现场位置(必填)")
.fill("演练:东侧阀门旁,未实地核验。本轮仍为功能验收,请不要写业务证据或记忆,只确认收到。");
const secondResponse = page.waitForResponse(
(response) => response.url() === `${base}/stream` && response.request().method() === "POST"
(response) => response.url() === `${base}/responses` && response.request().method() === "POST"
);
await panel.getByRole("button", { name: "发送补充资料" }).click();
const submitted = await secondResponse;
const submittedBody = submitted.request().postDataJSON() as {
messages: Array<{ role: string; parts: Array<{ type: string; text?: string }> }>;
input: Array<{ role: string; content: Array<{ type: string; text?: string }> }>;
};
expect(
submittedBody.messages
submittedBody.input
.filter((message) => message.role === "user")
.at(-1)
?.parts.some((part) => part.text?.includes("现场位置:演练:东侧阀门旁"))
?.content.some((part) => part.text?.includes("现场位置:演练:东侧阀门旁"))
).toBe(true);
expect(submitted.ok()).toBe(true);
await expect(panel.getByRole("button", { name: "发送 Agent 指令" })).toBeVisible({
+8 -7
View File
@@ -1,3 +1,4 @@
import { responsesFixture } from "./support/responses-fixture";
import { expect, test } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
@@ -99,7 +100,7 @@ for (const width of [1440, 375, 320]) {
data: { content: "仍需用户补充现场核验资料。", ui_block_ids: ["pressure"] }
}
];
await page.route("**/api/v1/agent/chat/session/browser-session-1", async (route) => {
await page.route("**/api/v1/session/browser-session-1", async (route) => {
if (route.request().method() !== "GET") return route.fallback();
await route.fulfill({
headers: { "Access-Control-Allow-Origin": "*" },
@@ -121,16 +122,16 @@ for (const width of [1440, 375, 320]) {
});
});
const prompts: string[] = [];
await page.route("**/api/v1/agent/chat/stream", async (route) => {
await page.route("**/api/v1/responses", async (route) => {
if (route.request().method() === "OPTIONS") return route.fallback();
const body = route.request().postDataJSON() as {
messages: Array<{ role: string; parts: Array<{ type: string; text?: string }> }>;
input: Array<{ role: string; content: Array<{ type: string; text?: string }> }>;
};
const text =
body.messages
body.input
.filter((m) => m.role === "user")
.at(-1)
?.parts.filter((p) => p.type === "text")
?.content.filter((p) => p.type === "input_text")
.map((p) => p.text)
.join("") ?? "";
prompts.push(text);
@@ -160,9 +161,9 @@ for (const width of [1440, 375, 320]) {
headers: {
"Access-Control-Allow-Origin": "*",
"Content-Type": "text/event-stream",
"X-Vercel-AI-UI-Message-Stream": "v1"
"Cache-Control": "no-cache"
},
body: chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join("")
body: responsesFixture(chunks)
});
});
await page.goto("/", { waitUntil: "domcontentloaded" });
+49
View File
@@ -0,0 +1,49 @@
import { responsesFixture } from "./support/responses-fixture";
import { expect, test } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api";
import { mockScadaApi } from "./support/mock-scada-api";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
test("chart and history actions send success only after rendering, and final answer replaces process text", async ({ page }) => {
await mockWorkbenchContext(page);
await mockScadaApi(page, [{ device_id: "D1", device_type: "pressure", node_id: "J1", link_id: null, api_query_id: null, measurement_unit: "m", transmission_mode: "test", transmission_frequency: "1min", reliability: 1, x: null, y: null, longitude: 121.48, latitude: 31.1 }]);
await mockAgentApi(page);
const receipts: Array<{ actionId: string; status: string; output: unknown }> = [];
const chart = { title: "监测压力", chart_type: "line", x_data: ["t1", "t2"], series: [{ name: "D1 scada_raw", data: [20, 21] }] };
await page.route("**/api/v1/frontend-action-registry", (route) => route.fulfill({ headers: { "Access-Control-Allow-Origin": "*" }, json: { schema_version: "frontend-action-registry@1", actions: ["show_chart", "view_history", "zoom_to_map", "locate_features"].map((id) => ({ id, version: "frontend-action@1" })) } }));
await page.route("**/api/v1/frontend-actions/*/result", async (route) => {
if (route.request().method() === "OPTIONS") { await route.fulfill({ status: 204, headers: { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Headers": "*", "Access-Control-Allow-Methods": "POST, OPTIONS" } }); return; }
receipts.push(route.request().postDataJSON());
await route.fulfill({ headers: { "Access-Control-Allow-Origin": "*" }, json: { accepted: true } });
});
await page.route("**/api/v1/responses", async (route) => {
const now = Date.now();
const action = (name: string, params: unknown, actionId: string) => ({ version: "frontend-action@1", actionId, toolCallId: actionId, sessionId: "migrated-session", name, params, issuedAt: now, expiresAt: now + 15000 });
const chunks = [
{ type: "start", messageId: "assistant-migrated" },
{ type: "data-progress", data: { session_id: "migrated-session", id: "business-data", phase: "data", status: "completed", title: "获取业务数据" } },
{ type: "text-start", id: "text" }, { type: "text-delta", id: "text", delta: "正在整理过程说明" }, { type: "text-end", id: "text" },
{ type: "data-frontend_action", data: action("show_chart", chart, "chart-action") },
{ type: "data-frontend_action", data: action("view_history", { element_type: "junction", element_id: "J1", start: "2026-09-01T00:00:00Z", end: "2026-09-01T01:00:00Z", chart }, "history-action") },
{ type: "data-frontend_action", data: action("locate_features", { layer: "scada", feature_ids: ["D1", "missing"] }, "locate-action") },
{ type: "data-frontend_action", data: action("zoom_to_map", { center: [121.48, 31.1], zoom: 14.5 }, "zoom-action") },
{ type: "data-final_answer", data: { session_id: "migrated-session", content: "最终答复:压力曲线已核实。" } },
{ type: "finish", finishReason: "stop" },
];
await route.fulfill({ headers: { "Access-Control-Allow-Origin": "*", "Content-Type": "text/event-stream", "Cache-Control": "no-cache" }, body: responsesFixture(chunks) });
});
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
const prompt = page.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" }).filter({ visible: true }).first();
await page.waitForFunction(() => Boolean(window.__waterNetworkMap?.getLayer("scada-pressure-hit")));
await prompt.fill("查询历史并展示压力曲线");
await page.getByRole("button", { name: "发送 Agent 指令" }).filter({ visible: true }).first().click();
await expect(page.getByText("最终答复:压力曲线已核实。", { exact: true })).toBeVisible();
await expect(page.getByText("正在整理过程说明", { exact: true })).toBeHidden();
await expect(page.locator('[data-agent-ui-result="chart-action"] svg[role="img"]')).toBeVisible();
await expect(page.locator('[data-agent-ui-result="history-action"] svg[role="img"]')).toBeVisible();
await expect.poll(() => receipts.length).toBe(4);
expect(receipts.find((receipt) => receipt.actionId === "locate-action")?.output).toEqual({ located_ids: ["D1"], missing_ids: ["missing"], fitted: true });
expect(receipts.every((receipt) => receipt.status === "succeeded")).toBe(true);
});
+27 -4
View File
@@ -43,7 +43,7 @@ test("dragging the Agent edge keeps the workspace attached and preserves the dra
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
const workspace = page.locator("#main-workspace");
const handle = page.getByRole("separator", { name: "调整 Agent 面板宽度" });
const prompt = panel.getByPlaceholder("输入调度问题,Agent 将通过后端会话流式响应");
const prompt = panel.getByPlaceholder("询问供水问题,或描述需要处理的任务");
await expect(panel.getByText("当前上下文", { exact: true })).toHaveCount(0);
await prompt.fill("保留尚未发送的诊断问题");
await expect(panel).toHaveCSS("width", "500px");
@@ -231,7 +231,7 @@ test("the floating condition feed coordinates with the docked Agent", async ({ p
.poll(async () => Math.round((await workspace.boundingBox())?.x ?? 0))
.toBeGreaterThan(400);
const expandedOffset = Math.round((await workspace.boundingBox())?.x ?? 0);
const prompt = page.getByPlaceholder("输入调度问题,Agent 将通过后端会话流式响应");
const prompt = page.getByPlaceholder("询问供水问题,或描述需要处理的任务");
await prompt.fill("保留尚未发送的诊断问题");
const conditionToggle = page
.locator("header.acrylic-navigation")
@@ -286,10 +286,33 @@ test.describe("mobile touch layout", () => {
test("mobile alert summary opens the Agent conversation panel", async ({ page }) => {
await page.clock.setFixedTime(new Date("2026-07-21T10:40:00+08:00"));
await page.setViewportSize({ width: 375, height: 812 });
await mockWorkbenchContext(page, {
availability: "ready",
source: "none",
scenario_availability: "not_configured",
artifact_availability: "not_configured",
schedule_availability: "not_configured",
work_order_availability: "not_configured",
alert_availability: "ready",
generated_at: "2026-07-21T02:40:00Z",
conditions: [],
alerts: [{
id: "test-alert",
condition_id: null,
source: { task_type: "other_task", task_id: "test-task", run_id: "test-run" },
title: "供水压力告警",
description: "待核对压力异常。",
occurred_at: "2026-07-21T02:30:00Z",
severity: "warning"
}],
scenarios: [],
latest_analysis: null,
work_orders: []
});
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: /查看异常处置面板/ }).click();
await expect(page.getByText("来源:工况演示回退", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "查看待办与告警,当前 1 条待处理事项" }).click();
await expect(page.getByText("业务告警来源:后端运行态", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "工况汇总" }).click();
await expect(
@@ -0,0 +1,92 @@
import { expect, test } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api";
import { TEST_PROJECT_ID } from "./support/mock-scada-api";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
for (const { scope, width } of [
{ scope: "project", width: 1440 },
{ scope: "personal", width: 1440 },
{ scope: "personal", width: 375 }
] as const) {
test(`authenticated ${scope} chat sends the matching context at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 });
await mockWorkbenchContext(page);
await mockAgentApi(page);
if (scope === "personal") {
await page.route("**/api/v1/projects?*", (route) => route.fulfill({
headers: { "Access-Control-Allow-Origin": "*" },
json: { items: [], total: 0, limit: 1000, offset: 0 }
}));
}
await page.route("**/runtime-config.js", (route) => route.fulfill({
contentType: "application/javascript",
body: `globalThis.__TJWATER_CONFIG__ = ${JSON.stringify({
TJWATER_AUTH_MODE: "required",
TJWATER_KEYCLOAK_ISSUER: "https://authentication.invalid/realms/test",
TJWATER_KEYCLOAK_CLIENT_ID: "browser-test",
TJWATER_SERVER_API_BASE_URL: "https://tjwater-api.invalid",
TJWATER_AGENT_URL: "http://127.0.0.1:8787",
TJWATER_MAP_URL: "https://workbench-map.invalid/geoserver",
TJWATER_GEOSERVER_WORKSPACE: "tjwater_next",
TJWATER_ENABLE_MSW: "false"
})};`
}));
// Exercise the real authentication provider and chat transport without
// credentials, external authentication, or model inference.
await page.route("**/keycloak-js.js*", (route) => route.fulfill({
contentType: "application/javascript",
body: `export default class Keycloak {
token = "BROWSER_CONTEXT_TEST_ONLY";
idTokenParsed = { name: "Browser context test" };
async init() { return true; }
async updateToken() { return false; }
async login() {}
async logout() {}
}`
}));
const streamHeaders: Array<Record<string, string>> = [];
const expectedProject = scope === "project" ? TEST_PROJECT_ID : undefined;
await page.route("**/api/v1/responses", async (route) => {
if (route.request().method() !== "POST") {
await route.fallback();
return;
}
const headers = await route.request().allHeaders();
streamHeaders.push(headers);
if (headers.authorization !== "Bearer BROWSER_CONTEXT_TEST_ONLY" || headers["x-project-id"] !== expectedProject) {
await route.fulfill({
status: 401,
headers: { "Access-Control-Allow-Origin": "*" },
json: {
code: "PRODUCT_CONTEXT_UNAVAILABLE",
message: "valid Bearer authorization and X-Project-Id are required"
}
});
return;
}
await route.fallback();
});
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: width < 1024 ? "打开 Agent 面板" : /展开 Agent 助手面板/ }).click();
const panel = page.locator('aside[aria-label="Agent 命令面板"]').filter({ visible: true });
if (scope === "personal") {
await panel.getByRole("button", { name: "选择对话归属" }).click();
await page.getByRole("menuitem", { name: "个人会话", exact: true }).click();
await expect(panel.getByRole("button", { name: "选择对话归属" })).toHaveText("个人会话");
}
await expect(panel.getByRole("button", { name: "选择 Agent 模型" })).toContainText("测试模型");
await panel.getByRole("textbox").fill("检查会话项目上下文");
await panel.getByRole("button", { name: "发送 Agent 指令" }).click();
await expect.poll(() => streamHeaders.length).toBe(1);
expect(streamHeaders[0].authorization).toBe("Bearer BROWSER_CONTEXT_TEST_ONLY");
expect(streamHeaders[0]["x-project-id"]).toBe(expectedProject);
await expect(panel.getByText("已收到工况汇总请求。", { exact: true })).toBeVisible();
await expect(page.getByText("Agent 请求失败", { exact: true })).toHaveCount(0);
const box = await panel.boundingBox();
expect(box).not.toBeNull();
expect(box!.x).toBeGreaterThanOrEqual(0);
expect(box!.x + box!.width).toBeLessThanOrEqual(width);
});
}
+16 -9
View File
@@ -1,3 +1,4 @@
import { responsesFixture } from "./support/responses-fixture";
import { expect, test, type Page } from "@playwright/test";
import { mockScadaApi } from "./support/mock-scada-api";
@@ -10,7 +11,7 @@ test("submits a custom answer with the actionable id after a later tool placehol
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
const prompt = page
.getByPlaceholder(/输入调度问题/)
.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" })
.filter({ visible: true })
.first();
await prompt.fill("测试 question 自定义回答");
@@ -29,7 +30,7 @@ test("submits a custom answer with the actionable id after a later tool placehol
.poll(() => replies)
.toEqual([
{
pathname: "/api/v1/agent/chat/question/question-1/reply",
pathname: "/api/v1/question/question-1/reply",
body: {
session_id: "question-session",
answers: [["分析高峰时段压力"]]
@@ -48,7 +49,7 @@ test("keeps the custom answer control within a 375px Agent sheet", async ({ page
await page.getByRole("button", { name: /Agent 工作台抽屉高度/ }).click();
const panel = page.locator('aside[aria-label="Agent 命令面板"]').filter({ visible: true });
const prompt = panel.getByPlaceholder(/输入调度问题/);
const prompt = panel.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" });
await prompt.fill("测试窄屏 question");
await panel.getByRole("button", { name: "发送 Agent 指令" }).click();
await panel.getByRole("radio", { name: "自定义回答" }).click();
@@ -69,7 +70,7 @@ async function mockQuestionApi(page: Page, replies: Array<{ pathname: string; bo
body: `globalThis.__TJWATER_CONFIG__ = ${JSON.stringify({
TJWATER_AUTH_MODE: "disabled",
TJWATER_SERVER_API_BASE_URL: "https://tjwater-api.invalid",
TJWATER_AGENT_API_BASE_URL: "http://127.0.0.1:8787",
TJWATER_AGENT_URL: "http://127.0.0.1:8787",
TJWATER_MAP_URL: "https://question-map.invalid/geoserver",
TJWATER_GEOSERVER_WORKSPACE: "tjwater_next",
TJWATER_MAPBOX_ACCESS_TOKEN: "",
@@ -78,17 +79,23 @@ async function mockQuestionApi(page: Page, replies: Array<{ pathname: string; bo
})
);
await page.route("https://question-map.invalid/**", (route) => route.fulfill({ status: 204 }));
await page.route("**/api/v1/agent/chat/**", async (route) => {
await page.route("http://127.0.0.1:8787/api/v1/**", async (route) => {
const request = route.request();
const pathname = new URL(request.url()).pathname;
if (request.method() === "OPTIONS") {
await route.fulfill({ status: 204, headers: {
"Access-Control-Allow-Origin": "*", "Access-Control-Allow-Headers": "*", "Access-Control-Allow-Methods": "GET, POST, OPTIONS"
} });
return;
}
if (pathname.endsWith("/stream")) {
if (pathname.endsWith("/responses")) {
await route.fulfill({
status: 200,
headers: {
"Access-Control-Allow-Origin": "*",
"Cache-Control": "no-cache",
"Content-Type": "text/event-stream",
"X-Vercel-AI-UI-Message-Stream": "v1"
"Content-Type": "text/event-stream"
},
body: createQuestionStream()
});
@@ -176,5 +183,5 @@ function createQuestionStream() {
{ type: "finish", finishReason: "stop" }
];
return parts.map((part) => `data: ${JSON.stringify(part)}\n\n`).join("");
return responsesFixture(parts);
}
+136
View File
@@ -0,0 +1,136 @@
import { createServer, type ServerResponse } from "node:http";
import { expect, test } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
import { responseFixtureWriter } from "./support/responses-fixture";
import { expectThemeColor } from "./support/theme-colors";
for (const width of [1440, 375, 320]) {
test(`steers and queues without stopping, and edits unconsumed messages at ${width}px`, async ({ page }, testInfo) => {
const pending = new Map<string, { id: string; text: string; delivery: string; status: string }>();
const admitted: Array<{ id: string; text: string; delivery: string }> = [];
let response: ServerResponse | undefined;
let encode = responseFixtureWriter();
const send = (chunk: Record<string, unknown>) => response!.write(encode(chunk));
const server = createServer((_request, output) => {
response = output;
encode = responseFixtureWriter();
output.writeHead(200, { "Access-Control-Allow-Origin": "*", "Cache-Control": "no-cache", "Content-Type": "text/event-stream" });
send({ type: "start", messageId: "input-answer" });
send({ type: "data-progress", data: { session_id: "input-session", title: "正在核对压力", status: "running" } });
send({ type: "text-start", id: "answer" });
send({ type: "text-delta", id: "answer", delta: "已开始核对压力。" });
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("missing fixture address");
try {
await page.setViewportSize({ width, height: 900 });
await page.emulateMedia({ reducedMotion: "reduce" });
await mockWorkbenchContext(page);
await mockAgentApi(page);
let primaryRequests = 0;
let abortRequests = 0;
await page.route("http://127.0.0.1:8787/api/v1/**", async (route) => {
const request = route.request();
if (request.method() === "OPTIONS") { await route.fallback(); return; }
const path = new URL(request.url()).pathname;
const headers = { "Access-Control-Allow-Origin": "*" };
if (path.endsWith("/responses")) {
const body = request.postDataJSON();
if (!body.tjwater?.delivery) {
primaryRequests++;
await route.continue({ url: `http://127.0.0.1:${address.port}/responses` });
} else {
const item = { id: body.tjwater.input_id, text: body.input, delivery: body.tjwater.delivery, status: "pending" };
admitted.push(item); pending.set(item.id, item);
await route.fulfill({ status: 202, headers, json: item });
}
return;
}
if (path.endsWith("/inputs")) { await route.fulfill({ headers, json: { items: [...pending.values()] } }); return; }
if (path.includes("/inputs/") && request.method() === "PATCH") {
const item = pending.get(path.split("/").at(-1)!);
if (!item) { await route.fulfill({ status: 409, headers, json: { message: "Consumed" } }); return; }
item.delivery = request.postDataJSON().delivery;
await route.fulfill({ headers, json: item });
return;
}
if (path.endsWith("/abort")) {
abortRequests++; pending.clear(); response?.end();
await route.fulfill({ headers, json: { aborted: true } });
return;
}
await route.fallback();
});
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: width < 1024 ? "打开 Agent 面板" : /展开 Agent 助手面板/ }).click();
const composer = page.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" });
await composer.fill("核对压力变化");
await page.getByRole("button", { name: "发送 Agent 指令" }).click();
const deliveryMode = page.getByRole("button", { name: "运行中消息发送方式" });
await expect(deliveryMode).toHaveText("引导当前任务");
await deliveryMode.focus();
await page.keyboard.press("ArrowDown");
await expect(page.getByRole("menuitemradio", { name: "引导当前任务" })).toHaveAttribute("aria-checked", "true");
const queueOption = page.getByRole("menuitemradio", { name: "排队处理" });
await queueOption.hover();
await expectThemeColor(queueOption, "background-color", "--action-selection-soft-hover");
await expectThemeColor(queueOption, "border-color", "--action-border-hover");
await page.getByRole("menu").screenshot({ path: testInfo.outputPath("delivery-menu.png"), animations: "disabled" });
await page.keyboard.press("Escape");
await expect(deliveryMode).toBeFocused();
await expect(deliveryMode).toHaveText("引导当前任务");
await expect(page.getByRole("button", { name: "停止 Agent 指令" })).toBeVisible();
await composer.fill("补充当前任务");
await page.getByRole("button", { name: "引导当前 Agent 任务" }).click();
const pendingMode = page.getByRole("button", { name: "消息发送方式:补充当前任务" });
await expect(pendingMode).toHaveText("引导当前任务");
await pendingMode.click();
await page.getByRole("menuitemradio", { name: "排队处理" }).click();
await expect(pendingMode).toHaveText("排队处理");
await pendingMode.click();
await expect(page.getByRole("menuitemradio", { name: "排队处理" })).toHaveAttribute("aria-checked", "true");
const steerOption = page.getByRole("menuitemradio", { name: "引导当前任务" });
await steerOption.hover();
await expectThemeColor(steerOption, "background-color", "--action-selection-soft-hover");
await page.screenshot({ path: testInfo.outputPath("pending-delivery-menu.png"), animations: "disabled" });
await page.keyboard.press("Home");
await page.keyboard.press("Enter");
await expect(pendingMode).toHaveText("引导当前任务");
await expect(pendingMode).toBeFocused();
await deliveryMode.click();
await page.getByRole("menuitemradio", { name: "排队处理" }).click();
await composer.fill("下一轮核对流量");
await page.getByRole("button", { name: "将消息加入 Agent 队列" }).click();
await expect(page.getByRole("button", { name: "消息发送方式:下一轮核对流量" })).toHaveText("排队处理");
expect(admitted.map((item) => item.delivery)).toEqual(["steer", "queue"]);
expect(primaryRequests).toBe(1);
expect(abortRequests).toBe(0);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
for (const item of pending.values()) {
send({ type: "data-user_input", data: { ...item, session_id: "input-session", status: "delivered" } });
}
pending.clear();
await expect(page.getByRole("list", { name: "待处理消息" })).toHaveCount(0);
send({ type: "text-delta", id: "answer", delta: "已结合补充信息完成核对。" });
send({ type: "text-end", id: "answer" });
send({ type: "finish" }); response!.end();
await expect(page.getByRole("button", { name: "发送 Agent 指令" })).toBeVisible();
await expect(page.getByText("补充当前任务", { exact: true })).toBeVisible();
await expect(page.getByText("下一轮核对流量", { exact: true })).toBeVisible();
await composer.fill("继续核对压力");
await page.getByRole("button", { name: "发送 Agent 指令" }).click();
await expect(page.getByRole("button", { name: "停止 Agent 指令" })).toBeVisible();
await composer.fill("等待处理的后续问题");
await page.getByRole("button", { name: "将消息加入 Agent 队列" }).click();
await expect(page.getByRole("button", { name: "消息发送方式:等待处理的后续问题" })).toBeVisible();
await page.getByRole("button", { name: "停止 Agent 指令" }).click();
await expect(page.getByRole("list", { name: "待处理消息" })).toHaveCount(0);
expect(abortRequests).toBe(1);
} finally {
response?.end(); server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
}
+78 -3
View File
@@ -7,7 +7,7 @@ test.beforeEach(async ({ page }) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await mockWorkbenchContext(page);
await mockAgentApi(page);
await page.route("**/api/v1/agent/chat/models", (route) => route.fulfill({
await page.route("**/api/v1/models", (route) => route.fulfill({
headers: { "Access-Control-Allow-Origin": "*" },
json: {
default_model: "test/fast",
@@ -59,8 +59,8 @@ for (const width of [1440, 375]) {
const trigger = page.locator('button[aria-label="权限批准模式"]');
await trigger.click();
await waitForMenuAnimations(page.getByRole("menu"));
const request = page.getByRole("menuitem", { name: /工具权限逐次确认/ });
const always = page.getByRole("menuitem", { name: /自动允许本轮权限请求/ });
const request = page.getByRole("menuitem", { name: /需要审批的请求逐次确认/ });
const always = page.getByRole("menuitem", { name: /自动批准本轮工具权限请求/ });
for (const item of [request, always]) {
await item.hover();
await expectThemeColor(item, "background-color", "--action-selection-soft-hover");
@@ -85,6 +85,81 @@ for (const width of [1440, 375]) {
});
}
test("sends the selected approval mode and can return to manual approval", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
const trigger = page.getByRole("button", { name: "权限批准模式" });
for (const [label, mode] of [
["自动批准本轮工具权限请求", "always"],
["需要审批的请求逐次确认", "request"],
] as const) {
await expect(trigger).toBeEnabled();
await trigger.click();
await page.getByRole("menuitem", { name: new RegExp(label) }).click();
await page.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" }).fill(`验证权限模式 ${mode}`);
const sent = page.waitForRequest((request) => request.method() === "POST" && request.url().endsWith("/api/v1/responses"));
await page.getByRole("button", { name: "发送 Agent 指令" }).click();
expect((await sent).postDataJSON().tjwater.approval_mode).toBe(mode);
}
await expect(trigger).toBeEnabled();
});
test("replaces a removed model before sending and shows the model actually submitted", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
const trigger = page.getByRole("button", { name: "选择 Agent 模型" });
await expect(trigger).toHaveAccessibleDescription("当前模型:快速");
await trigger.click();
await page.getByRole("menuitem", { name: /深入分析和复杂任务处理/ }).click();
await expect(trigger).toHaveAccessibleDescription("当前模型:专家");
// Simulate an Agent restart with a new catalog while this page stays open.
await page.route("**/api/v1/models", (route) => route.fulfill({
headers: { "Access-Control-Allow-Origin": "*" },
json: {
default_model: "waternetwork/gpt-6-luna",
models: [{ id: "waternetwork/gpt-6-luna", label: "快速", description: "当前可用模型", icon: "bolt" }]
}
}));
await page.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" }).fill("检查更新后的快速模型");
const sent = page.waitForRequest((request) => request.method() === "POST" && request.url().endsWith("/api/v1/responses"));
await page.getByRole("button", { name: "发送 Agent 指令" }).click();
expect((await sent).postDataJSON().model).toBe("waternetwork/gpt-6-luna");
await expect(trigger).toHaveAccessibleDescription("当前模型:快速");
});
test("keeps an explicitly selected model when it remains available", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
const trigger = page.getByRole("button", { name: "选择 Agent 模型" });
await expect(trigger).toHaveAccessibleDescription("当前模型:快速");
await trigger.click();
await page.getByRole("menuitem", { name: /深入分析和复杂任务处理/ }).click();
await page.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" }).fill("保留专家模型选择");
const sent = page.waitForRequest((request) => request.method() === "POST" && request.url().endsWith("/api/v1/responses"));
await page.getByRole("button", { name: "发送 Agent 指令" }).click();
expect((await sent).postDataJSON().model).toBe("test/expert");
await expect(trigger).toHaveAccessibleDescription("当前模型:专家");
});
test("does not send a generation request when the refreshed catalog is empty", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
await expect(page.getByRole("button", { name: "选择 Agent 模型" })).toHaveAccessibleDescription("当前模型:快速");
await page.route("**/api/v1/models", (route) => route.fulfill({
headers: { "Access-Control-Allow-Origin": "*" }, json: { default_model: "test/fast", models: [] }
}));
const requests: string[] = [];
page.on("request", (request) => {
if (request.method() === "POST" && request.url().endsWith("/api/v1/responses")) requests.push(request.url());
});
await page.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" }).fill("检查空模型目录");
await page.getByRole("button", { name: "发送 Agent 指令" }).click();
await expect(page.getByText("Agent 后端当前没有可用模型,请检查模型配置。").first()).toBeVisible();
expect(requests).toEqual([]);
});
async function waitForMenuAnimations(menu: Locator) {
await expect(menu).toBeVisible();
await menu.evaluate((element) => Promise.all(
+153
View File
@@ -0,0 +1,153 @@
import { expect, test } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
test.beforeEach(async ({ page }) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await mockWorkbenchContext(page);
await mockAgentApi(page, { sessions: [{
session_id: "recent-pressure", title: "城南供水压力复核", created_at: Date.now() - 3_600_000, updated_at: Date.now() - 60_000,
}] });
});
test("empty conversation centers one composer and directly sends an Agent-generated recent activity question", async ({ page }) => {
let requests = 0;
await page.route("**/api/v1/suggestions", async (route) => {
if (route.request().method() === "OPTIONS") return route.fallback();
requests += 1;
const body = route.request().postDataJSON() as { model: string; activities: Array<{ id: string; title: string; kind: string }> };
expect(body.model).toBe("test/model");
const recent = body.activities.find((item) => item.title === "城南供水压力复核");
expect(recent).toBeDefined();
await route.fulfill({ headers: { "Access-Control-Allow-Origin": "*" }, json: { suggestions: [{
question: "重新核对城南供水压力复核涉及的监测证据", activity_id: recent!.id,
}] } });
});
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
await expect(panel).toHaveAttribute("data-agent-starting", "true");
await expect(panel.getByRole("heading", { name: "我能帮你处理什么?" })).toBeVisible();
const question = panel.getByRole("button", { name: /重新核对城南供水压力复核涉及的监测证据/ });
await expect(question).toBeVisible();
await expect(question).toContainText("近期会话");
const composer = (await panel.locator(".agent-panel-start-composer").boundingBox())!;
const panelBox = (await panel.boundingBox())!;
expect(Math.abs(composer.y + composer.height / 2 - (panelBox.y + 56 + (panelBox.height - 56) / 2))).toBeLessThan(1);
await expect(panel.locator("form")).toHaveCount(1);
await panel.screenshot({ path: "test-results/agent-history-ui/agent-start-generated.png" });
const handle = page.getByRole("separator", { name: "调整 Agent 面板宽度" });
for (const width of [400, 640]) {
const box = (await handle.boundingBox())!;
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(width, box.y + box.height / 2);
await page.mouse.up();
await expect(panel).toHaveCSS("width", `${width}px`);
}
expect(requests).toBe(1);
await question.click();
await expect(panel).toHaveAttribute("data-agent-starting", "false");
await expect(panel.locator(".agent-panel-composer-motion")).toHaveCSS("transform", "none");
await expect(panel.locator("textarea")).toHaveCSS("min-height", "96px");
expect((await panel.locator(".agent-panel-band").last().boundingBox())!.height).toBeLessThanOrEqual(160);
await expect(panel.locator(".agent-panel-user-message")).toHaveText("重新核对城南供水压力复核涉及的监测证据");
await expect(panel.locator("form")).toHaveCount(1);
await expect.poll(async () => {
const form = (await panel.locator("form").boundingBox())!;
const box = (await panel.boundingBox())!;
return Math.abs(form.y + form.height - (box.y + box.height - 8));
}).toBeLessThan(1);
await panel.screenshot({ path: "test-results/agent-history-ui/agent-conversation-compact.png" });
const prompt = panel.locator("textarea");
await prompt.focus();
await page.keyboard.type("核对监测点");
await page.keyboard.press("Shift+Enter");
await page.keyboard.type("补充现场记录");
await expect(prompt).toHaveValue("核对监测点\n补充现场记录");
const initialHeight = (await prompt.boundingBox())!.height;
expect(initialHeight).toBe(96);
await prompt.fill("监测点、异常时间及现场核验记录。\n".repeat(30));
for (const height of [900, 640]) {
await page.setViewportSize({ width: 1280, height });
const promptBox = (await prompt.boundingBox())!;
expect(promptBox.height).toBeGreaterThan(initialHeight);
expect(promptBox.height).toBeLessThanOrEqual(Math.min(height * 0.24, 192));
expect(promptBox.x).toBeGreaterThanOrEqual(0);
await expect.poll(() => prompt.evaluate((element) => {
const input = element.getBoundingClientRect();
const container = element.closest("aside")!.getBoundingClientRect();
return input.left >= container.left && input.right <= container.right;
})).toBe(true);
expect(await prompt.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true);
const send = panel.getByRole("button", { name: "发送 Agent 指令" });
await expect(send).toBeVisible();
const sendBox = (await send.boundingBox())!;
expect(sendBox.y + sendBox.height).toBeLessThanOrEqual(height);
expect((await panel.getByRole("log").boundingBox())!.height).toBeGreaterThan(100);
}
await prompt.fill("");
await expect(prompt).toHaveCSS("height", "96px");
await panel.screenshot({ path: "test-results/agent-history-ui/agent-conversation-short-viewport.png" });
});
test("failed recommendation generation leaves the centered input ready for a normal conversation", async ({ page }) => {
await page.route("**/api/v1/suggestions", (route) => route.request().method() === "OPTIONS" ? route.fallback() : route.fulfill({
status: 503, headers: { "Access-Control-Allow-Origin": "*" }, json: { message: "unavailable" },
}));
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
await expect(panel.getByText("推荐问题暂不可用,可直接输入任务。")).toBeVisible();
const prompt = panel.getByPlaceholder("询问供水问题,或描述需要处理的任务");
await prompt.fill("核对供水运行情况");
await panel.getByRole("button", { name: "发送 Agent 指令" }).click();
await expect(panel.locator(".agent-panel-user-message")).toHaveText("核对供水运行情况");
await expect(panel).toHaveAttribute("data-agent-starting", "false");
});
test("first send smoothly moves the existing composer to the bottom", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "no-preference" });
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
await expect(panel.getByRole("group", { name: "推荐问题" }).getByRole("button")).toHaveCount(3);
await panel.evaluate(async (element) => {
await Promise.all(element.getAnimations({ subtree: true })
.filter((animation) => animation.effect?.getTiming().iterations !== Infinity)
.map((animation) => animation.finished.catch(() => undefined)));
});
const form = panel.locator("form");
await form.locator("textarea").fill("核对近期供水压力变化");
const beforeTop = (await form.boundingBox())!.y;
await page.evaluate(() => {
const panel = document.querySelector('aside[aria-label="Agent 命令面板"]')!;
const form = panel.querySelector("form")!;
const state = { form, positions: [] as Array<{ active: boolean; y: number }>, done: false };
Object.assign(window, { __agentComposerTransition: state });
const start = performance.now();
const sample = () => {
state.positions.push({ active: panel.getAttribute("data-agent-starting") === "false", y: form.getBoundingClientRect().y });
if (performance.now() - start < 1000) requestAnimationFrame(sample);
else state.done = true;
};
requestAnimationFrame(sample);
});
await panel.getByRole("button", { name: "发送 Agent 指令" }).click();
await expect(panel).toHaveAttribute("data-agent-starting", "false");
await expect.poll(() => page.evaluate(() => (window as typeof window & {
__agentComposerTransition: { done: boolean }
}).__agentComposerTransition.done)).toBe(true);
const afterTop = (await form.boundingBox())!.y;
const result = await page.evaluate(() => {
const state = (window as typeof window & { __agentComposerTransition: {
form: HTMLFormElement; positions: Array<{ active: boolean; y: number }>
} }).__agentComposerTransition;
return { sameForm: state.form === document.querySelector('aside[aria-label="Agent 命令面板"] form'), positions: state.positions };
});
expect(result.sameForm).toBe(true);
expect(afterTop).toBeGreaterThan(beforeTop);
expect(result.positions.some((position) => position.active && position.y > beforeTop + 4 && position.y < afterTop - 4)).toBe(true);
await expect(panel.locator("form")).toHaveCount(1);
});
+19 -37
View File
@@ -1,7 +1,9 @@
import { responseFixtureWriter } from "./support/responses-fixture";
import { createServer, type Server } from "node:http";
import type { AddressInfo } from "node:net";
import { expect, test } from "@playwright/test";
import { mockScadaApi } from "./support/mock-scada-api";
import { expectThemeColor } from "./support/theme-colors";
const STREAM_CHUNK_SIZE = 18;
const STREAM_CHUNK_DELAY_MS = 20;
@@ -17,16 +19,14 @@ test.beforeAll(async () => {
streamServer = createServer((_request, response) => {
response.writeHead(200, {
"Access-Control-Allow-Origin": "*",
"Cache-Control": "no-cache",
"Content-Type": "text/event-stream",
"X-Vercel-AI-UI-Message-Stream": "v1"
"Cache-Control": "no-cache, no-transform",
"Content-Type": "text/event-stream"
});
const content = createLongMarkdownResponse();
const chunks = splitIntoCodePointChunks(content, STREAM_CHUNK_SIZE);
const send = (chunk: Record<string, unknown>) => {
response.write(`data: ${JSON.stringify(chunk)}\n\n`);
};
const encode = responseFixtureWriter();
const send = (chunk: Record<string, unknown>) => { response.write(encode(chunk)); };
send({ type: "start", messageId: "assistant-stream-stability" });
send({
@@ -186,7 +186,7 @@ test("stream completion and the plan card remain positionally stable", async ({
body: `globalThis.__TJWATER_CONFIG__ = ${JSON.stringify({
TJWATER_AUTH_MODE: "disabled",
TJWATER_SERVER_API_BASE_URL: "https://tjwater-api.invalid",
TJWATER_AGENT_API_BASE_URL: "http://127.0.0.1:8787",
TJWATER_AGENT_URL: "http://127.0.0.1:8787",
TJWATER_MAP_URL: "https://stream-map.invalid/geoserver",
TJWATER_GEOSERVER_WORKSPACE: "tjwater_next",
TJWATER_MAPBOX_ACCESS_TOKEN: "",
@@ -195,7 +195,7 @@ test("stream completion and the plan card remain positionally stable", async ({
})
);
await page.route("https://stream-map.invalid/**", (route) => route.fulfill({ status: 204 }));
await page.route("**/api/v1/agent/chat/**", async (route) => {
await page.route("http://127.0.0.1:8787/api/v1/**", async (route) => {
if (route.request().method() === "OPTIONS") {
await route.fulfill({
status: 204,
@@ -210,7 +210,7 @@ test("stream completion and the plan card remain positionally stable", async ({
const path = new URL(route.request().url()).pathname;
if (path.endsWith("/stream")) {
if (path.endsWith("/responses")) {
await route.continue({ url: streamServerUrl });
return;
}
@@ -244,7 +244,7 @@ test("stream completion and the plan card remain positionally stable", async ({
page.getByRole("button", { name: "选择 Agent 模型" }).filter({ visible: true }).first()
).toContainText("测试模型", { timeout: 10_000 });
const prompt = page
.getByPlaceholder(/输入调度问题/)
.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" })
.filter({ visible: true })
.first();
await expect(prompt).toBeVisible();
@@ -311,7 +311,7 @@ test("stream completion and the plan card remain positionally stable", async ({
await sendButton.click();
const userMessage = page.locator(".agent-panel-user-message");
const assistantMessage = page.locator(".agent-panel-message").first();
await expect(userMessage).toHaveCSS("background-color", "oklch(0.965 0.018 255)");
await expectThemeColor(userMessage, "background-color", "--agent-user-message-surface");
expect(
await userMessage.evaluate((element) => getComputedStyle(element).backgroundColor)
).not.toBe(
@@ -323,26 +323,10 @@ test("stream completion and the plan card remain positionally stable", async ({
await expect
.poll(() => assistantMessage.evaluate((element) => getComputedStyle(element).boxShadow))
.toBe(await userMessage.evaluate((element) => getComputedStyle(element).boxShadow));
const operationalBrief = page.locator(".agent-panel-control").filter({
has: page.getByRole("button", { name: "预览影响", exact: true })
});
await expect(operationalBrief).toBeVisible();
await expect(operationalBrief).toContainText("Agent 分析中");
await expect(operationalBrief.getByRole("button", { name: "预览影响" })).toBeDisabled();
await expect(operationalBrief.getByRole("button", { name: "检查证据" })).toBeDisabled();
await expect(operationalBrief).toHaveCSS("position", "static");
await expect(operationalBrief).toHaveCSS("border-radius", "12px");
// Desktop uses an inline brief above the conversation, not the legacy floating overlay.
await expect(page.locator(".agent-panel-operational-brief")).toHaveCount(0);
await expectThemeColor(page.locator("[data-agent-conversation-state=active]"), "background-color", "--agent-dock-reading");
await expect(page.locator(".agent-panel-shell")).toHaveClass(/surface-dock/);
await expect(page.locator(".agent-panel-operational-float")).toHaveCount(0);
await expect
.poll(() =>
operationalBrief.evaluate((element) => {
const conversation = document.querySelector(".agent-panel-conversation")!;
return conversation.getBoundingClientRect().top - element.getBoundingClientRect().bottom;
})
)
.toBeGreaterThanOrEqual(0);
const liveProgress = page.getByRole("list", { name: "最近执行进度" });
await expect(liveProgress.locator("li")).toHaveCount(3);
@@ -470,9 +454,9 @@ test("stream completion and the plan card remain positionally stable", async ({
const expandedLiveProgress = page.getByRole("list", { name: "全部执行进度" });
await expect(expandedLiveProgress.locator("li")).toHaveCount(4);
await expect(expandedLiveProgress.locator(".status-badge")).toHaveCount(0);
await expect(expandedLiveProgress.locator('[data-progress-status="running"]').first()).toHaveCSS(
"color",
"oklch(0.546 0.245 262.881)"
await expectThemeColor(
expandedLiveProgress.locator('[data-progress-status="running"]').first(),
"color", "--action-blue-hover"
);
await expect(expandedLiveProgress.getByText("正在分析运行上下文", { exact: true })).toBeVisible();
await expect(
@@ -494,10 +478,8 @@ test("stream completion and the plan card remain positionally stable", async ({
resumeStream();
await expect(
page.getByText("分析完成", { exact: true }).filter({ visible: true }).first()
).toBeVisible({
timeout: 10_000
});
page.getByRole("button", { name: "发送 Agent 指令" }).filter({ visible: true }).first()
).toBeVisible({ timeout: 10_000 });
await expect(progressToggle.locator("../..")).toContainText("步骤4/4");
await expect(page.getByRole("list", { name: "最近执行进度" })).toHaveCount(0);
await expect(
@@ -618,7 +600,7 @@ test("stream completion and the plan card remain positionally stable", async ({
expect(pressedScrollButtonBox.y).toBe(scrollButtonBox.y + 1);
await page.mouse.move(scrollButtonBox.x - 20, scrollButtonBox.y);
await page.mouse.up();
await expect(scrollToLatestButton).toHaveCSS("background-color", "rgba(255, 255, 255, 0.94)");
await expectThemeColor(scrollToLatestButton, "background-color", "--agent-dock-overlay-surface");
await expect
.poll(() =>
scrollToLatestButton.evaluate((element) => getComputedStyle(element).backdropFilter)
+1 -1
View File
@@ -97,7 +97,7 @@ test("speech recognition appends every final transcript in one result batch", as
await page.getByRole("button", { name: "语音输入" }).click();
await expect(page.getByPlaceholder("输入调度问题,Agent 将通过后端会话流式响应")).toHaveValue("检查城南泵站 水位");
await expect(page.getByPlaceholder("询问供水问题,或描述需要处理的任务")).toHaveValue("检查城南泵站 水位");
});
test("speech recognition pulse stays transparent across its loop boundary", async ({ page }) => {
+1 -1
View File
@@ -217,7 +217,7 @@ async function prepareWorkbench(page: Page) {
TJWATER_MAP_URL: "https://button-system.invalid/geoserver",
TJWATER_GEOSERVER_WORKSPACE: "tjwater_next",
TJWATER_SERVER_API_BASE_URL: "https://tjwater-api.invalid",
TJWATER_AGENT_API_BASE_URL: "http://127.0.0.1:8787",
TJWATER_AGENT_URL: "http://127.0.0.1:8787",
TJWATER_ENABLE_DEV_PANEL: "false",
TJWATER_ENABLE_MSW: "false"
};`

Some files were not shown because too many files have changed in this diff Show More