feat(agent): support Responses steering and project-aware tools
Use the dedicated TJWATER_AGENT_URL and Responses stream protocol. Keep running turns active while users steer or queue follow-ups and edit pending delivery. Refresh the model catalog before generation and preserve approval mode selection. Integrate scoped investigation messaging/presentation, geographic place selection, current Agent composer styles and supporting workbench contracts. Validation: 77 focused Vitest checks, 21 browser cases, lint, typecheck and production build. Preserve direct Server/Agent routing and project authentication.
This commit is contained in:
+1
-1
@@ -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
|
||||
|
||||
@@ -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 的原生收件箱决定,已处理后不再允许修改;追加消息和模式切换复用当前会话归属及授权,不会先中断执行。
|
||||
|
||||
## 常用命令
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 针对工况生成的处置建议区块。
|
||||
|
||||
@@ -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 服务和现有会话数据保留。
|
||||
|
||||
@@ -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。地图和影像中的底色应能透过外层材质;报告正文保留更高遮蔽度,保证阅读。
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
@@ -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>;
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 () => ({
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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. */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 "需重点复核";
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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("未安排");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user