@ynhcj/xiaoyi-channel 1.1.15 → 1.1.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/bot.d.ts +1 -0
- package/dist/src/bot.js +43 -8
- package/dist/src/channel.js +15 -1
- package/dist/src/monitor.js +1 -0
- package/dist/src/parser.d.ts +6 -0
- package/dist/src/parser.js +16 -0
- package/dist/src/provider.d.ts +2 -0
- package/dist/src/provider.js +107 -0
- package/dist/src/tools/device-tool-map.d.ts +4 -0
- package/dist/src/tools/device-tool-map.js +36 -0
- package/dist/src/tools/save-media-to-gallery-tool.d.ts +5 -0
- package/dist/src/tools/save-media-to-gallery-tool.js +178 -0
- package/dist/src/tools/send-command-to-car-tool.d.ts +5 -0
- package/dist/src/tools/send-command-to-car-tool.js +85 -0
- package/dist/src/tools/session-manager.d.ts +1 -0
- package/dist/src/tools/upload-file-tool.js +2 -2
- package/dist/src/tools/xiaoyi-add-collection-tool.d.ts +4 -0
- package/dist/src/tools/xiaoyi-add-collection-tool.js +173 -0
- package/dist/src/tools/xiaoyi-collection-tool.js +42 -7
- package/dist/src/tools/xiaoyi-delete-collection-tool.d.ts +4 -0
- package/dist/src/tools/xiaoyi-delete-collection-tool.js +163 -0
- package/dist/src/utils/runtime-manager.d.ts +7 -0
- package/dist/src/utils/runtime-manager.js +42 -0
- package/package.json +2 -3
package/dist/src/bot.d.ts
CHANGED
package/dist/src/bot.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { getXYRuntime } from "./runtime.js";
|
|
2
2
|
import { setCachedContext } from "./steer-injector.js";
|
|
3
3
|
import { createXYReplyDispatcher } from "./reply-dispatcher.js";
|
|
4
|
-
import { parseA2AMessage, extractTextFromParts, extractFileParts, extractPushId, extractTriggerData } from "./parser.js";
|
|
4
|
+
import { parseA2AMessage, extractTextFromParts, extractFileParts, extractPushId, extractDeviceType, extractTriggerData } from "./parser.js";
|
|
5
5
|
import { downloadFilesFromParts } from "./file-download.js";
|
|
6
6
|
import { resolveXYConfig } from "./config.js";
|
|
7
7
|
import { sendStatusUpdate, sendClearContextResponse, sendTasksCancelResponse, sendA2AResponse } from "./formatter.js";
|
|
@@ -9,6 +9,7 @@ import { registerSession, unregisterSession, runWithSessionContext } from "./too
|
|
|
9
9
|
import { configManager } from "./utils/config-manager.js";
|
|
10
10
|
import { addPushId } from "./utils/pushid-manager.js";
|
|
11
11
|
import { getPushDataById } from "./utils/pushdata-manager.js";
|
|
12
|
+
import { saveRuntimeInfo } from "./utils/runtime-manager.js";
|
|
12
13
|
import { registerTaskId, decrementTaskIdRef, lockTaskId, unlockTaskId, hasActiveTask, } from "./task-manager.js";
|
|
13
14
|
/**
|
|
14
15
|
* Handle an incoming A2A message.
|
|
@@ -16,7 +17,7 @@ import { registerTaskId, decrementTaskIdRef, lockTaskId, unlockTaskId, hasActive
|
|
|
16
17
|
* Runtime is expected to be validated before calling this function.
|
|
17
18
|
*/
|
|
18
19
|
export async function handleXYMessage(params) {
|
|
19
|
-
const { cfg, runtime, message, accountId } = params;
|
|
20
|
+
const { cfg, runtime, message, accountId, webSocketSessionId } = params;
|
|
20
21
|
const log = runtime?.log ?? console.log;
|
|
21
22
|
const error = runtime?.error ?? console.error;
|
|
22
23
|
// 每次收到消息时更新缓存,供 steer 注入使用
|
|
@@ -125,6 +126,18 @@ export async function handleXYMessage(params) {
|
|
|
125
126
|
else {
|
|
126
127
|
log(`[BOT] ℹ️ No push_id found in message, will use config default`);
|
|
127
128
|
}
|
|
129
|
+
// Extract deviceType if present (same level as push_id in systemVariables)
|
|
130
|
+
const deviceType = extractDeviceType(parsed.parts);
|
|
131
|
+
if (deviceType) {
|
|
132
|
+
log(`[BOT] 📱 Extracted deviceType from user message: ${deviceType}`);
|
|
133
|
+
}
|
|
134
|
+
// 保存 runtime 信息到 .xiaoyiruntime 文件(异步,不阻塞主流程)
|
|
135
|
+
saveRuntimeInfo(webSocketSessionId || parsed.sessionId, // SESSION_ID (WebSocket 层级,如果没有则 fallback)
|
|
136
|
+
parsed.sessionId, // CONVERSATION_ID (param 里的 sessionId)
|
|
137
|
+
parsed.taskId // TASK_ID (param.id)
|
|
138
|
+
).catch((err) => {
|
|
139
|
+
error(`[BOT] Failed to save runtime info:`, err);
|
|
140
|
+
});
|
|
128
141
|
// Resolve configuration (needed for status updates)
|
|
129
142
|
const config = resolveXYConfig(cfg);
|
|
130
143
|
// ✅ Resolve agent route (following feishu pattern)
|
|
@@ -230,7 +243,9 @@ export async function handleXYMessage(params) {
|
|
|
230
243
|
taskId: parsed.taskId,
|
|
231
244
|
messageId: parsed.messageId,
|
|
232
245
|
agentId: route.accountId,
|
|
246
|
+
deviceType,
|
|
233
247
|
};
|
|
248
|
+
log(`[BOT-DISPATCH] ⏳ withReplyDispatcher starting, sessionKey=${route.sessionKey}`);
|
|
234
249
|
await core.channel.reply.withReplyDispatcher({
|
|
235
250
|
dispatcher,
|
|
236
251
|
onSettled: () => {
|
|
@@ -249,12 +264,32 @@ export async function handleXYMessage(params) {
|
|
|
249
264
|
},
|
|
250
265
|
run: () =>
|
|
251
266
|
// 🔐 Use AsyncLocalStorage to provide session context to tools
|
|
252
|
-
runWithSessionContext(sessionContext, () =>
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
267
|
+
runWithSessionContext(sessionContext, async () => {
|
|
268
|
+
log(`[BOT-DISPATCH] ⏳ dispatchReplyFromConfig starting...`);
|
|
269
|
+
log(`[BOT-DISPATCH] - sessionKey: ${ctxPayload.SessionKey}`);
|
|
270
|
+
log(`[BOT-DISPATCH] - provider: ${ctxPayload.Provider}`);
|
|
271
|
+
log(`[BOT-DISPATCH] - surface: ${ctxPayload.Surface}`);
|
|
272
|
+
log(`[BOT-DISPATCH] - from: ${ctxPayload.From}`);
|
|
273
|
+
log(`[BOT-DISPATCH] - body length: ${ctxPayload.Body?.length ?? 0}`);
|
|
274
|
+
try {
|
|
275
|
+
const result = await core.channel.reply.dispatchReplyFromConfig({
|
|
276
|
+
ctx: ctxPayload,
|
|
277
|
+
cfg,
|
|
278
|
+
dispatcher,
|
|
279
|
+
replyOptions,
|
|
280
|
+
});
|
|
281
|
+
log(`[BOT-DISPATCH] ✅ dispatchReplyFromConfig returned`);
|
|
282
|
+
log(`[BOT-DISPATCH] - result: ${JSON.stringify(result)}`);
|
|
283
|
+
return result;
|
|
284
|
+
}
|
|
285
|
+
catch (dispatchErr) {
|
|
286
|
+
error(`[BOT-DISPATCH] ❌ dispatchReplyFromConfig threw`);
|
|
287
|
+
error(`[BOT-DISPATCH] - error name: ${dispatchErr instanceof Error ? dispatchErr.name : "unknown"}`);
|
|
288
|
+
error(`[BOT-DISPATCH] - error message: ${String(dispatchErr)}`);
|
|
289
|
+
error(`[BOT-DISPATCH] - error stack: ${dispatchErr instanceof Error ? dispatchErr.stack?.slice(0, 500) : "N/A"}`);
|
|
290
|
+
throw dispatchErr;
|
|
291
|
+
}
|
|
292
|
+
}),
|
|
258
293
|
});
|
|
259
294
|
log(`[BOT] ✅ Dispatcher completed for session: ${parsed.sessionId}`);
|
|
260
295
|
log(`xy: dispatch complete (session=${parsed.sessionId})`);
|
package/dist/src/channel.js
CHANGED
|
@@ -24,6 +24,14 @@ import { sendFileToUserTool } from "./tools/send-file-to-user-tool.js";
|
|
|
24
24
|
import { viewPushResultTool } from "./tools/view-push-result-tool.js";
|
|
25
25
|
import { imageReadingTool } from "./tools/image-reading-tool.js";
|
|
26
26
|
import { timestampToUtc8Tool } from "./tools/timestamp-to-utc8-tool.js";
|
|
27
|
+
import { sendCommandToCarTool } from "./tools/send-command-to-car-tool.js";
|
|
28
|
+
import { xiaoyiCollectionTool } from "./tools/xiaoyi-collection-tool.js";
|
|
29
|
+
import { xiaoyiAddCollectionTool } from "./tools/xiaoyi-add-collection-tool.js";
|
|
30
|
+
import { xiaoyiDeleteCollectionTool } from "./tools/xiaoyi-delete-collection-tool.js";
|
|
31
|
+
import { saveMediaToGalleryTool } from "./tools/save-media-to-gallery-tool.js";
|
|
32
|
+
import { filterToolsByDevice } from "./tools/device-tool-map.js";
|
|
33
|
+
import { getCurrentSessionContext } from "./tools/session-manager.js";
|
|
34
|
+
import { logger } from "./utils/logger.js";
|
|
27
35
|
/**
|
|
28
36
|
* Xiaoyi Channel Plugin for OpenClaw.
|
|
29
37
|
* Implements Xiaoyi A2A protocol with dual WebSocket connections.
|
|
@@ -62,7 +70,13 @@ export const xyPlugin = {
|
|
|
62
70
|
schema: xyConfigSchema,
|
|
63
71
|
},
|
|
64
72
|
outbound: xyOutbound,
|
|
65
|
-
agentTools:
|
|
73
|
+
agentTools: () => {
|
|
74
|
+
const allTools = [locationTool, noteTool, searchNoteTool, modifyNoteTool, calendarTool, searchCalendarTool, searchContactTool, searchPhotoGalleryTool, uploadPhotoTool, xiaoyiGuiTool, callPhoneTool, searchMessageTool, sendMessageTool, searchFileTool, uploadFileTool, createAlarmTool, searchAlarmTool, modifyAlarmTool, deleteAlarmTool, sendFileToUserTool, viewPushResultTool, imageReadingTool, timestampToUtc8Tool, sendCommandToCarTool, xiaoyiCollectionTool, xiaoyiAddCollectionTool, xiaoyiDeleteCollectionTool, saveMediaToGalleryTool];
|
|
75
|
+
const ctx = getCurrentSessionContext();
|
|
76
|
+
const filtered = filterToolsByDevice(allTools, ctx?.deviceType);
|
|
77
|
+
logger.log(`[DEVICE-FILTER] deviceType=${ctx?.deviceType ?? "(none)"}, tools: ${allTools.length} → ${filtered.length} (${filtered.map(t => t.name).join(", ")})`);
|
|
78
|
+
return filtered;
|
|
79
|
+
},
|
|
66
80
|
messaging: {
|
|
67
81
|
normalizeTarget: (raw) => {
|
|
68
82
|
const trimmed = raw.trim();
|
package/dist/src/monitor.js
CHANGED
package/dist/src/parser.d.ts
CHANGED
|
@@ -43,6 +43,12 @@ export declare function isTasksCancelMessage(method: string): boolean;
|
|
|
43
43
|
* Looks for push_id in data parts under variables.systemVariables.push_id
|
|
44
44
|
*/
|
|
45
45
|
export declare function extractPushId(parts: A2AMessagePart[]): string | null;
|
|
46
|
+
/**
|
|
47
|
+
* Extract deviceType from message parts.
|
|
48
|
+
* Looks for deviceType in data parts under variables.systemVariables.deviceType
|
|
49
|
+
* (same level as push_id).
|
|
50
|
+
*/
|
|
51
|
+
export declare function extractDeviceType(parts: A2AMessagePart[]): string | null;
|
|
46
52
|
/**
|
|
47
53
|
* Extract Trigger event data from message parts.
|
|
48
54
|
* Looks for Trigger events with pushDataId in data parts.
|
package/dist/src/parser.js
CHANGED
|
@@ -72,6 +72,22 @@ export function extractPushId(parts) {
|
|
|
72
72
|
}
|
|
73
73
|
return null;
|
|
74
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* Extract deviceType from message parts.
|
|
77
|
+
* Looks for deviceType in data parts under variables.systemVariables.deviceType
|
|
78
|
+
* (same level as push_id).
|
|
79
|
+
*/
|
|
80
|
+
export function extractDeviceType(parts) {
|
|
81
|
+
for (const part of parts) {
|
|
82
|
+
if (part.kind === "data" && part.data) {
|
|
83
|
+
const deviceType = part.data.variables?.systemVariables?.device_type;
|
|
84
|
+
if (deviceType && typeof deviceType === "string") {
|
|
85
|
+
return deviceType;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
75
91
|
/**
|
|
76
92
|
* Extract Trigger event data from message parts.
|
|
77
93
|
* Looks for Trigger events with pushDataId in data parts.
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { getCurrentSessionContext } from "./tools/session-manager.js";
|
|
2
|
+
/**
|
|
3
|
+
* Dynamic header keys injected via extraParams and forwarded to the HTTP request.
|
|
4
|
+
* Correspond to the three fields written to .xiaoyiruntime:
|
|
5
|
+
* TASK_ID, SESSION_ID, CONVERSATION_ID
|
|
6
|
+
*/
|
|
7
|
+
const HEADER_TASK_ID = "x-task-id";
|
|
8
|
+
const HEADER_SESSION_ID = "x-session-id";
|
|
9
|
+
const HEADER_CONVERSATION_ID = "x-conversation-id";
|
|
10
|
+
const EXTRA_PARAM_TASK_ID = "x-task-id";
|
|
11
|
+
const EXTRA_PARAM_SESSION_ID = "x-session-id";
|
|
12
|
+
const EXTRA_PARAM_CONVERSATION_ID = "x-conversation-id";
|
|
13
|
+
/**
|
|
14
|
+
* Encode uid to base64 and take first 32 chars.
|
|
15
|
+
*/
|
|
16
|
+
function encodeUid(uid) {
|
|
17
|
+
return Buffer.from(uid).toString("base64").slice(0, 32);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Get uid from plugin config (OpenClawConfig -> plugins -> xiaoyi-channel -> config).
|
|
21
|
+
*/
|
|
22
|
+
function getUidFromConfig(config) {
|
|
23
|
+
return config?.plugins?.entries?.["xiaoyi-channel"]?.config?.uid;
|
|
24
|
+
}
|
|
25
|
+
export const xiaoyiProvider = {
|
|
26
|
+
id: "xiaoyiprovider",
|
|
27
|
+
label: "Xiaoyi Provider",
|
|
28
|
+
docsPath: "/providers/models",
|
|
29
|
+
auth: [],
|
|
30
|
+
/**
|
|
31
|
+
* Inject dynamic session params into extraParams so they flow
|
|
32
|
+
* through to wrapStreamFn's ctx.extraParams.
|
|
33
|
+
*
|
|
34
|
+
* Priority:
|
|
35
|
+
* 1. Session context (from AsyncLocalStorage, set by bot.ts)
|
|
36
|
+
* 2. uid-based fallback: base64(uid)[:32]_timestamp
|
|
37
|
+
* 3. No uid available → return undefined (no headers injected)
|
|
38
|
+
*/
|
|
39
|
+
prepareExtraParams: (ctx) => {
|
|
40
|
+
const sessionCtx = getCurrentSessionContext();
|
|
41
|
+
if (sessionCtx) {
|
|
42
|
+
return {
|
|
43
|
+
...ctx.extraParams,
|
|
44
|
+
[EXTRA_PARAM_TASK_ID]: sessionCtx.taskId,
|
|
45
|
+
[EXTRA_PARAM_SESSION_ID]: sessionCtx.sessionId,
|
|
46
|
+
[EXTRA_PARAM_CONVERSATION_ID]: sessionCtx.messageId,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
// Fallback: uid-based values
|
|
50
|
+
const uid = getUidFromConfig(ctx.config);
|
|
51
|
+
if (!uid)
|
|
52
|
+
return undefined;
|
|
53
|
+
const prefix = encodeUid(uid);
|
|
54
|
+
const ts = Date.now();
|
|
55
|
+
const fallbackValue = `${prefix}_${ts}`;
|
|
56
|
+
return {
|
|
57
|
+
...ctx.extraParams,
|
|
58
|
+
[EXTRA_PARAM_TASK_ID]: fallbackValue,
|
|
59
|
+
[EXTRA_PARAM_SESSION_ID]: fallbackValue,
|
|
60
|
+
[EXTRA_PARAM_CONVERSATION_ID]: fallbackValue,
|
|
61
|
+
};
|
|
62
|
+
},
|
|
63
|
+
/**
|
|
64
|
+
* Wrap the stream function to inject dynamic headers into every
|
|
65
|
+
* HTTP request to the model provider.
|
|
66
|
+
*
|
|
67
|
+
* Reads the values injected by prepareExtraParams and adds them
|
|
68
|
+
* as HTTP headers on the outgoing request.
|
|
69
|
+
*/
|
|
70
|
+
wrapStreamFn: (ctx) => {
|
|
71
|
+
const underlying = ctx.streamFn;
|
|
72
|
+
if (!underlying)
|
|
73
|
+
return underlying;
|
|
74
|
+
const dynamicHeaders = {};
|
|
75
|
+
if (ctx.extraParams) {
|
|
76
|
+
const taskId = ctx.extraParams[EXTRA_PARAM_TASK_ID];
|
|
77
|
+
const sessionId = ctx.extraParams[EXTRA_PARAM_SESSION_ID];
|
|
78
|
+
const conversationId = ctx.extraParams[EXTRA_PARAM_CONVERSATION_ID];
|
|
79
|
+
if (typeof taskId === "string")
|
|
80
|
+
dynamicHeaders[HEADER_TASK_ID] = taskId;
|
|
81
|
+
if (typeof sessionId === "string")
|
|
82
|
+
dynamicHeaders[HEADER_SESSION_ID] = sessionId;
|
|
83
|
+
if (typeof conversationId === "string")
|
|
84
|
+
dynamicHeaders[HEADER_CONVERSATION_ID] = conversationId;
|
|
85
|
+
}
|
|
86
|
+
if (Object.keys(dynamicHeaders).length === 0)
|
|
87
|
+
return underlying;
|
|
88
|
+
return async (model, context, options) => {
|
|
89
|
+
// 记录输入
|
|
90
|
+
console.log(`[xiaoyiprovider] input messages count: ${context.messages.length}`);
|
|
91
|
+
if (context.systemPrompt) {
|
|
92
|
+
console.log(`[xiaoyiprovider] system prompt length: ${context.systemPrompt.length}`);
|
|
93
|
+
}
|
|
94
|
+
console.log(`[xiaoyiprovider] headers: ${JSON.stringify(dynamicHeaders)}`);
|
|
95
|
+
const stream = await underlying(model, context, {
|
|
96
|
+
...options,
|
|
97
|
+
headers: {
|
|
98
|
+
...options?.headers,
|
|
99
|
+
...dynamicHeaders,
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
// 异步监听输出(不阻塞 stream 返回)
|
|
103
|
+
stream.result().then((msg) => console.log(`[xiaoyiprovider] output: ${JSON.stringify(msg)}`), (err) => console.log(`[xiaoyiprovider] error: ${err}`));
|
|
104
|
+
return stream;
|
|
105
|
+
};
|
|
106
|
+
},
|
|
107
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Device type to tool name mapping.
|
|
2
|
+
// Supports two modes:
|
|
3
|
+
// - allowlist: only listed tools are available (used for restrictive devices like car)
|
|
4
|
+
// - denylist: listed tools are blocked, everything else is available (used for permissive devices like pc)
|
|
5
|
+
// Tools NOT listed in any device entry → available to all devices (no restriction).
|
|
6
|
+
/** Known device type enum. */
|
|
7
|
+
export const DEVICE_TYPES = ["car", "2in1", "phone"];
|
|
8
|
+
const DEVICE_TOOL_POLICY = {
|
|
9
|
+
"2in1": {
|
|
10
|
+
allowlist: false,
|
|
11
|
+
tools: [
|
|
12
|
+
"xiaoyi_gui_agent",
|
|
13
|
+
"call_phone",
|
|
14
|
+
"send_message",
|
|
15
|
+
"search_message",
|
|
16
|
+
"send_command_to_car",
|
|
17
|
+
"search_contact",
|
|
18
|
+
"QueryCollection",
|
|
19
|
+
"AddCollection",
|
|
20
|
+
"DeleteCollection",
|
|
21
|
+
],
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
export function filterToolsByDevice(tools, deviceType) {
|
|
25
|
+
if (!deviceType)
|
|
26
|
+
return tools;
|
|
27
|
+
const policy = DEVICE_TOOL_POLICY[deviceType];
|
|
28
|
+
if (!policy)
|
|
29
|
+
return tools; // unrecognized device → no filtering
|
|
30
|
+
if (policy.allowlist) {
|
|
31
|
+
return tools.filter((tool) => policy.tools.includes(tool.name));
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
return tools.filter((tool) => !policy.tools.includes(tool.name));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { getXYWebSocketManager } from "../client.js";
|
|
2
|
+
import { sendCommand } from "../formatter.js";
|
|
3
|
+
import { getCurrentSessionContext } from "./session-manager.js";
|
|
4
|
+
import { XYFileUploadService } from "../file-upload.js";
|
|
5
|
+
/**
|
|
6
|
+
* Duck-typed ToolInputError: openclaw 按 .name 字段匹配,不用 instanceof。
|
|
7
|
+
* 抛出此错误会让 openclaw 返回 HTTP 400 而非 500,
|
|
8
|
+
* LLM 会将其识别为参数错误而非瞬时故障,不会触发重试。
|
|
9
|
+
*/
|
|
10
|
+
class ToolInputError extends Error {
|
|
11
|
+
status = 400;
|
|
12
|
+
constructor(message) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = "ToolInputError";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* XY save media to gallery tool - saves image or video files to user's device gallery.
|
|
19
|
+
* Supports local file paths (auto-uploaded to get public URL) and public URLs.
|
|
20
|
+
*/
|
|
21
|
+
export const saveMediaToGalleryTool = {
|
|
22
|
+
name: "SaveMediaToGallery",
|
|
23
|
+
label: "Save Media to Gallery",
|
|
24
|
+
description: `将图片文件或者视频文件保存到手机图库。
|
|
25
|
+
工具参数说明:
|
|
26
|
+
a. mediaType:非必填,string类型,不传端侧默认为pic。支持传 pic(图片) 或 video(视频)。
|
|
27
|
+
b. fileName:非必填,string类型,文件名称,不传手机侧默认生成随机uuid。
|
|
28
|
+
c. url:必填,string类型,支持本地路径或者公网url路径。如果是本地路径,会先上传获取公网url再保存到图库。
|
|
29
|
+
|
|
30
|
+
注意:
|
|
31
|
+
a. 操作超时时间为60秒,请勿重复调用此工具
|
|
32
|
+
b. 如果遇到各类调用失败场景,最多只能重试一次,不可以重复调用多次。
|
|
33
|
+
c. 调用工具前需认真检查调用参数是否满足工具要求
|
|
34
|
+
|
|
35
|
+
回复约束:如果工具返回没有授权或者其他报错,只需要完整描述没有授权或者其他报错内容即可,不需要主动给用户提供解决方案,例如告诉用户如何授权,如何解决报错等都是不需要的,请严格遵守。
|
|
36
|
+
`,
|
|
37
|
+
parameters: {
|
|
38
|
+
type: "object",
|
|
39
|
+
properties: {
|
|
40
|
+
mediaType: {
|
|
41
|
+
type: "string",
|
|
42
|
+
description: "非必填,不传默认为pic。支持 pic(图片) 或 video(视频)。",
|
|
43
|
+
},
|
|
44
|
+
fileName: {
|
|
45
|
+
type: "string",
|
|
46
|
+
description: "非必填,文件名称,不传手机侧默认生成随机uuid。",
|
|
47
|
+
},
|
|
48
|
+
url: {
|
|
49
|
+
type: "string",
|
|
50
|
+
description: "必填,支持本地路径或者公网url路径。如果是本地路径会先上传获取公网url。",
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
required: ["url"],
|
|
54
|
+
},
|
|
55
|
+
async execute(toolCallId, params) {
|
|
56
|
+
// Validate parameters
|
|
57
|
+
const { mediaType, fileName, url } = params;
|
|
58
|
+
if (!url || typeof url !== "string") {
|
|
59
|
+
throw new ToolInputError("缺少必填参数: url");
|
|
60
|
+
}
|
|
61
|
+
if (mediaType && !["pic", "video"].includes(mediaType)) {
|
|
62
|
+
throw new ToolInputError(`mediaType只支持 pic 或 video,当前值: ${mediaType}`);
|
|
63
|
+
}
|
|
64
|
+
// Strip file extension from fileName if present
|
|
65
|
+
let sanitizedFileName = fileName;
|
|
66
|
+
if (sanitizedFileName && typeof sanitizedFileName === "string") {
|
|
67
|
+
const lastDot = sanitizedFileName.lastIndexOf(".");
|
|
68
|
+
if (lastDot > 0) {
|
|
69
|
+
sanitizedFileName = sanitizedFileName.substring(0, lastDot);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// Get session context
|
|
73
|
+
const sessionContext = getCurrentSessionContext();
|
|
74
|
+
if (!sessionContext) {
|
|
75
|
+
throw new Error("No active XY session found. SaveMediaToGallery tool can only be used during an active conversation.");
|
|
76
|
+
}
|
|
77
|
+
const { config, sessionId, taskId, messageId } = sessionContext;
|
|
78
|
+
// Get WebSocket manager
|
|
79
|
+
const wsManager = getXYWebSocketManager(config);
|
|
80
|
+
// Determine the URL: if it's a local path, upload first to get public URL
|
|
81
|
+
let publicUrl = url;
|
|
82
|
+
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
|
83
|
+
// Local file path - upload to get public URL
|
|
84
|
+
const uploadService = new XYFileUploadService(config.fileUploadUrl, config.apiKey, config.uid);
|
|
85
|
+
publicUrl = await uploadService.uploadFileAndGetUrl(url);
|
|
86
|
+
if (!publicUrl) {
|
|
87
|
+
throw new Error("本地文件上传失败,无法获取公网URL");
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// Build intentParam
|
|
91
|
+
const intentParam = {
|
|
92
|
+
url: publicUrl,
|
|
93
|
+
};
|
|
94
|
+
if (mediaType) {
|
|
95
|
+
intentParam.mediaType = mediaType;
|
|
96
|
+
}
|
|
97
|
+
if (sanitizedFileName) {
|
|
98
|
+
intentParam.fileName = sanitizedFileName;
|
|
99
|
+
}
|
|
100
|
+
// Build SaveMediaToGallery command
|
|
101
|
+
const command = {
|
|
102
|
+
header: {
|
|
103
|
+
namespace: "Common",
|
|
104
|
+
name: "Action",
|
|
105
|
+
},
|
|
106
|
+
payload: {
|
|
107
|
+
cardParam: {},
|
|
108
|
+
executeParam: {
|
|
109
|
+
executeMode: "background",
|
|
110
|
+
intentName: "SaveMediaToGallery",
|
|
111
|
+
bundleName: "com.huawei.hmos.vassistant",
|
|
112
|
+
dimension: "",
|
|
113
|
+
needUnlock: true,
|
|
114
|
+
actionResponse: true,
|
|
115
|
+
appType: "OHOS_APP",
|
|
116
|
+
timeOut: 5,
|
|
117
|
+
intentParam,
|
|
118
|
+
permissionId: ["ohos.permission.WRITE_IMAGEVIDEO"],
|
|
119
|
+
achieveType: "INTENT",
|
|
120
|
+
},
|
|
121
|
+
responses: [
|
|
122
|
+
{
|
|
123
|
+
resultCode: "",
|
|
124
|
+
displayText: "",
|
|
125
|
+
ttsText: "",
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
needUploadResult: true,
|
|
129
|
+
noHalfPage: false,
|
|
130
|
+
pageControlRelated: false,
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
// Send command and wait for response (60 second timeout)
|
|
134
|
+
return new Promise((resolve, reject) => {
|
|
135
|
+
const timeout = setTimeout(() => {
|
|
136
|
+
wsManager.off("data-event", handler);
|
|
137
|
+
reject(new Error("保存媒体到图库超时(60秒)"));
|
|
138
|
+
}, 60000);
|
|
139
|
+
// Listen for data events from WebSocket
|
|
140
|
+
const handler = (event) => {
|
|
141
|
+
if (event.intentName === "SaveMediaToGallery") {
|
|
142
|
+
clearTimeout(timeout);
|
|
143
|
+
wsManager.off("data-event", handler);
|
|
144
|
+
if (event.status === "success" && event.outputs) {
|
|
145
|
+
resolve({
|
|
146
|
+
content: [
|
|
147
|
+
{
|
|
148
|
+
type: "text",
|
|
149
|
+
text: JSON.stringify(event.outputs),
|
|
150
|
+
}
|
|
151
|
+
]
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
reject(new Error(`保存媒体到图库失败: ${event.status}`));
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
// Register event handler
|
|
160
|
+
wsManager.on("data-event", handler);
|
|
161
|
+
// Send the command
|
|
162
|
+
sendCommand({
|
|
163
|
+
config,
|
|
164
|
+
sessionId,
|
|
165
|
+
taskId,
|
|
166
|
+
messageId,
|
|
167
|
+
command,
|
|
168
|
+
})
|
|
169
|
+
.then(() => {
|
|
170
|
+
})
|
|
171
|
+
.catch((error) => {
|
|
172
|
+
clearTimeout(timeout);
|
|
173
|
+
wsManager.off("data-event", handler);
|
|
174
|
+
reject(error);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
},
|
|
178
|
+
};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Send Command To Car tool implementation
|
|
2
|
+
import { sendCommand } from "../formatter.js";
|
|
3
|
+
import { getCurrentSessionContext } from "./session-manager.js";
|
|
4
|
+
import { logger } from "../utils/logger.js";
|
|
5
|
+
/**
|
|
6
|
+
* Send command to car (小艺车机) tool - sends an output command to the car's Xiaoyi system.
|
|
7
|
+
* The command will be received and executed on the car's Xiaoyi device.
|
|
8
|
+
*/
|
|
9
|
+
export const sendCommandToCarTool = {
|
|
10
|
+
name: "send_command_to_car",
|
|
11
|
+
label: "Send Command To Car",
|
|
12
|
+
description: "将输出指令发送给小艺车机,车机小艺会接收并执行该指令。注意:请勿重复调用此工具,如果超时或失败,最多重试一次。回复约束:如果工具返回没有授权或者其他报错,只需要完整描述没有授权或者其他报错内容即可,不需要主动给用户提供解决方案。",
|
|
13
|
+
parameters: {
|
|
14
|
+
type: "object",
|
|
15
|
+
properties: {
|
|
16
|
+
command: {
|
|
17
|
+
type: "string",
|
|
18
|
+
description: "要发送给车机的指令内容(对应intentParam中的out字段)",
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
required: ["command"],
|
|
22
|
+
},
|
|
23
|
+
async execute(toolCallId, params) {
|
|
24
|
+
// Validate command parameter
|
|
25
|
+
if (!params.command || typeof params.command !== "string" || params.command.trim() === "") {
|
|
26
|
+
throw new Error("Missing required parameter: command must be a non-empty string");
|
|
27
|
+
}
|
|
28
|
+
// Get session context
|
|
29
|
+
const sessionContext = getCurrentSessionContext();
|
|
30
|
+
if (!sessionContext) {
|
|
31
|
+
throw new Error("No active XY session found. Send command to car tool can only be used during an active conversation.");
|
|
32
|
+
}
|
|
33
|
+
const { config, sessionId, taskId, messageId } = sessionContext;
|
|
34
|
+
// Build PlayStoryBook command
|
|
35
|
+
const command = {
|
|
36
|
+
header: {
|
|
37
|
+
namespace: "Common",
|
|
38
|
+
name: "Action",
|
|
39
|
+
},
|
|
40
|
+
payload: {
|
|
41
|
+
cardParam: {},
|
|
42
|
+
executeParam: {
|
|
43
|
+
achieveType: "INTENT",
|
|
44
|
+
actionResponse: true,
|
|
45
|
+
bundleName: "com.huawei.vassistantcar",
|
|
46
|
+
dimension: "",
|
|
47
|
+
executeMode: "background",
|
|
48
|
+
intentName: "PlayStoryBook",
|
|
49
|
+
intentParam: {
|
|
50
|
+
out: params.command,
|
|
51
|
+
},
|
|
52
|
+
needUnlock: true,
|
|
53
|
+
permissionId: [],
|
|
54
|
+
timeOut: 5,
|
|
55
|
+
},
|
|
56
|
+
needUploadResult: true,
|
|
57
|
+
pageControlRelated: false,
|
|
58
|
+
responses: [
|
|
59
|
+
{
|
|
60
|
+
displayText: "",
|
|
61
|
+
resultCode: "",
|
|
62
|
+
ttsText: "",
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
// Send command - fire and forget, return success once sent
|
|
68
|
+
await sendCommand({
|
|
69
|
+
config,
|
|
70
|
+
sessionId,
|
|
71
|
+
taskId,
|
|
72
|
+
messageId,
|
|
73
|
+
command,
|
|
74
|
+
});
|
|
75
|
+
logger.log("[sendCommandToCar] command sent to car successfully");
|
|
76
|
+
return {
|
|
77
|
+
content: [
|
|
78
|
+
{
|
|
79
|
+
type: "text",
|
|
80
|
+
text: JSON.stringify({ success: true, message: "指令已成功下发给车机" }),
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
};
|
|
84
|
+
},
|
|
85
|
+
};
|
|
@@ -18,10 +18,10 @@ export const uploadFileTool = {
|
|
|
18
18
|
label: "Upload File",
|
|
19
19
|
description: `工具能力描述:将手机本地文件上传并获取可公网访问的 URL。
|
|
20
20
|
|
|
21
|
-
前置工具调用:此工具使用前必须先调用 search_file 工具获取文件的 uri
|
|
21
|
+
前置工具调用:此工具使用前必须先调用 search_file 或者 QueryCollection 工具获取文件的 uri
|
|
22
22
|
|
|
23
23
|
工具参数说明:
|
|
24
|
-
a. 入参中的fileInfos数组,每个元素必须包含mediaUri字段(对应于search_file
|
|
24
|
+
a. 入参中的fileInfos数组,每个元素必须包含mediaUri字段(对应于search_file工具或者QueryCollection返回结果中的uri),必须与search_file或者QueryCollection结果中对应的uri完全保持一致,不要自行修改。
|
|
25
25
|
b. fileInfos中的timeout字段是可选的,表示上传文件超时时间,单位是毫秒,默认是20000(20秒)。
|
|
26
26
|
c. fileInfos 是文件在手机本地的信息数组(从 search_file 工具响应中获取)。限制:每次最多支持传入 5 条文件信息。
|
|
27
27
|
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { getXYWebSocketManager } from "../client.js";
|
|
2
|
+
import { sendCommand } from "../formatter.js";
|
|
3
|
+
import { getCurrentSessionContext } from "./session-manager.js";
|
|
4
|
+
import { XYFileUploadService } from "../file-upload.js";
|
|
5
|
+
/**
|
|
6
|
+
* Duck-typed ToolInputError: openclaw 按 .name 字段匹配,不用 instanceof。
|
|
7
|
+
* 抛出此错误会让 openclaw 返回 HTTP 400 而非 500,
|
|
8
|
+
* LLM 会将其识别为参数错误而非瞬时故障,不会触发重试。
|
|
9
|
+
*/
|
|
10
|
+
class ToolInputError extends Error {
|
|
11
|
+
status = 400;
|
|
12
|
+
constructor(message) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = "ToolInputError";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* XY add collection tool - adds data to user's XiaoYi collection.
|
|
19
|
+
*/
|
|
20
|
+
export const xiaoyiAddCollectionTool = {
|
|
21
|
+
name: "AddCollection",
|
|
22
|
+
label: "Add XiaoYi Collection",
|
|
23
|
+
description: `向小艺收藏中添加公共知识数据,可以给用户提供个性化体验。用户希望保存到个人化知识库中的数据都可以调用本技能。不同类型的数据对应的数据要求如下:
|
|
24
|
+
注意:
|
|
25
|
+
a. 操作超时时间为60秒,请勿重复调用此工具
|
|
26
|
+
b. 如果遇到各类调用失败场景,最多只能重试一次,不可以重复调用多次。
|
|
27
|
+
c. 调用工具前需认真检查调用参数是否满足工具要求
|
|
28
|
+
|
|
29
|
+
回复约束:如果工具返回没有授权或者其他报错,只需要完整描述没有授权或者其他报错内容即可,不需要主动给用户提供解决方案,例如告诉用户如何授权,如何解决报错等都是不需要的,请严格遵守。
|
|
30
|
+
`,
|
|
31
|
+
parameters: {
|
|
32
|
+
type: "object",
|
|
33
|
+
properties: {
|
|
34
|
+
content: {
|
|
35
|
+
type: "string",
|
|
36
|
+
description: "必填字段(HYPER_LINK/TEXT类型时)。用户添加收藏的链接url或文本原文。",
|
|
37
|
+
},
|
|
38
|
+
uri: {
|
|
39
|
+
type: "string",
|
|
40
|
+
description: "必填字段(IMAGE/FILE类型时)。图片或文件的地址链接。",
|
|
41
|
+
},
|
|
42
|
+
sourceAppBundleName: {
|
|
43
|
+
type: "string",
|
|
44
|
+
description: "非必填字段。标识该数据的来源应用。",
|
|
45
|
+
},
|
|
46
|
+
dataType: {
|
|
47
|
+
type: "string",
|
|
48
|
+
description: "必填字段。标识数据类型:HYPER_LINK表示网页,TEXT表示文本,IMAGE表示图片,FILE表示文件。",
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
required: ["dataType"],
|
|
52
|
+
},
|
|
53
|
+
async execute(toolCallId, params) {
|
|
54
|
+
// Validate parameters
|
|
55
|
+
const { content, uri, sourceAppBundleName, dataType } = params;
|
|
56
|
+
const validTypes = ["HYPER_LINK", "TEXT", "IMAGE", "FILE"];
|
|
57
|
+
if (!dataType || !validTypes.includes(dataType)) {
|
|
58
|
+
throw new ToolInputError(`dataType必填且必须为 HYPER_LINK、TEXT、IMAGE、FILE 之一,当前值: ${dataType}`);
|
|
59
|
+
}
|
|
60
|
+
if ((dataType === "HYPER_LINK" || dataType === "TEXT") && (!content || typeof content !== "string")) {
|
|
61
|
+
throw new ToolInputError(`dataType为${dataType}时,content字段必填且不能为空`);
|
|
62
|
+
}
|
|
63
|
+
if ((dataType === "IMAGE" || dataType === "FILE") && (!uri || typeof uri !== "string")) {
|
|
64
|
+
throw new ToolInputError(`dataType为${dataType}时,uri字段必填且不能为空`);
|
|
65
|
+
}
|
|
66
|
+
// Get session context
|
|
67
|
+
const sessionContext = getCurrentSessionContext();
|
|
68
|
+
if (!sessionContext) {
|
|
69
|
+
throw new Error("No active XY session found. AddCollection tool can only be used during an active conversation.");
|
|
70
|
+
}
|
|
71
|
+
const { config, sessionId, taskId, messageId } = sessionContext;
|
|
72
|
+
// Get WebSocket manager
|
|
73
|
+
const wsManager = getXYWebSocketManager(config);
|
|
74
|
+
// Handle uri: upload local paths to get public URL
|
|
75
|
+
let publicUri = uri;
|
|
76
|
+
if (uri && !uri.startsWith("http://") && !uri.startsWith("https://") && !uri.startsWith("file://")) {
|
|
77
|
+
const uploadService = new XYFileUploadService(config.fileUploadUrl, config.apiKey, config.uid);
|
|
78
|
+
publicUri = await uploadService.uploadFileAndGetUrl(uri);
|
|
79
|
+
if (!publicUri) {
|
|
80
|
+
throw new Error("本地文件上传失败,无法获取公网URL");
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// Build intentParam
|
|
84
|
+
const intentParam = {
|
|
85
|
+
dataType,
|
|
86
|
+
};
|
|
87
|
+
if (content) {
|
|
88
|
+
intentParam.content = content;
|
|
89
|
+
}
|
|
90
|
+
if (publicUri) {
|
|
91
|
+
intentParam.uri = publicUri;
|
|
92
|
+
}
|
|
93
|
+
if (sourceAppBundleName) {
|
|
94
|
+
intentParam.sourceAppBundleName = sourceAppBundleName;
|
|
95
|
+
}
|
|
96
|
+
// Build AddCollection command
|
|
97
|
+
const command = {
|
|
98
|
+
header: {
|
|
99
|
+
namespace: "Common",
|
|
100
|
+
name: "Action",
|
|
101
|
+
},
|
|
102
|
+
payload: {
|
|
103
|
+
cardParam: {},
|
|
104
|
+
executeParam: {
|
|
105
|
+
executeMode: "background",
|
|
106
|
+
intentName: "AddCollection",
|
|
107
|
+
bundleName: "com.huawei.hmos.vassistant",
|
|
108
|
+
needUnlock: true,
|
|
109
|
+
actionResponse: true,
|
|
110
|
+
appType: "OHOS_APP",
|
|
111
|
+
timeOut: 5,
|
|
112
|
+
intentParam,
|
|
113
|
+
permissionId: [],
|
|
114
|
+
achieveType: "INTENT",
|
|
115
|
+
},
|
|
116
|
+
responses: [
|
|
117
|
+
{
|
|
118
|
+
resultCode: "",
|
|
119
|
+
displayText: "",
|
|
120
|
+
ttsText: "",
|
|
121
|
+
},
|
|
122
|
+
],
|
|
123
|
+
needUploadResult: true,
|
|
124
|
+
noHalfPage: false,
|
|
125
|
+
pageControlRelated: false,
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
// Send command and wait for response (60 second timeout)
|
|
129
|
+
return new Promise((resolve, reject) => {
|
|
130
|
+
const timeout = setTimeout(() => {
|
|
131
|
+
wsManager.off("data-event", handler);
|
|
132
|
+
reject(new Error("添加小艺收藏超时(60秒)"));
|
|
133
|
+
}, 60000);
|
|
134
|
+
// Listen for data events from WebSocket
|
|
135
|
+
const handler = (event) => {
|
|
136
|
+
if (event.intentName === "AddCollection") {
|
|
137
|
+
clearTimeout(timeout);
|
|
138
|
+
wsManager.off("data-event", handler);
|
|
139
|
+
if (event.status === "success" && event.outputs) {
|
|
140
|
+
resolve({
|
|
141
|
+
content: [
|
|
142
|
+
{
|
|
143
|
+
type: "text",
|
|
144
|
+
text: JSON.stringify(event.outputs),
|
|
145
|
+
}
|
|
146
|
+
]
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
reject(new Error(`添加小艺收藏失败: ${event.status}`));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
// Register event handler
|
|
155
|
+
wsManager.on("data-event", handler);
|
|
156
|
+
// Send the command
|
|
157
|
+
sendCommand({
|
|
158
|
+
config,
|
|
159
|
+
sessionId,
|
|
160
|
+
taskId,
|
|
161
|
+
messageId,
|
|
162
|
+
command,
|
|
163
|
+
})
|
|
164
|
+
.then(() => {
|
|
165
|
+
})
|
|
166
|
+
.catch((error) => {
|
|
167
|
+
clearTimeout(timeout);
|
|
168
|
+
wsManager.off("data-event", handler);
|
|
169
|
+
reject(error);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
},
|
|
173
|
+
};
|
|
@@ -1,26 +1,54 @@
|
|
|
1
1
|
import { getXYWebSocketManager } from "../client.js";
|
|
2
2
|
import { sendCommand } from "../formatter.js";
|
|
3
3
|
import { getCurrentSessionContext } from "./session-manager.js";
|
|
4
|
+
/**
|
|
5
|
+
* Duck-typed ToolInputError: openclaw 按 .name 字段匹配,不用 instanceof。
|
|
6
|
+
* 抛出此错误会让 openclaw 返回 HTTP 400 而非 500,
|
|
7
|
+
* LLM 会将其识别为参数错误而非瞬时故障,不会触发重试。
|
|
8
|
+
*/
|
|
9
|
+
class ToolInputError extends Error {
|
|
10
|
+
status = 400;
|
|
11
|
+
constructor(message) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = "ToolInputError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
4
16
|
/**
|
|
5
17
|
* XY collection tool - retrieves user's collection data from XiaoYi.
|
|
6
18
|
* Returns personalized knowledge data saved in user's collection.
|
|
7
19
|
*/
|
|
8
20
|
export const xiaoyiCollectionTool = {
|
|
9
|
-
name: "
|
|
21
|
+
name: "QueryCollection",
|
|
10
22
|
label: "XiaoYi Collection",
|
|
11
|
-
description:
|
|
23
|
+
description: `检索用户在小艺收藏中记下来的公共知识数据,本技能支持查询用户收藏的公共知识数据,也可以根据特定语义化描述进行特定内容的检索,通过参数进行控制。本技能返回结果中,linkTitle是收藏内容的标题,description是对收藏内容的总结,label是收藏内容的标签,linkUrl是可以直接访问的原始内容链接。如果你认为某条数据对用户交互有用,可以通过linkUrl抓取更加丰富的原始数据。
|
|
24
|
+
注意:
|
|
25
|
+
a. 操作超时时间为60秒,请勿重复调用此工具
|
|
26
|
+
b. 如果遇到各类调用失败场景,最多只能重试一次,不可以重复调用多次。
|
|
27
|
+
c. 调用工具前需认真检查调用参数是否满足工具要求
|
|
28
|
+
|
|
29
|
+
回复约束:如果工具返回没有授权或者其他报错,只需要完整描述没有授权或者其他报错内容即可,不需要主动给用户提供解决方案,例如告诉用户如何授权,如何解决报错等都是不需要的,请严格遵守。
|
|
30
|
+
`,
|
|
12
31
|
parameters: {
|
|
13
32
|
type: "object",
|
|
14
33
|
properties: {
|
|
15
34
|
queryAll: {
|
|
16
35
|
type: "string",
|
|
17
|
-
description: "
|
|
18
|
-
|
|
36
|
+
description: "非必填参数,描述是否需要查询用户所有收藏数据。如果填入true则表示获取用户所有公共知识数据,其他参数无效。",
|
|
37
|
+
},
|
|
38
|
+
query: {
|
|
39
|
+
type: "string",
|
|
40
|
+
description: "非必填参数,queryAll不填或者为false则必填。用户的查询条件,可按照用户query进行检索。",
|
|
19
41
|
},
|
|
20
42
|
},
|
|
21
43
|
required: [],
|
|
22
44
|
},
|
|
23
45
|
async execute(toolCallId, params) {
|
|
46
|
+
// Validate parameters
|
|
47
|
+
const queryAll = params.queryAll;
|
|
48
|
+
const query = params.query;
|
|
49
|
+
if (queryAll !== "true" && (!query || typeof query !== "string")) {
|
|
50
|
+
throw new ToolInputError("queryAll不为true时,query参数必填");
|
|
51
|
+
}
|
|
24
52
|
// Get session context
|
|
25
53
|
const sessionContext = getCurrentSessionContext();
|
|
26
54
|
if (!sessionContext) {
|
|
@@ -29,6 +57,15 @@ export const xiaoyiCollectionTool = {
|
|
|
29
57
|
const { config, sessionId, taskId, messageId } = sessionContext;
|
|
30
58
|
// Get WebSocket manager
|
|
31
59
|
const wsManager = getXYWebSocketManager(config);
|
|
60
|
+
// Build intentParam
|
|
61
|
+
const intentParam = {};
|
|
62
|
+
if (queryAll === "true") {
|
|
63
|
+
intentParam.queryAll = "true";
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
intentParam.queryAll = "false";
|
|
67
|
+
intentParam.query = query;
|
|
68
|
+
}
|
|
32
69
|
// Build QueryCollection command
|
|
33
70
|
const command = {
|
|
34
71
|
header: {
|
|
@@ -45,9 +82,7 @@ export const xiaoyiCollectionTool = {
|
|
|
45
82
|
actionResponse: true,
|
|
46
83
|
appType: "OHOS_APP",
|
|
47
84
|
timeOut: 5,
|
|
48
|
-
intentParam
|
|
49
|
-
queryAll: params.queryAll || "true",
|
|
50
|
-
},
|
|
85
|
+
intentParam,
|
|
51
86
|
permissionId: [],
|
|
52
87
|
achieveType: "INTENT",
|
|
53
88
|
},
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { getXYWebSocketManager } from "../client.js";
|
|
2
|
+
import { sendCommand } from "../formatter.js";
|
|
3
|
+
import { getCurrentSessionContext } from "./session-manager.js";
|
|
4
|
+
/**
|
|
5
|
+
* Duck-typed ToolInputError: openclaw 按 .name 字段匹配,不用 instanceof。
|
|
6
|
+
* 抛出此错误会让 openclaw 返回 HTTP 400 而非 500,
|
|
7
|
+
* LLM 会将其识别为参数错误而非瞬时故障,不会触发重试。
|
|
8
|
+
*/
|
|
9
|
+
class ToolInputError extends Error {
|
|
10
|
+
status = 400;
|
|
11
|
+
constructor(message) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = "ToolInputError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* XY delete collection tool - deletes data from user's XiaoYi collection.
|
|
18
|
+
*/
|
|
19
|
+
export const xiaoyiDeleteCollectionTool = {
|
|
20
|
+
name: "DeleteCollection",
|
|
21
|
+
label: "Delete XiaoYi Collection",
|
|
22
|
+
description: `从小艺收藏中删除之前已保存的公共知识数据。任何用户希望删除已保存到个人知识库的数据都可以调用本技能。如果用户想更新之前的收藏数据,需要先query获取itemId然后再delete,最后执行Add,按照这个步骤完成收藏数据更新。
|
|
23
|
+
注意:
|
|
24
|
+
a. 操作超时时间为60秒,请勿重复调用此工具
|
|
25
|
+
b. 如果遇到各类调用失败场景,最多只能重试一次,不可以重复调用多次。
|
|
26
|
+
c. 调用工具前需认真检查调用参数是否满足工具要求
|
|
27
|
+
|
|
28
|
+
回复约束:如果工具返回没有授权或者其他报错,只需要完整描述没有授权或者其他报错内容即可,不需要主动给用户提供解决方案,例如告诉用户如何授权,如何解决报错等都是不需要的,请严格遵守。
|
|
29
|
+
`,
|
|
30
|
+
parameters: {
|
|
31
|
+
type: "object",
|
|
32
|
+
properties: {
|
|
33
|
+
itemIds: {
|
|
34
|
+
// 不指定 type,允许传入数组或 JSON 字符串
|
|
35
|
+
// 具体的类型验证和转换在 execute 函数内部进行
|
|
36
|
+
description: "准备删除的数据的itemId合集。itemId可以由用户指定,也可以从之前检索回来的收藏数据项的itemId字段获取。",
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
required: ["itemIds"],
|
|
40
|
+
},
|
|
41
|
+
async execute(toolCallId, params) {
|
|
42
|
+
// ===== 参数规范化:兼容数组和 JSON 字符串 =====
|
|
43
|
+
let itemIds = null;
|
|
44
|
+
if (!params.itemIds) {
|
|
45
|
+
throw new ToolInputError("缺少必填参数: itemIds");
|
|
46
|
+
}
|
|
47
|
+
// 情况1: 已经是数组
|
|
48
|
+
if (Array.isArray(params.itemIds)) {
|
|
49
|
+
itemIds = params.itemIds;
|
|
50
|
+
}
|
|
51
|
+
// 情况2: 是字符串,尝试解析为 JSON 数组
|
|
52
|
+
else if (typeof params.itemIds === 'string') {
|
|
53
|
+
try {
|
|
54
|
+
const parsed = JSON.parse(params.itemIds);
|
|
55
|
+
if (Array.isArray(parsed)) {
|
|
56
|
+
itemIds = parsed;
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
throw new ToolInputError("itemIds must be an array or a JSON string representing an array");
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
catch (parseError) {
|
|
63
|
+
if (parseError instanceof ToolInputError)
|
|
64
|
+
throw parseError;
|
|
65
|
+
throw new ToolInputError(`itemIds must be a valid JSON array string. Parse error: ${parseError instanceof Error ? parseError.message : String(parseError)}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// 情况3: 其他类型,报错
|
|
69
|
+
else {
|
|
70
|
+
throw new ToolInputError(`itemIds must be an array or a JSON string, got ${typeof params.itemIds}`);
|
|
71
|
+
}
|
|
72
|
+
// 验证数组非空
|
|
73
|
+
if (!itemIds || itemIds.length === 0) {
|
|
74
|
+
throw new ToolInputError("itemIds array cannot be empty");
|
|
75
|
+
}
|
|
76
|
+
// Get session context
|
|
77
|
+
const sessionContext = getCurrentSessionContext();
|
|
78
|
+
if (!sessionContext) {
|
|
79
|
+
throw new Error("No active XY session found. DeleteCollection tool can only be used during an active conversation.");
|
|
80
|
+
}
|
|
81
|
+
const { config, sessionId, taskId, messageId } = sessionContext;
|
|
82
|
+
// Get WebSocket manager
|
|
83
|
+
const wsManager = getXYWebSocketManager(config);
|
|
84
|
+
// Build DeleteCollection command
|
|
85
|
+
const command = {
|
|
86
|
+
header: {
|
|
87
|
+
namespace: "Common",
|
|
88
|
+
name: "Action",
|
|
89
|
+
},
|
|
90
|
+
payload: {
|
|
91
|
+
cardParam: {},
|
|
92
|
+
executeParam: {
|
|
93
|
+
executeMode: "background",
|
|
94
|
+
intentName: "DeleteCollection",
|
|
95
|
+
bundleName: "com.huawei.hmos.vassistant",
|
|
96
|
+
needUnlock: true,
|
|
97
|
+
actionResponse: true,
|
|
98
|
+
appType: "OHOS_APP",
|
|
99
|
+
timeOut: 5,
|
|
100
|
+
intentParam: {
|
|
101
|
+
itemIds,
|
|
102
|
+
},
|
|
103
|
+
permissionId: [],
|
|
104
|
+
achieveType: "INTENT",
|
|
105
|
+
},
|
|
106
|
+
responses: [
|
|
107
|
+
{
|
|
108
|
+
resultCode: "",
|
|
109
|
+
displayText: "",
|
|
110
|
+
ttsText: "",
|
|
111
|
+
},
|
|
112
|
+
],
|
|
113
|
+
needUploadResult: true,
|
|
114
|
+
noHalfPage: false,
|
|
115
|
+
pageControlRelated: false,
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
// Send command and wait for response (60 second timeout)
|
|
119
|
+
return new Promise((resolve, reject) => {
|
|
120
|
+
const timeout = setTimeout(() => {
|
|
121
|
+
wsManager.off("data-event", handler);
|
|
122
|
+
reject(new Error("删除小艺收藏超时(60秒)"));
|
|
123
|
+
}, 60000);
|
|
124
|
+
// Listen for data events from WebSocket
|
|
125
|
+
const handler = (event) => {
|
|
126
|
+
if (event.intentName === "DeleteCollection") {
|
|
127
|
+
clearTimeout(timeout);
|
|
128
|
+
wsManager.off("data-event", handler);
|
|
129
|
+
if (event.status === "success" && event.outputs) {
|
|
130
|
+
resolve({
|
|
131
|
+
content: [
|
|
132
|
+
{
|
|
133
|
+
type: "text",
|
|
134
|
+
text: JSON.stringify(event.outputs),
|
|
135
|
+
}
|
|
136
|
+
]
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
reject(new Error(`删除小艺收藏失败: ${event.status}`));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
// Register event handler
|
|
145
|
+
wsManager.on("data-event", handler);
|
|
146
|
+
// Send the command
|
|
147
|
+
sendCommand({
|
|
148
|
+
config,
|
|
149
|
+
sessionId,
|
|
150
|
+
taskId,
|
|
151
|
+
messageId,
|
|
152
|
+
command,
|
|
153
|
+
})
|
|
154
|
+
.then(() => {
|
|
155
|
+
})
|
|
156
|
+
.catch((error) => {
|
|
157
|
+
clearTimeout(timeout);
|
|
158
|
+
wsManager.off("data-event", handler);
|
|
159
|
+
reject(error);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
},
|
|
163
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 保存 runtime 信息到 .xiaoyiruntime 文件
|
|
3
|
+
* @param webSocketSessionId - WebSocket 层级的 sessionId (SESSION_ID)
|
|
4
|
+
* @param conversationId - param 里的 sessionId (CONVERSATION_ID)
|
|
5
|
+
* @param taskId - 任务 ID (param.id)
|
|
6
|
+
*/
|
|
7
|
+
export declare function saveRuntimeInfo(webSocketSessionId: string, conversationId: string, taskId: string): Promise<void>;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// xiaoyi runtime 持久化管理器
|
|
2
|
+
import { promises as fs } from "fs";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import { logger } from "./logger.js";
|
|
5
|
+
const RUNTIME_FILE = "/home/sandbox/.openclaw/.xiaoyiruntime";
|
|
6
|
+
/**
|
|
7
|
+
* 确保目录存在
|
|
8
|
+
*/
|
|
9
|
+
async function ensureDirectoryExists(filePath) {
|
|
10
|
+
const dir = path.dirname(filePath);
|
|
11
|
+
try {
|
|
12
|
+
await fs.mkdir(dir, { recursive: true });
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
logger.error(`[RuntimeManager] Failed to create directory ${dir}:`, error);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* 保存 runtime 信息到 .xiaoyiruntime 文件
|
|
20
|
+
* @param webSocketSessionId - WebSocket 层级的 sessionId (SESSION_ID)
|
|
21
|
+
* @param conversationId - param 里的 sessionId (CONVERSATION_ID)
|
|
22
|
+
* @param taskId - 任务 ID (param.id)
|
|
23
|
+
*/
|
|
24
|
+
export async function saveRuntimeInfo(webSocketSessionId, conversationId, taskId) {
|
|
25
|
+
if (!webSocketSessionId || !conversationId || !taskId) {
|
|
26
|
+
logger.warn(`[RuntimeManager] Invalid params: SESSION_ID=${webSocketSessionId}, CONVERSATION_ID=${conversationId}, TASK_ID=${taskId}`);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
await ensureDirectoryExists(RUNTIME_FILE);
|
|
31
|
+
const content = `SESSION_ID=${webSocketSessionId}\nCONVERSATION_ID=${conversationId}\nTASK_ID=${taskId}\n`;
|
|
32
|
+
await fs.writeFile(RUNTIME_FILE, content, "utf-8");
|
|
33
|
+
logger.log(`[RuntimeManager] ✅ Saved runtime info to .xiaoyiruntime`);
|
|
34
|
+
logger.log(`[RuntimeManager] - SESSION_ID: ${webSocketSessionId}`);
|
|
35
|
+
logger.log(`[RuntimeManager] - CONVERSATION_ID: ${conversationId}`);
|
|
36
|
+
logger.log(`[RuntimeManager] - TASK_ID: ${taskId}`);
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
logger.error(`[RuntimeManager] Failed to save runtime info:`, error);
|
|
40
|
+
// 不抛出异常,避免影响主流程
|
|
41
|
+
}
|
|
42
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ynhcj/xiaoyi-channel",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.16",
|
|
4
4
|
"description": "OpenClaw Xiaoyi Channel plugin - Xiaoyi A2A protocol integration",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -59,13 +59,12 @@
|
|
|
59
59
|
"dependencies": {
|
|
60
60
|
"ws": "^8.14.2",
|
|
61
61
|
"uuid": "^9.0.0",
|
|
62
|
-
"node-fetch": "^
|
|
62
|
+
"node-fetch": "^3.3.2"
|
|
63
63
|
},
|
|
64
64
|
"devDependencies": {
|
|
65
65
|
"@types/ws": "^8.5.8",
|
|
66
66
|
"@types/uuid": "^9.0.5",
|
|
67
67
|
"@types/node": "^20.8.0",
|
|
68
|
-
"@types/node-fetch": "^2.6.2",
|
|
69
68
|
"typescript": "^5.9.2"
|
|
70
69
|
}
|
|
71
70
|
}
|