feat(agents): 支持个人微信发布与渠道会话
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled

This commit is contained in:
2026-09-13 16:22:36 +08:00
parent 03774d0625
commit 2e710db0fb
28 changed files with 2678 additions and 31 deletions

View File

@@ -37,6 +37,34 @@ const MESSAGES: Record<string, string> = {
agent_archived: '请先从归档恢复智能体,再启用',
attachment_too_large: '附件最大支持 5 MB',
download_failed: '文件保存失败,请重试',
channel_not_found: '微信渠道不存在或已解绑',
channel_operation_conflict: '微信渠道正在处理其他操作,请稍后查看状态',
channel_request_invalid: '微信渠道请求无效,请刷新后重试',
channel_rate_limited: '微信渠道操作过于频繁,请稍后重试',
channel_conversation_not_found: '微信会话不存在或已关闭',
channel_session_control_failed: '微信会话操作失败,请刷新后重试',
channel_delivery_not_found: '渠道交付记录不存在',
channel_delivery_retry_failed: '文件补发失败,请刷新交付记录后重试',
pairing_expired: '配对码已过期,请重新生成',
verification_required: '请先完成微信验证',
artifact_unavailable: '文件已不可用,请刷新文件列表',
operation_unavailable: '操作状态暂时无法确认,请稍后查询',
channel_scope_changed: '微信渠道版本已变化,请刷新后重试',
binding_revoked: '微信渠道已暂停、切换或撤销',
binding_paused: '微信渠道已暂停,请先启用后重试',
caller_not_found: '渠道调用者不存在',
delivery_not_found: '交付记录不存在',
delivery_part_not_found: '交付文件分片不存在',
artifact_snapshot_missing: '文件快照尚未准备好,请稍后重试',
channel_control_failed: '微信会话控制失败,请刷新后重试',
run_active: '当前会话仍在执行,请稍后重试',
run_not_active: '当前会话没有可操作的运行',
channel_session_unavailable: '新的微信会话暂时无法建立',
pairing_invalid: '配对请求无效,请刷新后重试',
invitation_invalid: '配对码无效或已撤销',
invitation_consumed: '配对码已经使用',
invites_disabled: '当前渠道仅允许创建者本人配对',
agentbus_unavailable: '渠道服务暂时不可用,请稍后重试',
};
export class CloudAgentsError extends Error {
@@ -177,7 +205,11 @@ export class CloudAgentsModule {
private async withRecovery<T>(operation: string, input: Record<string, unknown>, execute: () => Promise<T>): Promise<T> {
const binding = getWorksSquareAccountBinding();
if (!binding) throw new CloudAgentsError(401, 'session_expired');
const id = [operation, input.slug ?? input.application_id ?? input.run_id ?? '', input.request_id ?? input.operation_id].join(':');
const operationKey = operation === 'retryChannelDeliveryPart'
? [input.channel_account_id, input.logical_message_id, input.part_id]
.map(value => encodeURIComponent(typeof value === 'string' ? value : '')).join(':')
: input.request_id ?? input.operation_id;
const id = [operation, input.slug ?? input.application_id ?? input.run_id ?? '', operationKey].join(':');
await this.journal.put(binding.accountKey, { id, operation, input, created_at: new Date().toISOString() }, () => this.requireCurrent(binding));
this.requireCurrent(binding);
const result = await execute();
@@ -220,6 +252,28 @@ export class CloudAgentsModule {
await this.journal.remove(binding.accountKey, id, () => this.requireCurrent(binding));
return { discarded: true };
}
if (pending.operation === 'wechatBindVerification') {
// The verification code is intentionally never persisted. First recover the
// durable provider receipt: the code may already have been consumed even if
// the response was lost. Only an absent receipt requires new input.
const pendingInput = record(pending.input);
const pendingSlug = typeof pendingInput.slug === 'string' ? pendingInput.slug : undefined;
const operationId = typeof pendingInput.operation_id === 'string' ? pendingInput.operation_id : undefined;
if (pendingSlug && operationId) {
try {
const receipt = await this.execute({ operation: 'channelOperation', input: { slug: pendingSlug, operation_id: operationId } });
const status = typeof record(receipt).status === 'string' ? String(record(receipt).status).toLowerCase() : '';
const terminal = ['completed', 'complete', 'succeeded', 'success', 'failed', 'rejected', 'cancelled', 'canceled', 'expired', 'error'].includes(status);
if (terminal) await this.journal.remove(binding.accountKey, id, () => this.requireCurrent(binding));
return { operation: pending.operation, input: pending.input, result: receipt, ...(terminal ? {} : { pending: true }) };
} catch (error) {
if (!(error instanceof CloudAgentsError) || error.status !== 404) throw error;
}
}
// No receipt exists (or this is an old record without enough metadata), so
// the Renderer must collect a fresh verification code.
return { operation: pending.operation, input: pending.input, requires_input: true };
}
const result = pending.operation === 'createAgent' ? await this.create(pending.input)
: await this.execute({ operation: pending.operation, input: pending.input });
return { operation: pending.operation, input: pending.input, result };
@@ -276,8 +330,17 @@ export class CloudAgentsModule {
try { plan = operationPlan(value); }
catch { throw new CloudAgentsError(422, 'invalid_input'); }
const invoke = async () => plan.project(await this.request(plan.path, plan.method, plan.body, plan.cloud === 'ws'));
const recoverable = ['submit', 'preview', 'resume', 'publish', 'createApplication', 'createKey', 'createKnowledge', 'processKnowledge', 'createSchedule', 'runSchedule', 'importKnowledgeAttachment', 'createChild'];
const result = await (recoverable.includes(plan.operation) ? this.withRecovery(plan.operation, plan.input, invoke) : invoke());
const recoverable = ['submit', 'preview', 'resume', 'publish', 'createApplication', 'createKey', 'createKnowledge', 'processKnowledge', 'createSchedule', 'runSchedule', 'importKnowledgeAttachment', 'createChild',
'createChannelBinding', 'switchChannelBindingAgent', 'enableChannelBinding', 'pauseChannelBinding', 'disconnectChannelBinding',
'channelPolicy', 'createChannelPairing', 'wechatBindStart', 'wechatBindVerification', 'wechatUnbind', 'revokeChannelCaller',
'channelConversationControl', 'retryChannelDeliveryPart'];
let result: unknown;
if (recoverable.includes(plan.operation)) {
const journalInput = plan.operation === 'wechatBindVerification'
? Object.fromEntries(Object.entries(plan.input).filter(([key]) => key !== 'verify_code'))
: plan.input;
result = await this.withRecovery(plan.operation, journalInput, invoke);
} else result = await invoke();
try {
const projected = plan.project(result);
if (record(value).operation === 'access') {
@@ -395,6 +458,59 @@ export class CloudAgentsModule {
} finally { await fs.unlink(temporary).catch(() => undefined); }
}
/** Save a personal-channel artifact through the session-scoped bridge. */
async downloadChannelArtifact(value: unknown): Promise<{ saved: boolean }> {
const input = record(value);
const sessionId = textField(input.session_id, 256);
const path = textField(input.path, 2048);
if (!/^[a-zA-Z0-9._:#-]{1,256}$/.test(sessionId)
|| path.includes('\\') || path.includes('\0') || /:\/\//.test(path)
|| path.replace(/^\/+/, '').split('/').some(part => !part || part === '..' || part === '.')) {
throw new CloudAgentsError(422, 'invalid_input');
}
const binding = getWorksSquareAccountBinding();
if (!binding) throw new CloudAgentsError(401, 'session_expired');
const { dialog } = await import('electron');
const fs = await import('node:fs/promises');
const { basename, dirname, join } = await import('node:path');
const picked = await dialog.showSaveDialog({ title: '保存微信渠道文件', defaultPath: basename(path),
properties: ['createDirectory', 'showOverwriteConfirmation'] });
this.requireCurrent(binding);
if (picked.canceled || !picked.filePath) return { saved: false };
const temporary = join(dirname(picked.filePath), '.makelore-channel-download-' + crypto.randomUUID());
try {
await runWithDeadline(async signal => {
const session = await this.session(binding, signal);
this.requireCurrent(binding);
const encodedPath = path.replace(/^\/+/, '').split('/').map(encodeURIComponent).join('/');
const url = session.apiBaseUrl + '/api/makelore/channel-conversations/' + encodeURIComponent(sessionId)
+ '/artifacts/' + encodedPath + '?download=true';
const response = await this.fetchImpl(url, { redirect: 'error', signal, headers: { Authorization: 'Bearer ' + session.accessToken } });
if (!response.ok || !response.body) throw new CloudAgentsError(response.status || 502, 'download_failed');
const output = await fs.open(temporary, 'wx');
const reader = response.body.getReader();
let size = 0;
try {
while (true) {
const { value: chunk, done } = await reader.read();
this.requireCurrent(binding);
if (done) break;
size += chunk.byteLength;
if (size > 1024 * 1024 * 1024) throw new CloudAgentsError(413, 'download_failed');
let offset = 0;
while (offset < chunk.byteLength) offset += (await output.write(chunk, offset, chunk.byteLength - offset)).bytesWritten;
}
} finally { await reader.cancel().catch(() => undefined); await output.close(); }
}, 300000);
this.requireCurrent(binding);
await fs.rename(temporary, picked.filePath);
return { saved: true };
} catch(error) {
if (error instanceof CloudAgentsError) throw error;
throw new CloudAgentsError(502, 'download_failed');
} finally { await fs.unlink(temporary).catch(() => undefined); }
}
async *events(runId: string, after: string, signal: AbortSignal): AsyncGenerator<Uint8Array> {
if (!/^[a-zA-Z0-9_-]{1,64}$/.test(runId) || !/^\d+-\d+$/.test(after)) {
throw new CloudAgentsError(422, 'invalid_input');