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

@@ -2,11 +2,11 @@ import type { CloudAgentOperations } from '../../shared/cloud-agents';
type OperationSpec = {
method: string; path: string; body?: readonly string[]; output: readonly string[];
query?: Record<string, string>; cloud?: 'ws';
query?: Record<string, string>; fixedQuery?: Record<string, string>; cloud?: 'ws'; project?: (value: unknown) => unknown;
};
const prompt = ['request_id', 'thread_id', 'query', 'expected_revision', 'attachment_file_ids'];
const request = ['request_id', 'thread_id', 'run_id', 'status', 'version'];
const schedule = ['name', 'prompt', 'cron_expression', 'timezone', 'enabled'];
const schedule = ['name', 'prompt', 'cron_expression', 'timezone', 'enabled', 'result_notification'];
const job = ['id', 'agent_slug', ...schedule, 'next_run_at', 'runs'];
const entry = ['slug', 'name', 'purpose', 'published_version', 'is_creator', 'payer'];
const budget = ['agent_slug', 'unit', 'timezone', 'request_limit_points', 'daily_limit_points', 'daily_committed_points', 'resets_at'];
@@ -14,6 +14,204 @@ const draft = ['slug', 'name', 'purpose', 'system_prompt', 'draft_revision', 'up
const mcpInput = ['name', 'description', 'transport', 'url', 'headers'];
const mcpOutput = ['slug', 'name', 'description', 'transport', 'url', 'enabled', 'has_credentials'];
const child = ['name', 'purpose', 'system_prompt'];
const channelView = ['id', 'address', 'display_name', 'target_agent_address', 'route_revision', 'provider_generation', 'binding_id', 'enabled', 'status', 'worker_online',
'agent_slug', 'published_version', 'desired_state', 'sync_state', 'health', 'last_confirmed_at', 'revision', 'blockers', 'access_mode', 'policy_revision',
'adoption_state', 'managed_agent_slug'];
const channelSession = ['session_id', 'caller_id', 'agent_slug', 'agent_name', 'binding_id', 'provider_generation', 'channel_account_id', 'core_conversation_id', 'access_mode', 'grant_state', 'session_state', 'sequence', 'content_uid', 'thread_id', 'created_at', 'closed_at'];
const channelCaller = ['caller_id', 'binding_id', 'provider_generation', 'channel_account_id', 'agent_slug', 'core_conversation_id', 'access_mode', 'grant_state', 'paired_ws_account_id', 'session_id', 'content_uid', 'thread_id', 'session_state', 'created_at', 'revoked_at'];
const channelOperation = ['operation_id', 'status', 'steps', 'result', 'error_code'];
type ObjectRecord = Record<string, unknown>;
function object(value: unknown): ObjectRecord {
return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as ObjectRecord : {};
}
function copy(value: unknown, keys: readonly string[]): ObjectRecord {
const source = object(value);
return Object.fromEntries(keys.filter(key => source[key] !== undefined).map(key => [key, source[key]]));
}
function list(value: unknown, project: (item: unknown) => unknown, max = 200): unknown[] {
return Array.isArray(value) ? value.slice(0, max).map(project) : [];
}
function safeHttps(value: unknown): string | undefined {
if (typeof value !== 'string' || value.length > 2048) return undefined;
try {
const url = new URL(value);
if (url.protocol !== 'https:' || url.username || url.password || url.hash) return undefined;
return url.toString();
} catch { return undefined; }
}
function projectChannelView(value: unknown): ObjectRecord {
const result = copy(value, channelView);
if (Array.isArray(result.blockers)) result.blockers = result.blockers.filter((item): item is string => typeof item === 'string').slice(0, 20);
return result;
}
function projectBindings(value: unknown): unknown {
const source = object(value);
return {
items: list(source.items, projectChannelView),
...(Array.isArray(source.available_accounts) ? { available_accounts: list(source.available_accounts, projectChannelView) } : {}),
};
}
function projectMutation(value: unknown): unknown {
const source = object(value);
return {
...(source.channel === undefined ? {} : { channel: projectChannelView(source.channel) }),
...(source.target_agent === undefined ? {} : { target_agent: typeof source.target_agent === 'string' ? source.target_agent : projectChannelView(source.target_agent) }),
...(source.worker && typeof source.worker === 'object' ? { worker: copy(source.worker, ['account_id', 'address']) } : {}),
...(source.wechat && typeof source.wechat === 'object' ? { wechat: copy(source.wechat, ['status', 'message']) } : {}),
...(source.disconnected === undefined ? {} : { disconnected: source.disconnected === true }),
};
}
function projectPolicy(value: unknown): unknown {
const source = object(value);
return {
...copy(source, ['binding_id', 'channel_account_id', 'access_mode', 'policy_revision']),
revoked_caller_ids: Array.isArray(source.revoked_caller_ids) ? source.revoked_caller_ids.filter((item): item is string => typeof item === 'string').slice(0, 200) : [],
revoked_invitation_ids: Array.isArray(source.revoked_invitation_ids) ? source.revoked_invitation_ids.filter((item): item is string => typeof item === 'string').slice(0, 200) : [],
};
}
function projectPairing(value: unknown): unknown { return copy(value, ['invitation_id', 'binding_id', 'provider_generation', 'agent_slug', 'kind', 'expires_at', 'consumed', 'code']); }
function projectWechat(value: unknown): unknown {
const source = object(value);
const qrcode_url = safeHttps(source.qrcode_url);
return { ...copy(source, ['session_key', 'status', 'message']), ...(qrcode_url ? { qrcode_url } : {}) };
}
function projectSession(value: unknown): unknown { return copy(value, channelSession); }
function projectRun(value: unknown): unknown {
const source = object(value);
return { ...copy(source, ['agent_run_id', 'request_id', 'thread_id', 'agent_slug', 'status', 'output', 'version']),
...(source.error && typeof source.error === 'object' ? { error: copy(source.error, ['type', 'message']) } : {}),
...(source.interrupt && typeof source.interrupt === 'object' ? { interrupt: copy(source.interrupt, ['status', 'message', 'run_id', 'questions', 'approval']) } : {}),
};
}
function projectConversation(value: unknown): unknown {
const source = object(value);
return { ...projectSession(source), messages: list(source.messages, item => copy(item, ['id', 'role', 'content', 'request_id', 'run_id', 'created_at'])),
run: source.run == null ? null : projectRun(source.run),
queued_requests: list(source.queued_requests, item => copy(item, ['request_id', 'thread_id', 'run_id', 'status', 'version'])),
next_offset: source.next_offset ?? null };
}
function projectFiles(value: unknown): unknown {
const source = object(value);
return { session_id: source.session_id, thread_id: source.thread_id ?? null,
files: list(source.files, item => copy(item, ['name', 'path', 'directory_path', 'is_dir', 'size'])) };
}
function projectCallers(value: unknown): unknown { return { items: list(object(value).items, item => copy(item, channelCaller)) }; }
function projectDeliverySlot(value: unknown): ObjectRecord {
return copy(value, ['state', 'message_id', 'status', 'artifact_status', 'artifact_count', 'artifact_failure_count']);
}
function projectActivityDelivery(value: unknown): ObjectRecord {
const source = object(value);
const result = copy(source, ['state', 'delivery_id', 'logical_message_id', 'request_id', 'run_id', 'inbound_message_id']);
for (const key of ['ack', 'progress', 'result'] as const) if (source[key] !== undefined) result[key] = projectDeliverySlot(source[key]);
const core = object(source.core);
if (Object.keys(core).length) {
const projectedCore = copy(core, ['state', 'core_status', 'adapter_status', 'provider_status', 'delivery_status', 'part_count', 'sent_part_count', 'failed_part_count']);
if (Array.isArray(core.parts)) {
projectedCore.parts = list(core.parts, part => {
const item = object(part);
return copy(item, ['part_id', 'type', 'core_status', 'adapter_status', 'provider_status', 'error_category']);
});
}
result.core = projectedCore;
}
const outputMessageId = object(source.result).message_id;
if (typeof outputMessageId === 'string' && outputMessageId) result.logical_message_id = outputMessageId;
if (Array.isArray(core.parts)) {
result.parts = list(core.parts, part => {
const item = object(part);
return { part_id: item.part_id, kind: item.type, status: item.adapter_status ?? item.core_status ?? item.provider_status };
});
}
return result;
}
function projectActivity(value: unknown): unknown {
return {
items: list(object(value).items, item => {
const source = object(item);
const result = copy(source, ['run_id', 'request_id', 'agent_slug', 'caller_id', 'access_mode', 'binding_id', 'provider_generation', 'channel_account_id', 'status', 'source', 'channel', 'error_type']);
if (source.token_usage && typeof source.token_usage === 'object') result.token_usage = copy(source.token_usage, ['input_tokens', 'output_tokens', 'total_tokens', 'prompt_tokens', 'completion_tokens', 'cached_tokens']);
if (source.timing && typeof source.timing === 'object') result.timing = copy(source.timing, ['created_at', 'started_at', 'prepared_at', 'first_output_at', 'finished_at', 'first_model_request_at']);
if (source.delivery && typeof source.delivery === 'object') {
const delivery = object(source.delivery);
result.delivery = projectActivityDelivery(delivery);
}
if (source.input_preparation && typeof source.input_preparation === 'object') result.input_preparation = copy(source.input_preparation, ['intake_id', 'delivery_id', 'logical_message_id', 'state', 'prepared_file_count', 'native_request_id', 'native_run_id', 'error_type']);
return result;
}),
next_offset: object(value).next_offset ?? null,
};
}
function projectDelivery(value: unknown): unknown {
const source = object(value);
return { ...copy(source, ['logical_message_id', 'message_id', 'status']), parts: list(source.parts, item => copy(item, ['part_id', 'part_index', 'type', 'status', 'attempts', 'sent_at'])) };
}
function projectControl(value: unknown): unknown {
const source = object(value);
return { ...copy(source, ['operation_id', 'status', 'session_id', 'previous_session_id', 'run_id', 'request_id', 'intake_ids', 'action']),
...(source.session && typeof source.session === 'object' ? { session: projectSession(source.session) } : {}) };
}
function projectOperation(value: unknown): unknown {
const source = object(value);
const operationResult = source.result && typeof source.result === 'object'
? (object(source.result).channel !== undefined ? projectMutation(source.result) : projectChannelView(source.result))
: undefined;
return { ...copy(source, ['operation_id', 'status', 'error_code']),
...(Array.isArray(source.steps) ? { steps: list(source.steps, item => copy(item, ['name', 'step', 'operation_id', 'status', 'error_code'])) } : {}),
...(operationResult !== undefined ? { result: operationResult } : {}) };
}
function projectScheduleNotification(value: unknown): unknown {
const source = object(value);
return copy(source, ['enabled', 'state', 'error_message', 'channel_account_id', 'caller_id']);
}
function projectSchedule(value: unknown): unknown {
const source = object(value);
const result = copy(source, ['id', 'agent_slug', 'name', 'prompt', 'cron_expression', 'timezone', 'enabled', 'next_run_at']);
if (source.result_notification !== undefined) result.result_notification = source.result_notification === null ? null : projectScheduleNotification(source.result_notification);
if (Array.isArray(source.runs)) {
result.runs = list(source.runs, item => {
const run = copy(item, ['status', 'thread_id', 'error_message', 'conversation_available', 'completed_at', 'run_id']);
if (object(item).result_notification !== undefined) run.result_notification = object(item).result_notification === null ? null : projectScheduleNotification(object(item).result_notification);
return run;
}, 20);
}
return result;
}
function projectSchedules(value: unknown): unknown { return { jobs: list(object(value).jobs, projectSchedule) }; }
function validateChannelInput(operation: string, args: ObjectRecord): void {
const channelOperations = new Set([
'createChannelBinding', 'switchChannelBindingAgent', 'enableChannelBinding', 'pauseChannelBinding',
'disconnectChannelBinding', 'channelPolicy', 'createChannelPairing', 'wechatBindStart',
'wechatBindStatus', 'wechatBindVerification', 'wechatBindAccount', 'wechatUnbind', 'channelSelfCallers',
'channelCallers', 'revokeChannelCaller', 'channelActivity', 'channelConversations', 'channelConversation',
'channelFiles', 'channelConversationControl', 'channelDelivery', 'retryChannelDeliveryPart', 'channelOperation',
]);
if (channelOperations.has(operation)) {
for (const key of ['operation_id', 'channel_account_id', 'caller_id', 'session_id', 'session_key', 'logical_message_id', 'part_id', 'run_id']) {
const value = args[key];
const valid = key === 'session_key'
? typeof value === 'string' && /^[\x21-\x7e]{1,512}$/.test(value)
: typeof value === 'string' && /^[a-zA-Z0-9._:#-]{1,256}$/.test(value);
if (value !== undefined && !valid) throw new Error('invalid_id');
}
if (args.operation_id !== undefined && (typeof args.operation_id !== 'string' || args.operation_id.length > 128)) throw new Error('invalid_id');
}
if (operation === 'createChannelBinding' && (typeof args.display_name !== 'string' || !args.display_name.trim() || args.display_name.length > 100)) throw new Error('invalid_input');
if (operation === 'switchChannelBindingAgent' && (typeof args.target_agent_slug !== 'string' || !/^ml-[a-f0-9]{32}$/.test(args.target_agent_slug))) throw new Error('invalid_id');
if (operation === 'channelPolicy' && !['self_only', 'invited'].includes(String(args.access_mode))) throw new Error('invalid_input');
if (operation === 'createChannelPairing' && !['self', 'invite'].includes(String(args.kind))) throw new Error('invalid_input');
if (operation === 'wechatBindStart' && typeof args.force !== 'boolean') throw new Error('invalid_input');
if (operation === 'wechatBindVerification' && (typeof args.verify_code !== 'string' || !/^[0-9A-Za-z-]{1,128}$/.test(args.verify_code))) throw new Error('invalid_input');
if (operation === 'channelConversationControl' && !['resume', 'stop', 'new-session'].includes(String(args.action))) throw new Error('invalid_input');
for (const key of ['expected_revision', 'expected_policy_revision']) {
if (args[key] !== undefined && (!Number.isSafeInteger(args[key]) || Number(args[key]) < 0)) throw new Error('invalid_input');
}
if (args.result_notification !== undefined && args.result_notification !== null) {
const target = object(args.result_notification);
if (target.enabled !== true || typeof target.channel_account_id !== 'string' || !/^[a-zA-Z0-9._:#-]{1,256}$/.test(target.channel_account_id)
|| typeof target.caller_id !== 'string' || !/^[a-zA-Z0-9._:#-]{1,256}$/.test(target.caller_id)) throw new Error('invalid_input');
}
}
/** Only these product operations can cross the Main boundary; no arbitrary upstream URL or credentials. */
const operations: Record<keyof CloudAgentOperations, OperationSpec> = {
applicationCalls: { method: 'GET', path: '/applications/:application_id/calls', query: { offset: 'offset' }, output: ['items', 'next_offset'] },
@@ -65,9 +263,9 @@ const operations: Record<keyof CloudAgentOperations, OperationSpec> = {
run: { method: 'GET', path: '/runs/:run_id', output: ['agent_run_id', 'request_id', 'thread_id', 'agent_slug', 'status', 'output', 'version', 'error', 'interrupt'] },
cancelRun: { method: 'POST', path: '/runs/:run_id/cancel', output: ['status'] },
resume: { method: 'POST', path: '/runs/:run_id/resume', body: ['operation_id', 'decision'], output: ['run_id', 'status'] },
schedules: { method: 'GET', path: '/agents/:slug/schedules', output: ['jobs'] },
createSchedule: { method: 'POST', path: '/agents/:slug/schedules', body: ['operation_id', ...schedule], output: job },
updateSchedule: { method: 'PUT', path: '/agents/:slug/schedules/:job_id', body: schedule, output: job },
schedules: { method: 'GET', path: '/agents/:slug/schedules', output: ['jobs'], project: projectSchedules },
createSchedule: { method: 'POST', path: '/agents/:slug/schedules', body: ['operation_id', ...schedule], output: job, project: projectSchedule },
updateSchedule: { method: 'PUT', path: '/agents/:slug/schedules/:job_id', body: schedule, output: job, project: projectSchedule },
deleteSchedule: { method: 'DELETE', path: '/agents/:slug/schedules/:job_id', output: ['deleted'] },
runSchedule: { method: 'POST', path: '/agents/:slug/schedules/:job_id/run-now', body: ['operation_id'], output: ['thread_id', 'status'] },
attachments: { method: 'GET', path: '/threads/:thread_id/attachments', output: ['attachments'] },
@@ -75,6 +273,31 @@ const operations: Record<keyof CloudAgentOperations, OperationSpec> = {
confirmAttachment: { method: 'POST', path: '/threads/:thread_id/attachments/confirm', body: ['attachments'], output: ['attachments'] },
deleteAttachment: { method: 'DELETE', path: '/threads/:thread_id/attachments/:file_id', output: ['message'] },
files: { method: 'GET', path: '/threads/:thread_id/files', query: { path: 'path' }, output: ['files'] },
channelConnections: { method: 'GET', path: '/channel-connections', output: ['items'], project: value => ({ items: list(object(value).items, projectChannelView) }) },
channelBindings: { method: 'GET', path: '/agents/:slug/channel-bindings', output: ['items', 'available_accounts'], project: projectBindings },
createChannelBinding: { method: 'POST', path: '/agents/:slug/channel-bindings', body: ['operation_id', 'display_name', 'channel_account_id'], output: ['channel', 'worker'], project: projectMutation },
switchChannelBindingAgent: { method: 'POST', path: '/agents/:slug/channel-bindings/:channel_account_id/route', body: ['operation_id', 'target_agent_slug', 'expected_revision', 'enabled'], output: ['channel', 'target_agent'], project: projectMutation },
enableChannelBinding: { method: 'POST', path: '/agents/:slug/channel-bindings/:channel_account_id/enable', body: ['operation_id', 'expected_revision'], output: ['channel'], project: projectMutation },
pauseChannelBinding: { method: 'POST', path: '/agents/:slug/channel-bindings/:channel_account_id/pause', body: ['operation_id', 'expected_revision'], output: ['channel'], project: projectMutation },
disconnectChannelBinding: { method: 'POST', path: '/agents/:slug/channel-bindings/:channel_account_id/disconnect', body: ['operation_id', 'expected_revision'], output: ['channel', 'wechat', 'disconnected'], project: projectMutation },
channelPolicy: { method: 'PATCH', path: '/agents/:slug/channel-bindings/:channel_account_id', body: ['operation_id', 'access_mode', 'expected_policy_revision'], output: ['binding_id', 'channel_account_id', 'access_mode', 'policy_revision', 'revoked_caller_ids', 'revoked_invitation_ids'], project: projectPolicy },
createChannelPairing: { method: 'POST', path: '/agents/:slug/channel-bindings/:channel_account_id/pairings', body: ['operation_id', 'kind'], output: ['invitation_id', 'binding_id', 'provider_generation', 'agent_slug', 'kind', 'expires_at', 'consumed', 'code'], project: projectPairing },
wechatBindStart: { method: 'POST', path: '/agents/:slug/channel-bindings/:channel_account_id/wechat-bind/start', body: ['operation_id', 'force'], output: ['session_key', 'status', 'message', 'qrcode_url'], project: projectWechat },
wechatBindStatus: { method: 'GET', path: '/agents/:slug/channel-bindings/:channel_account_id/wechat-bind/status', query: { session_key: 'session_key' }, output: ['session_key', 'status', 'message', 'qrcode_url'], project: projectWechat },
wechatBindVerification: { method: 'POST', path: '/agents/:slug/channel-bindings/:channel_account_id/wechat-bind/verification', body: ['operation_id', 'session_key', 'verify_code'], output: ['session_key', 'status', 'message', 'qrcode_url'], project: projectWechat },
wechatBindAccount: { method: 'GET', path: '/agents/:slug/channel-bindings/:channel_account_id/wechat-bind/account', output: ['status', 'message'] },
wechatUnbind: { method: 'POST', path: '/agents/:slug/channel-bindings/:channel_account_id/wechat-bind/unbind', body: ['operation_id'], output: ['status', 'message'] },
channelSelfCallers: { method: 'GET', path: '/agents/:slug/channel-callers', fixedQuery: { access_mode: 'self_only' }, output: ['items'], project: projectCallers },
channelCallers: { method: 'GET', path: '/agents/:slug/channel-bindings/:channel_account_id/callers', output: ['items'], project: projectCallers },
revokeChannelCaller: { method: 'POST', path: '/agents/:slug/channel-bindings/:channel_account_id/callers/:caller_id/revoke', body: ['operation_id'], output: ['status', 'caller_id'] },
channelActivity: { method: 'GET', path: '/agents/:slug/channel-bindings/:channel_account_id/activity', query: { offset: 'offset', limit: 'limit' }, output: ['items', 'next_offset'], project: projectActivity },
channelConversations: { method: 'GET', path: '/channel-conversations', query: { slug: 'agent_slug', offset: 'offset' }, output: ['items', 'next_offset'], project: value => ({ items: list(object(value).items, projectSession), next_offset: object(value).next_offset ?? null }) },
channelConversation: { method: 'GET', path: '/channel-conversations/:session_id', query: { offset: 'offset' }, output: ['session_id', 'caller_id', 'agent_slug', 'messages', 'run', 'queued_requests', 'next_offset'], project: projectConversation },
channelFiles: { method: 'GET', path: '/channel-conversations/:session_id/files', query: { path: 'path' }, output: ['session_id', 'thread_id', 'files'], project: projectFiles },
channelConversationControl: { method: 'POST', path: '/channel-conversations/:session_id/controls', body: ['operation_id', 'action', 'run_id', 'decision'], output: ['operation_id', 'status', 'session_id', 'action'], project: projectControl },
channelDelivery: { method: 'GET', path: '/agents/:slug/channel-bindings/:channel_account_id/deliveries/:logical_message_id', output: ['logical_message_id', 'message_id', 'status', 'parts'], project: projectDelivery },
retryChannelDeliveryPart: { method: 'POST', path: '/agents/:slug/channel-bindings/:channel_account_id/deliveries/:logical_message_id/parts/:part_id/retry', output: ['logical_message_id', 'message_id', 'status', 'parts'], project: projectDelivery },
channelOperation: { method: 'GET', path: '/channel-operations/:operation_id', query: { slug: 'agent_slug' }, output: channelOperation, project: projectOperation },
};
export function operationPlan(value: unknown) {
@@ -84,18 +307,21 @@ export function operationPlan(value: unknown) {
|| !input || typeof input !== 'object' || Array.isArray(input)) throw new Error('invalid_operation');
const spec = operations[operation as keyof CloudAgentOperations];
const args = input as Record<string, unknown>;
validateChannelInput(operation, args);
const routeKeys = [...spec.path.matchAll(/:([a-z_]+)/g)].map(match => match[1]);
const path = spec.path.replace(/:([a-z_]+)/g, (_, key: string) => {
const id = args[key];
if (typeof id !== 'string' || !/^[a-zA-Z0-9_-]{1,128}$/.test(id)
if (typeof id !== 'string' || !/^[a-zA-Z0-9._:#-]{1,256}$/.test(id)
|| (key === 'slug' && !/^ml-[a-f0-9]{32}$/.test(id))) throw new Error('invalid_id');
return encodeURIComponent(id);
});
const query = new URLSearchParams();
for (const [name, value] of Object.entries(spec.fixedQuery ?? {})) query.set(name, value);
for (const [key, name] of Object.entries(spec.query ?? {})) {
const v = args[key];
if (v === undefined) continue;
if (key === 'archived' ? typeof v !== 'boolean' : key === 'offset' ? !Number.isSafeInteger(v) || Number(v) < 0 : typeof v !== 'string' || v.length > (key === 'path' ? 2048 : 100)) {
if (key === 'archived' ? typeof v !== 'boolean' : key === 'offset' || key === 'limit' ? !Number.isSafeInteger(v) || Number(v) < 0 || (key === 'limit' && Number(v) > 100) : typeof v !== 'string' || v.length > (key === 'path' ? 2048 : key === 'session_key' ? 512 : 200)
|| (key === 'slug' || key === 'agent_slug') && !/^ml-[a-f0-9]{32}$/.test(String(v))) {
throw new Error('invalid_query');
}
query.set(name, String(v));
@@ -107,6 +333,7 @@ export function operationPlan(value: unknown) {
...spec, path: (spec.cloud === 'ws' ? '/api/cloud-agents' : '/api/makelore') + path + (query.size ? '?' + query : ''),
body: spec.body ? Object.fromEntries(spec.body.filter(key => args[key] !== undefined).map(key => [key, args[key]])) : undefined,
project: (result: unknown) => {
if (spec.project) return spec.project(result);
if (!result || typeof result !== 'object' || Array.isArray(result)) throw new Error('invalid_response');
const record = result as Record<string, unknown>;
return Object.fromEntries(spec.output.filter(key => record[key] !== undefined).map(key => [key, record[key]]));

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');