diff --git a/.opencode/agents/instruction.md b/.opencode/agents/instruction.md index fe04ccd..04a7353 100644 --- a/.opencode/agents/instruction.md +++ b/.opencode/agents/instruction.md @@ -30,7 +30,9 @@ Skills 树是**动态生长的**——工作流不是预置的,而是从实际 |------|------| | 获取后端数据(数据源、推理、分析) | `tjwater_cli` | | 发现可用命令 | `tjwater_cli(command="help")` | -| UI 操作 / 可视化 | `locate_features`、`view_scada`、`show_chart`、`render_junctions`、`view_history`、`apply_layer_style` | +| 查询实时公开网页信息 | `web_search` | +| 地址/地点转经纬度 | `geocode` | +| UI 操作 / 可视化 | `locate_features`、`zoom_to_map`、`view_scada`、`show_chart`、`render_junctions`、`view_history`、`apply_layer_style` | | 持久化渲染数据 | ①准备 { node_area_map } JSON → ②`store_render_ref` 存为受控 ref → ③`render_junctions` 渲染到前端 | **前端工具仅做显示,不返回数据**,不要假设其返回内容。 diff --git a/.opencode/tools/geocode.ts b/.opencode/tools/geocode.ts new file mode 100644 index 0000000..d113ad5 --- /dev/null +++ b/.opencode/tools/geocode.ts @@ -0,0 +1,37 @@ +import { tool } from "@opencode-ai/plugin"; + +const internalBaseUrl = + process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787"; +const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? ""; + +export default tool({ + description: + "调用 TJWater 后端的天地图地理编码服务,将中国境内结构化地址或地点名称转换为经纬度。若需缩放地图,把返回的 location.lon/location.lat 传给 zoom_to_map,并设置 source_crs='EPSG:4326'。", + args: { + reason: tool.schema + .string() + .describe("Why geocoding is required for the current user request."), + keyword: tool.schema + .string() + .describe("Address or place name to geocode, such as 北京市人民政府."), + }, + async execute(args, context) { + const response = await fetch(`${internalBaseUrl}/internal/tools/geocode`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-agent-internal-token": internalToken, + }, + body: JSON.stringify({ + session_id: context.sessionID, + keyword: args.keyword, + }), + }); + + const text = await response.text(); + if (!response.ok) { + throw new Error(text); + } + return text; + }, +}); diff --git a/.opencode/tools/web_search.ts b/.opencode/tools/web_search.ts new file mode 100644 index 0000000..98a1729 --- /dev/null +++ b/.opencode/tools/web_search.ts @@ -0,0 +1,62 @@ +import { tool } from "@opencode-ai/plugin"; + +const internalBaseUrl = + process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787"; +const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? ""; + +export default tool({ + description: + "调用 TJWater 后端的实时网页搜索服务。适合查询新闻、政策、规范、产品资料、公开网页事实等可能变化的信息。", + args: { + reason: tool.schema + .string() + .describe("Why web search is required for the current user request."), + query: tool.schema.string().describe("Search query text."), + freshness: tool.schema + .enum(["noLimit", "oneDay", "oneWeek", "oneMonth", "oneYear"]) + .optional() + .describe("Optional freshness filter. Defaults to noLimit."), + summary: tool.schema + .boolean() + .optional() + .describe("Whether the backend should include page summaries."), + count: tool.schema + .number() + .int() + .positive() + .optional() + .describe("Optional result count, backend accepts 1 to 50."), + include: tool.schema + .array(tool.schema.string()) + .optional() + .describe("Optional domains to include."), + exclude: tool.schema + .array(tool.schema.string()) + .optional() + .describe("Optional domains to exclude."), + }, + async execute(args, context) { + const response = await fetch(`${internalBaseUrl}/internal/tools/web-search`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-agent-internal-token": internalToken, + }, + body: JSON.stringify({ + session_id: context.sessionID, + query: args.query, + freshness: args.freshness, + summary: args.summary, + count: args.count, + include: args.include, + exclude: args.exclude, + }), + }); + + const text = await response.text(); + if (!response.ok) { + throw new Error(text); + } + return text; + }, +}); diff --git a/.opencode/tools/zoom_to_map.ts b/.opencode/tools/zoom_to_map.ts new file mode 100644 index 0000000..986533b --- /dev/null +++ b/.opencode/tools/zoom_to_map.ts @@ -0,0 +1,32 @@ +import { tool } from "@opencode-ai/plugin"; + +export default tool({ + description: + "在前端地图上缩放定位到坐标。默认坐标为 EPSG:3857;如果来自天地图 geocode 的 lon/lat,传 source_crs='EPSG:4326',前端会转换为 EPSG:3857 后缩放。", + args: { + reason: tool.schema + .string() + .describe("Why this map zoom action is needed for the current request."), + x: tool.schema + .number() + .describe("X coordinate. For EPSG:4326 this is longitude; for EPSG:3857 this is meters."), + y: tool.schema + .number() + .describe("Y coordinate. For EPSG:4326 this is latitude; for EPSG:3857 this is meters."), + source_crs: tool.schema + .enum(["EPSG:3857", "EPSG:4326"]) + .optional() + .describe("Input coordinate CRS. Defaults to EPSG:3857."), + zoom: tool.schema + .number() + .optional() + .describe("Optional OpenLayers zoom level. Defaults to 18."), + duration_ms: tool.schema + .number() + .optional() + .describe("Optional animation duration in milliseconds. Defaults to 1000."), + }, + async execute() { + return "已缩放到指定地图坐标。"; + }, +}); diff --git a/src/routes/chatStreamEvents.ts b/src/routes/chatStreamEvents.ts index 55a7cef..ecb44d1 100644 --- a/src/routes/chatStreamEvents.ts +++ b/src/routes/chatStreamEvents.ts @@ -61,9 +61,12 @@ const isDevelopmentDebugLoggingEnabled = process.env.NODE_ENV === "development"; const toolLabels: Record = { memory_manager: "记忆写入", + geocode: "地理编码", session_search: "历史会话检索", skill_manager: "流程沉淀", + web_search: "网页搜索", locate_features: "地图定位", + zoom_to_map: "地图缩放", view_history: "历史数据面板", view_scada: "SCADA 面板", show_chart: "图表渲染", diff --git a/src/server.ts b/src/server.ts index 9e39cdb..0d24b94 100644 --- a/src/server.ts +++ b/src/server.ts @@ -250,6 +250,143 @@ app.post("/internal/tools/session-search", async (req, res) => { }); }); +const callBackendJson = async ( + path: string, + accessToken: string | undefined, + payload: unknown, +) => { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), config.TJWATER_API_TIMEOUT_MS); + try { + const headers: Record = { + Accept: "application/json", + "Content-Type": "application/json", + }; + if (accessToken) { + headers.Authorization = `Bearer ${accessToken}`; + } + const response = await fetch(new URL(path, config.TJWATER_API_BASE_URL), { + method: "POST", + headers, + body: JSON.stringify(payload), + signal: controller.signal, + }); + const text = await response.text(); + return { + ok: response.ok, + status: response.status, + text, + }; + } finally { + clearTimeout(timer); + } +}; + +const parseStringArray = (value: unknown) => + Array.isArray(value) + ? value.filter((item): item is string => typeof item === "string") + : undefined; + +app.post("/internal/tools/web-search", async (req, res) => { + if (req.header("x-agent-internal-token") !== internalToken) { + res.status(403).json({ message: "forbidden" }); + return; + } + + const sessionId = + typeof req.body?.session_id === "string" ? req.body.session_id.trim() : ""; + const context = sessionId ? getRuntimeSessionContext(sessionId) : null; + if (!context) { + res.status(404).json({ + message: "session context not found", + detail: sessionId, + }); + return; + } + + const query = typeof req.body?.query === "string" ? req.body.query.trim() : ""; + if (!query) { + res.status(400).json({ message: "query is required" }); + return; + } + + const count = + typeof req.body?.count === "number" && Number.isFinite(req.body.count) + ? Math.trunc(req.body.count) + : undefined; + const payload = { + query, + freshness: + typeof req.body?.freshness === "string" ? req.body.freshness : undefined, + summary: + typeof req.body?.summary === "boolean" ? req.body.summary : undefined, + count, + include: parseStringArray(req.body?.include), + exclude: parseStringArray(req.body?.exclude), + }; + + try { + const response = await callBackendJson( + "/api/v1/web-search", + context.accessToken, + payload, + ); + res + .status(response.ok ? 200 : response.status) + .type("application/json") + .send(response.text); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + res.status(503).json({ + message: "web search service unavailable", + detail, + }); + } +}); + +app.post("/internal/tools/geocode", async (req, res) => { + if (req.header("x-agent-internal-token") !== internalToken) { + res.status(403).json({ message: "forbidden" }); + return; + } + + const sessionId = + typeof req.body?.session_id === "string" ? req.body.session_id.trim() : ""; + const context = sessionId ? getRuntimeSessionContext(sessionId) : null; + if (!context) { + res.status(404).json({ + message: "session context not found", + detail: sessionId, + }); + return; + } + + const keyword = + typeof req.body?.keyword === "string" ? req.body.keyword.trim() : ""; + if (!keyword) { + res.status(400).json({ message: "keyword is required" }); + return; + } + + try { + const response = await callBackendJson( + "/api/v1/tianditu/geocode", + context.accessToken, + { keyword }, + ); + res + .status(response.ok ? 200 : response.status) + .type("application/json") + .send(response.text); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + res.status(503).json({ + message: "geocoding service unavailable", + detail, + }); + } +}); + app.use( "/api/v1/agent/chat", buildChatRouter(