@ynhcj/xiaoyi-channel 0.0.169-beta → 0.0.171-beta

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.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { getXYRuntime } from "./runtime.js";
2
2
  import { createXYReplyDispatcher } from "./reply-dispatcher.js";
3
- import { parseA2AMessage, extractTextFromParts, extractFileParts, extractPushId, extractDeviceType, extractTriggerData, extractRunCrossTaskContext } from "./parser.js";
3
+ import { parseA2AMessage, extractTextFromParts, extractFileParts, extractPushId, extractDeviceType, extractModelName, extractTriggerData, extractRunCrossTaskContext } from "./parser.js";
4
4
  import { downloadFilesFromParts } from "./file-download.js";
5
5
  import { resolveXYConfig } from "./config.js";
6
6
  import { sendStatusUpdate, sendClearContextResponse, sendTasksCancelResponse, sendA2AResponse } from "./formatter.js";
@@ -32,6 +32,7 @@ export async function handleXYMessage(params) {
32
32
  try {
33
33
  // Check for special messages BEFORE parsing (these have different param structures)
34
34
  const messageMethod = message.method;
35
+ logger.log(`[BOT] Received A2A message: ${JSON.stringify(message)}`);
35
36
  // Handle clearContext messages (sessionId at top level, no params)
36
37
  if (messageMethod === "clearContext" || messageMethod === "clear_context") {
37
38
  const sessionId = message.sessionId ?? message.params?.sessionId;
@@ -138,6 +139,11 @@ export async function handleXYMessage(params) {
138
139
  if (deviceType) {
139
140
  log.log(`[BOT] Extracted deviceType: ${deviceType}`);
140
141
  }
142
+ // Extract modelName if present (used by provider.ts to override model.id)
143
+ const modelName = extractModelName(parsed.parts);
144
+ if (modelName) {
145
+ log.log(`[BOT] Extracted modelName: ${modelName}`);
146
+ }
141
147
  const runCrossTaskContext = extractRunCrossTaskContext(parsed.parts);
142
148
  // Resolve configuration (needed for status updates)
143
149
  const config = resolveXYConfig(cfg);
@@ -164,6 +170,7 @@ export async function handleXYMessage(params) {
164
170
  messageId: parsed.messageId,
165
171
  agentId: route.accountId,
166
172
  deviceType,
173
+ modelName,
167
174
  runCrossTaskContext: runCrossTaskContext ?? undefined,
168
175
  });
169
176
  // 🔑 发送初始状态更新
@@ -311,6 +318,7 @@ export async function handleXYMessage(params) {
311
318
  messageId: parsed.messageId,
312
319
  agentId: route.accountId,
313
320
  deviceType,
321
+ modelName,
314
322
  runCrossTaskContext: runCrossTaskContext ?? undefined,
315
323
  };
316
324
  log.log(`[BOT-DISPATCH] withReplyDispatcher starting, sessionKey=${route.sessionKey}`);
@@ -17,6 +17,11 @@ export declare class XYFileUploadService {
17
17
  * Uses completeAndQuery endpoint to get the file URL directly.
18
18
  */
19
19
  uploadFileAndGetUrl(filePath: string, objectType?: string): Promise<string>;
20
+ /**
21
+ * Upload a file and return a preview-able URL (needPreview=true).
22
+ * Same as uploadFileAndGetUrl but adds needPreview flag to get a directly viewable URL.
23
+ */
24
+ uploadFileAndGetPreviewUrl(filePath: string, objectType?: string): Promise<string>;
20
25
  /**
21
26
  * Upload multiple files and return their file IDs.
22
27
  */
@@ -235,6 +235,107 @@ export class XYFileUploadService {
235
235
  }
236
236
  }
237
237
  }
238
+ /**
239
+ * Upload a file and return a preview-able URL (needPreview=true).
240
+ * Same as uploadFileAndGetUrl but adds needPreview flag to get a directly viewable URL.
241
+ */
242
+ async uploadFileAndGetPreviewUrl(filePath, objectType = "TEMPORARY_MATERIAL_DOC") {
243
+ let localFilePath = filePath;
244
+ let isTempFile = false;
245
+ try {
246
+ // Handle remote URLs by downloading first
247
+ if (isRemoteUrl(filePath)) {
248
+ localFilePath = await downloadToTempFile(filePath);
249
+ isTempFile = true;
250
+ }
251
+ // Read file
252
+ const fileBuffer = await fs.readFile(localFilePath);
253
+ const fileName = path.basename(localFilePath);
254
+ const fileSha256 = calculateSHA256(fileBuffer);
255
+ const fileSize = fileBuffer.length;
256
+ // Phase 1: Prepare
257
+ logger.log(`[XY File Upload] Phase 1 (preview): Prepare upload for ${fileName}`);
258
+ const prepareResp = await fetch(`${this.baseUrl}/osms/v1/file/manager/prepare`, {
259
+ method: "POST",
260
+ headers: {
261
+ "Content-Type": "application/json",
262
+ "x-uid": this.uid,
263
+ "x-api-key": this.apiKey,
264
+ "x-request-from": "openclaw",
265
+ },
266
+ body: JSON.stringify({
267
+ objectType,
268
+ fileName,
269
+ fileSha256,
270
+ fileSize,
271
+ fileOwnerInfo: {
272
+ uid: this.uid,
273
+ teamId: this.uid,
274
+ },
275
+ useEdge: false,
276
+ }),
277
+ });
278
+ if (!prepareResp.ok) {
279
+ throw new Error(`Prepare failed: HTTP ${prepareResp.status}`);
280
+ }
281
+ const prepareData = await prepareResp.json();
282
+ if (prepareData.code !== "0") {
283
+ throw new Error(`Prepare failed: ${prepareData.desc}`);
284
+ }
285
+ const { objectId, draftId, uploadInfos } = prepareData;
286
+ logger.log(`[XY File Upload] Prepare (preview) complete: objectId=${objectId}, draftId=${draftId}`);
287
+ // Phase 2: Upload
288
+ logger.log(`[XY File Upload] Phase 2 (preview): Upload file data`);
289
+ const uploadInfo = uploadInfos[0];
290
+ const uploadResp = await fetch(uploadInfo.url, {
291
+ method: uploadInfo.method,
292
+ headers: uploadInfo.headers,
293
+ body: fileBuffer,
294
+ });
295
+ if (!uploadResp.ok) {
296
+ throw new Error(`Upload failed: HTTP ${uploadResp.status}`);
297
+ }
298
+ logger.log(`[XY File Upload] Upload (preview) complete`);
299
+ // Phase 3: CompleteAndQuery with needPreview=true
300
+ logger.log(`[XY File Upload] Phase 3 (preview): CompleteAndQuery with needPreview=true`);
301
+ const completeResp = await fetch(`${this.baseUrl}/osms/v1/file/manager/completeAndQuery`, {
302
+ method: "POST",
303
+ headers: {
304
+ "Content-Type": "application/json",
305
+ "x-uid": this.uid,
306
+ "x-api-key": this.apiKey,
307
+ "x-request-from": "openclaw",
308
+ },
309
+ body: JSON.stringify({
310
+ objectId,
311
+ draftId,
312
+ needPreview: true,
313
+ }),
314
+ });
315
+ if (!completeResp.ok) {
316
+ throw new Error(`CompleteAndQuery (preview) failed: HTTP ${completeResp.status}`);
317
+ }
318
+ const completeData = await completeResp.json();
319
+ const fileUrl = completeData?.fileDetailInfo?.url || "";
320
+ if (!fileUrl) {
321
+ throw new Error("No file URL returned from completeAndQuery (preview)");
322
+ }
323
+ logger.log(`[XY File Upload] File upload with preview URL successful`);
324
+ return fileUrl;
325
+ }
326
+ catch (error) {
327
+ logger.error(`[XY File Upload] File upload with preview URL failed for ${filePath}:`, error);
328
+ throw error;
329
+ }
330
+ finally {
331
+ if (isTempFile) {
332
+ try {
333
+ await fs.unlink(localFilePath);
334
+ }
335
+ catch { }
336
+ }
337
+ }
338
+ }
238
339
  /**
239
340
  * Upload multiple files and return their file IDs.
240
341
  */
@@ -82,6 +82,35 @@ export interface SendCommandParams {
82
82
  * listening in the calling tool works unchanged.
83
83
  */
84
84
  export declare function sendCommand(params: SendCommandParams): Promise<void>;
85
+ /**
86
+ * Parameters for sending a card (e.g., HTML H5 card).
87
+ */
88
+ export interface SendCardParams {
89
+ config: XYChannelConfig;
90
+ sessionId: string;
91
+ taskId: string;
92
+ messageId: string;
93
+ /** toolCallId from the tool's execute() — used for cron detection via hook-set Map. */
94
+ toolCallId?: string;
95
+ /** When true, the artifact-update is sent with final=true. Default: false. */
96
+ final?: boolean;
97
+ /** Array of card data objects to send. */
98
+ cardsInfo: CardDataObject[];
99
+ }
100
+ /**
101
+ * Card data object for sending display cards.
102
+ */
103
+ export interface CardDataObject {
104
+ cardName: string;
105
+ cardData: Record<string, any>;
106
+ displayType: string;
107
+ }
108
+ /**
109
+ * Send a card (e.g., HTML H5 card) as an artifact update (final=false).
110
+ *
111
+ * Cron-aware: same routing logic as sendCommand.
112
+ */
113
+ export declare function sendCard(params: SendCardParams): Promise<void>;
85
114
  /**
86
115
  * Parameters for sending a clearContext response.
87
116
  */
@@ -272,6 +272,59 @@ export async function sendCommand(params) {
272
272
  await wsManager.sendMessage(sessionId, outboundMessage);
273
273
  log.log(`[A2A_COMMAND] Command sent successfully`);
274
274
  }
275
+ /**
276
+ * Send a card (e.g., HTML H5 card) as an artifact update (final=false).
277
+ *
278
+ * Cron-aware: same routing logic as sendCommand.
279
+ */
280
+ export async function sendCard(params) {
281
+ const { config, sessionId, taskId, messageId, toolCallId } = params;
282
+ // ── Cron mode: route through push channel ──────────────────────
283
+ if (sessionId.startsWith("cron-") || isCronToolCall(toolCallId)) {
284
+ throw new Error("sendCard does not support cron mode");
285
+ }
286
+ // ── Normal mode: WebSocket ─────────────────────────────────────
287
+ const currentTaskId = getCurrentTaskId(sessionId) ?? taskId;
288
+ const currentMessageId = getCurrentMessageId(sessionId) ?? messageId;
289
+ const log = logger.withContext(sessionId, currentTaskId);
290
+ // Build artifact update with cardsInfo as data
291
+ const artifact = {
292
+ taskId: currentTaskId,
293
+ kind: "artifact-update",
294
+ append: false,
295
+ lastChunk: true,
296
+ final: params.final ?? false,
297
+ artifact: {
298
+ artifactId: uuidv4(),
299
+ parts: [
300
+ {
301
+ kind: "data",
302
+ data: {
303
+ cardsInfo: params.cardsInfo,
304
+ },
305
+ },
306
+ ],
307
+ },
308
+ };
309
+ // Build JSON-RPC response
310
+ const jsonRpcResponse = {
311
+ jsonrpc: "2.0",
312
+ id: currentMessageId,
313
+ result: artifact,
314
+ };
315
+ // Send via WebSocket
316
+ const wsManager = getXYWebSocketManager(config);
317
+ const outboundMessage = {
318
+ msgType: "agent_response",
319
+ agentId: config.agentId,
320
+ sessionId,
321
+ taskId: currentTaskId,
322
+ msgDetail: JSON.stringify(jsonRpcResponse),
323
+ };
324
+ log.log(`[A2A_CARD] Sending card`);
325
+ await wsManager.sendMessage(sessionId, outboundMessage);
326
+ log.log(`[A2A_CARD] Card sent successfully`);
327
+ }
275
328
  /**
276
329
  * Send a clearContext response.
277
330
  */
@@ -50,6 +50,12 @@ export declare function extractPushId(parts: A2AMessagePart[]): string | null;
50
50
  * (same level as push_id).
51
51
  */
52
52
  export declare function extractDeviceType(parts: A2AMessagePart[]): string | null;
53
+ /**
54
+ * Extract modelName from message parts.
55
+ * Looks for modelName in data parts under variables.clientVariables.modelName
56
+ * (same level as systemVariables).
57
+ */
58
+ export declare function extractModelName(parts: A2AMessagePart[]): string | null;
53
59
  /**
54
60
  * Extract Trigger event data from message parts.
55
61
  * Looks for Trigger events with pushDataId in data parts.
@@ -143,6 +143,22 @@ export function extractDeviceType(parts) {
143
143
  }
144
144
  return null;
145
145
  }
146
+ /**
147
+ * Extract modelName from message parts.
148
+ * Looks for modelName in data parts under variables.clientVariables.modelName
149
+ * (same level as systemVariables).
150
+ */
151
+ export function extractModelName(parts) {
152
+ for (const part of parts) {
153
+ if (part.kind === "data" && part.data) {
154
+ const modelName = part.data.variables?.clientVariables?.modelName;
155
+ if (modelName && typeof modelName === "string" && modelName.trim() !== "" && modelName.toLowerCase() !== "none") {
156
+ return modelName;
157
+ }
158
+ }
159
+ }
160
+ return null;
161
+ }
146
162
  /**
147
163
  * Extract Trigger event data from message parts.
148
164
  * Looks for Trigger events with pushDataId in data parts.
@@ -587,6 +587,12 @@ export const xiaoyiProvider = {
587
587
  }
588
588
  }
589
589
  }
590
+ // ── Override model.id if A2A message specified modelName ──
591
+ const modelNameOverride = getCurrentSessionContext()?.modelName;
592
+ if (modelNameOverride && modelNameOverride.trim() !== "" && modelNameOverride.toLowerCase() !== "none") {
593
+ logger.log(`[xiaoyiprovider] overriding model.id: ${model.id} → ${modelNameOverride}`);
594
+ model = { ...model, id: modelNameOverride };
595
+ }
590
596
  // ── Retry-capable streaming ──────────────────────────────
591
597
  const cronJob = isCronTriggered(context.messages);
592
598
  if (cronJob)
@@ -116,6 +116,8 @@ export function createXYReplyDispatcher(params) {
116
116
  let hasSentResponse = false;
117
117
  let finalSent = false;
118
118
  let accumulatedText = "";
119
+ let accumulatedReasoningHistory = "";
120
+ let lastReasoningText = "";
119
121
  const initialRunCrossTaskContext = getCurrentSessionContext()?.runCrossTaskContext;
120
122
  const getRunCrossTaskContext = () => {
121
123
  return getCurrentSessionContext()?.runCrossTaskContext ?? initialRunCrossTaskContext;
@@ -404,13 +406,26 @@ export function createXYReplyDispatcher(params) {
404
406
  }
405
407
  try {
406
408
  if (text.length > 0) {
409
+ // 🔑 检测是否是新一轮思考:当前text比上一次短,或不以上次内容开头
410
+ const isNewRound = lastReasoningText.length > 0 &&
411
+ (text.length < lastReasoningText.length || !text.startsWith(lastReasoningText));
412
+ if (isNewRound) {
413
+ // 将上一轮思考追加到历史
414
+ accumulatedReasoningHistory += (accumulatedReasoningHistory ? "\n\n" : "") + lastReasoningText;
415
+ }
416
+ // 更新当前轮最后一次text
417
+ lastReasoningText = text;
418
+ // 🔑 拼接历史 + 当前轮内容
419
+ const fullText = accumulatedReasoningHistory
420
+ ? accumulatedReasoningHistory + "\n\n" + text
421
+ : text;
407
422
  // 🔑 将模型真实的thinking/reasoning内容通过reasoningText转发
408
423
  await sendReasoningTextUpdate({
409
424
  config,
410
425
  sessionId,
411
426
  taskId: currentTaskId,
412
427
  messageId: currentMessageId,
413
- text,
428
+ text: fullText,
414
429
  append: false,
415
430
  });
416
431
  }
@@ -107,7 +107,7 @@ export function createCheckPluginPrivilegeTool(ctx) {
107
107
  executeMode: "background",
108
108
  intentName: "CheckPlugInPrivilege",
109
109
  intentParam: {
110
- checkIntentName: "CheckPluginPrivilege",
110
+ checkIntentName,
111
111
  permissionId,
112
112
  },
113
113
  needUnlock: false,
@@ -1,6 +1,7 @@
1
1
  import { createLocationTool } from "./location-tool.js";
2
2
  import { createXiaoyiGuiTool } from "./xiaoyi-gui-tool.js";
3
3
  import { createSendFileToUserTool } from "./send-file-to-user-tool.js";
4
+ import { createSendHtmlCardTool } from "./send-html-card-tool.js";
4
5
  import { viewPushResultTool } from "./view-push-result-tool.js";
5
6
  import { createImageReadingTool } from "./image-reading-tool.js";
6
7
  import { timestampToUtc8Tool } from "./timestamp-to-utc8-tool.js";
@@ -48,6 +49,7 @@ export function createAllTools(ctx) {
48
49
  createGetAlarmToolSchemaTool(ctx),
49
50
  createGetCollectionToolSchemaTool(ctx),
50
51
  createSendFileToUserTool(ctx),
52
+ createSendHtmlCardTool(ctx),
51
53
  // createGetEmailToolSchemaTool(ctx),
52
54
  viewPushResultTool,
53
55
  createImageReadingTool(ctx),
@@ -0,0 +1,7 @@
1
+ import type { SessionContext } from "./session-manager.js";
2
+ /**
3
+ * XY send HTML card tool - sends HTML content as an H5 card to user's device.
4
+ * Prefer this tool over send_file_to_user when sending HTML files to users.
5
+ * Only use send_file_to_user for HTML files when the user explicitly requests the raw file.
6
+ */
7
+ export declare function createSendHtmlCardTool(ctx: SessionContext): any;
@@ -0,0 +1,113 @@
1
+ import { XYFileUploadService } from "../file-upload.js";
2
+ import { sendCard } from "../formatter.js";
3
+ import { getCurrentTaskId } from "../task-manager.js";
4
+ import { logger } from "../utils/logger.js";
5
+ /**
6
+ * XY send HTML card tool - sends HTML content as an H5 card to user's device.
7
+ * Prefer this tool over send_file_to_user when sending HTML files to users.
8
+ * Only use send_file_to_user for HTML files when the user explicitly requests the raw file.
9
+ */
10
+ export function createSendHtmlCardTool(ctx) {
11
+ const { config, sessionId, taskId, messageId } = ctx;
12
+ return {
13
+ name: "send_html_card",
14
+ label: "Send HTML Card",
15
+ description: `工具能力描述:当需要把生成的html文件发送给用户时,优先使用这个工具。以H5卡片的形式展示HTML页面内容,用户可以直接在卡片中查看。如果用户要求原始文件,才使用send_file_to_user发送html文件,否则html的文件回传均使用此工具。
16
+
17
+ 工具参数说明:
18
+ a. htmlUrl 和 htmlLocal 至少填写一个
19
+ b. htmlUrl 是在线链接,可以直接公网访问的HTML页面地址
20
+ c. htmlLocal 是本地HTML文件路径,会先上传获取预览链接再以卡片形式发送
21
+
22
+ 注意事项:
23
+ a. 操作超时时间为2分钟(120秒),请勿重复调用此工具,如果超时或失败,最多重试一次
24
+ b. 最后要把最终的html的公网地址作为工具执行结果返回回去,要以markdown超链接的形式返回给用户`,
25
+ parameters: {
26
+ type: "object",
27
+ properties: {
28
+ htmlUrl: {
29
+ type: "string",
30
+ description: "在线HTML页面链接,可直接公网访问的URL地址",
31
+ },
32
+ htmlLocal: {
33
+ type: "string",
34
+ description: "本地HTML文件路径",
35
+ },
36
+ },
37
+ required: [],
38
+ },
39
+ async execute(toolCallId, params) {
40
+ // Validate at least one parameter is provided
41
+ if (!params.htmlUrl && !params.htmlLocal) {
42
+ throw new Error("htmlUrl 和 htmlLocal 至少需要填写一个");
43
+ }
44
+ const currentTaskId = getCurrentTaskId(sessionId) ?? taskId;
45
+ // Set timeout for the entire operation (2 minutes)
46
+ const TOOL_TIMEOUT = 120000;
47
+ let timeoutHandle = null;
48
+ const timeoutPromise = new Promise((_, reject) => {
49
+ timeoutHandle = setTimeout(() => {
50
+ reject(new Error("操作超时(2分钟)"));
51
+ }, TOOL_TIMEOUT);
52
+ });
53
+ const executionPromise = (async () => {
54
+ let url = params.htmlUrl;
55
+ // If htmlLocal is provided, upload it to get a preview URL
56
+ if (params.htmlLocal) {
57
+ const uploadService = new XYFileUploadService(config.fileUploadUrl, config.apiKey, config.uid);
58
+ logger.log(`[SEND-HTML-CARD] Uploading local HTML file: ${params.htmlLocal}`);
59
+ const previewUrl = await uploadService.uploadFileAndGetPreviewUrl(params.htmlLocal);
60
+ logger.log(`[SEND-HTML-CARD] Upload complete, preview URL obtained`);
61
+ url = previewUrl;
62
+ }
63
+ if (!url) {
64
+ throw new Error("未能获取HTML页面的URL");
65
+ }
66
+ // Build card data
67
+ const cardsInfo = [
68
+ {
69
+ cardName: "clawH5",
70
+ cardData: {
71
+ url,
72
+ },
73
+ displayType: "DisplayFaCard",
74
+ },
75
+ ];
76
+ // Send card via sendCard
77
+ await sendCard({
78
+ config,
79
+ sessionId,
80
+ taskId: currentTaskId,
81
+ messageId,
82
+ toolCallId,
83
+ cardsInfo,
84
+ });
85
+ return {
86
+ content: [
87
+ {
88
+ type: "text",
89
+ text: JSON.stringify({
90
+ success: true,
91
+ url,
92
+ message: "HTML卡片发送成功",
93
+ }),
94
+ },
95
+ ],
96
+ };
97
+ })();
98
+ try {
99
+ const result = await Promise.race([executionPromise, timeoutPromise]);
100
+ if (timeoutHandle) {
101
+ clearTimeout(timeoutHandle);
102
+ }
103
+ return result;
104
+ }
105
+ catch (error) {
106
+ if (timeoutHandle) {
107
+ clearTimeout(timeoutHandle);
108
+ }
109
+ throw error;
110
+ }
111
+ },
112
+ };
113
+ }
@@ -8,6 +8,9 @@ export interface SessionContext {
8
8
  messageId: string;
9
9
  agentId: string;
10
10
  deviceType?: string;
11
+ /** Model name extracted from A2A user variables (variables.clientVariables.modelName).
12
+ * When set, provider.ts replaces model.id in the OpenAI request body. */
13
+ modelName?: string;
11
14
  runCrossTaskContext?: RunCrossTaskContext;
12
15
  /** When true, this context was created for a cron/scheduled task execution.
13
16
  * Tools should use the push channel instead of WebSocket sendCommand. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ynhcj/xiaoyi-channel",
3
- "version": "0.0.169-beta",
3
+ "version": "0.0.171-beta",
4
4
  "description": "OpenClaw Xiaoyi Channel plugin - Xiaoyi A2A protocol integration",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",