feat(tools): add search and map tools
This commit is contained in:
@@ -30,7 +30,9 @@ Skills 树是**动态生长的**——工作流不是预置的,而是从实际
|
|||||||
|------|------|
|
|------|------|
|
||||||
| 获取后端数据(数据源、推理、分析) | `tjwater_cli` |
|
| 获取后端数据(数据源、推理、分析) | `tjwater_cli` |
|
||||||
| 发现可用命令 | `tjwater_cli(command="help")` |
|
| 发现可用命令 | `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` 渲染到前端 |
|
| 持久化渲染数据 | ①准备 { node_area_map } JSON → ②`store_render_ref` 存为受控 ref → ③`render_junctions` 渲染到前端 |
|
||||||
|
|
||||||
**前端工具仅做显示,不返回数据**,不要假设其返回内容。
|
**前端工具仅做显示,不返回数据**,不要假设其返回内容。
|
||||||
|
|||||||
@@ -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;
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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 "已缩放到指定地图坐标。";
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -61,9 +61,12 @@ const isDevelopmentDebugLoggingEnabled = process.env.NODE_ENV === "development";
|
|||||||
|
|
||||||
const toolLabels: Record<string, string> = {
|
const toolLabels: Record<string, string> = {
|
||||||
memory_manager: "记忆写入",
|
memory_manager: "记忆写入",
|
||||||
|
geocode: "地理编码",
|
||||||
session_search: "历史会话检索",
|
session_search: "历史会话检索",
|
||||||
skill_manager: "流程沉淀",
|
skill_manager: "流程沉淀",
|
||||||
|
web_search: "网页搜索",
|
||||||
locate_features: "地图定位",
|
locate_features: "地图定位",
|
||||||
|
zoom_to_map: "地图缩放",
|
||||||
view_history: "历史数据面板",
|
view_history: "历史数据面板",
|
||||||
view_scada: "SCADA 面板",
|
view_scada: "SCADA 面板",
|
||||||
show_chart: "图表渲染",
|
show_chart: "图表渲染",
|
||||||
|
|||||||
+137
@@ -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<string, string> = {
|
||||||
|
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(
|
app.use(
|
||||||
"/api/v1/agent/chat",
|
"/api/v1/agent/chat",
|
||||||
buildChatRouter(
|
buildChatRouter(
|
||||||
|
|||||||
Reference in New Issue
Block a user