628 lines
32 KiB
TypeScript
628 lines
32 KiB
TypeScript
import { EMPTY_CLOUD_CONFIGURATION, type CloudAgentConfiguration, type CloudAgentDraft, type CloudAgentPage, type CreateCloudAgent, type SaveCloudAgentDraft, type CloudUpload } from '../../shared/cloud-agents';
|
||
import { operationPlan } from './cloud-agent-operations';
|
||
import { CloudAgentJournal } from './cloud-agent-journal';
|
||
import type { CloudRecent } from '../../shared/cloud-agents';
|
||
import { WORKS_SQUARE_CONFIG } from '../api/works-config';
|
||
import { proxyAwareFetch, runWithDeadline } from '../utils/proxy-fetch';
|
||
import {
|
||
getValidWorksSquareAccessToken,
|
||
getWorksSquareAccountBinding,
|
||
isCurrentWorksSquareAccountBinding,
|
||
subscribeWorksSquareSession,
|
||
type WorksSquareAccountBinding,
|
||
} from './works-square-session';
|
||
|
||
const MESSAGES: Record<string, string> = {
|
||
session_expired: '登录已过期,请重新登录',
|
||
account_changed: '账号已切换,请重新打开智能体',
|
||
account_locked: '账号已锁定,请稍后重试',
|
||
module_unavailable: 'AI 智能体模块暂不可用',
|
||
agent_not_found: '智能体不存在',
|
||
draft_revision_conflict: '草稿已在其他设备更新,你的输入已保留',
|
||
operation_conflict: '创建操作的内容发生冲突,请刷新列表确认',
|
||
cloud_service_unavailable: '智能体服务暂时不可用,请重试',
|
||
invalid_input: '请检查名称、用途和配置内容',
|
||
model_required: '请先选择模型并保存',
|
||
model_unavailable: '所选模型当前不可用,请刷新模型目录',
|
||
resource_unavailable: '所选资源已不可用,请刷新配置',
|
||
publish_required: '请先发布智能体',
|
||
access_revoked: '该智能体的使用授权已撤销',
|
||
application_disabled: '应用已停用',
|
||
key_revoked: '此凭据已撤销,请新建凭据',
|
||
insufficient_balance: '创建者的词元点数不足',
|
||
request_conflict: '该请求已用于其他输入,请开始新的请求',
|
||
thread_archived: '请先恢复已归档的对话再提交任务',
|
||
thread_active: '请先处理待回答或审批的任务,并停止运行及排队任务,再归档对话',
|
||
knowledge_processing: '知识库有文档正在处理,请完成后再维护',
|
||
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 {
|
||
constructor(readonly status: number, readonly code: string) {
|
||
super(MESSAGES[code] ?? MESSAGES.cloud_service_unavailable);
|
||
}
|
||
}
|
||
|
||
function record(value: unknown): Record<string, unknown> {
|
||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||
? value as Record<string, unknown> : {};
|
||
}
|
||
|
||
function textField(value: unknown, max: number, nonempty = true): string {
|
||
if (typeof value !== 'string' || value.length > max || (nonempty && !value.trim())) {
|
||
throw new CloudAgentsError(422, 'invalid_input');
|
||
}
|
||
return value;
|
||
}
|
||
|
||
function revision(value: unknown): number {
|
||
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1) {
|
||
throw new CloudAgentsError(422, 'invalid_input');
|
||
}
|
||
return value;
|
||
}
|
||
|
||
function slug(value: string): string {
|
||
if (!/^ml-[a-f0-9]{32}$/.test(value)) throw new CloudAgentsError(422, 'invalid_input');
|
||
return value;
|
||
}
|
||
|
||
function projectDraft(value: unknown): CloudAgentDraft {
|
||
const item = record(value);
|
||
try { return {
|
||
slug: slug(textField(item.slug, 80)),
|
||
name: textField(item.name, 100),
|
||
purpose: textField(item.purpose, 2000),
|
||
system_prompt: textField(item.system_prompt, 32000, false),
|
||
draft_revision: revision(item.draft_revision),
|
||
updated_at: textField(item.updated_at, 40),
|
||
configuration: configuration(item.configuration),
|
||
published_version: item.published_version == null ? null : revision(item.published_version),
|
||
enabled: item.enabled !== false,
|
||
archived: item.archived === true,
|
||
}; } catch { throw new CloudAgentsError(502, 'cloud_service_unavailable'); }
|
||
}
|
||
|
||
function configuration(value: unknown): CloudAgentConfiguration {
|
||
const input = record(value);
|
||
const result = { ...EMPTY_CLOUD_CONFIGURATION, ...Object.fromEntries(
|
||
Object.keys(EMPTY_CLOUD_CONFIGURATION).filter(key => input[key] !== undefined).map(key => [key, input[key]]),
|
||
) };
|
||
textField(result.model, 200, false);
|
||
for (const key of ['tools', 'knowledges', 'mcps', 'skills', 'preload_skills', 'subagents'] as const) {
|
||
if (!Array.isArray(result[key]) || result[key].length > 100 || result[key].some(v => typeof v !== 'string' || v.length > 200)) {
|
||
throw new CloudAgentsError(422, 'invalid_input');
|
||
}
|
||
}
|
||
if (!['default', 'always_trust'].includes(result.tool_approval_mode)
|
||
|| !Number.isInteger(result.max_execution_steps) || result.max_execution_steps < 1 || result.max_execution_steps > 300
|
||
|| !Number.isInteger(result.max_output_tokens) || result.max_output_tokens < 1 || result.max_output_tokens > 32768
|
||
|| !Number.isInteger(result.max_run_seconds) || result.max_run_seconds < 10 || result.max_run_seconds > 3600) {
|
||
throw new CloudAgentsError(422, 'invalid_input');
|
||
}
|
||
return result;
|
||
}
|
||
|
||
type Session = {
|
||
binding: WorksSquareAccountBinding;
|
||
accessToken: string;
|
||
apiBaseUrl: string;
|
||
expiresAt: number;
|
||
};
|
||
|
||
/** Main owns cloud credentials and transport; Renderer receives product data only. */
|
||
export class CloudAgentsModule {
|
||
private cached: Session | null = null;
|
||
private readonly unsubscribe: () => void;
|
||
private readonly streams = new Map<AbortController, WorksSquareAccountBinding>();
|
||
private activityTimer: ReturnType<typeof setInterval>;
|
||
private activityPolling = false;
|
||
private activitySeen: Map<string, string> | null = null;
|
||
|
||
constructor(private readonly fetchImpl: (input: string, init?: RequestInit) => Promise<Response> = proxyAwareFetch,
|
||
private readonly journal = new CloudAgentJournal()) {
|
||
this.unsubscribe = subscribeWorksSquareSession(() => {
|
||
if (this.cached && !isCurrentWorksSquareAccountBinding(this.cached.binding)) {
|
||
this.cached = null;
|
||
this.activitySeen = null;
|
||
}
|
||
for (const [controller, binding] of this.streams) {
|
||
if (!isCurrentWorksSquareAccountBinding(binding)) controller.abort();
|
||
}
|
||
});
|
||
this.activityTimer = setInterval(() => { void this.pollActivity(); }, 30000);
|
||
this.activityTimer.unref?.();
|
||
}
|
||
|
||
dispose(): void {
|
||
this.cached = null;
|
||
clearInterval(this.activityTimer);
|
||
for (const controller of this.streams.keys()) controller.abort();
|
||
this.unsubscribe();
|
||
}
|
||
|
||
async list(cursor: string | null = null, archived = false): Promise<CloudAgentPage> {
|
||
if (cursor !== null && !/^\d{1,16}$/.test(cursor)) throw new CloudAgentsError(422, 'invalid_input');
|
||
const params = new URLSearchParams();
|
||
if (cursor) params.set('cursor', cursor);
|
||
if (archived) params.set('archived', 'true');
|
||
const body = record(await this.request('/api/makelore/agents' + (params.size ? '?' + params : ''), 'GET'));
|
||
if (!Array.isArray(body.agents) || body.agents.length > 100
|
||
|| (body.next_cursor !== null && (typeof body.next_cursor !== 'string' || !/^\d{1,16}$/.test(body.next_cursor)))) {
|
||
throw new CloudAgentsError(502, 'cloud_service_unavailable');
|
||
}
|
||
return { agents: body.agents.map(projectDraft), next_cursor: body.next_cursor as string | null };
|
||
}
|
||
|
||
async get(agentSlug: string): Promise<CloudAgentDraft> {
|
||
return projectDraft(await this.request(`/api/makelore/agents/${slug(agentSlug)}`, 'GET'));
|
||
}
|
||
|
||
async create(value: unknown): Promise<CloudAgentDraft> {
|
||
const input = record(value);
|
||
const operationId = textField(input.operation_id, 36);
|
||
if (!/^[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(operationId)) {
|
||
throw new CloudAgentsError(422, 'invalid_input');
|
||
}
|
||
const body: CreateCloudAgent = {
|
||
operation_id: operationId,
|
||
name: textField(input.name, 100).trim(),
|
||
purpose: textField(input.purpose, 2000).trim(),
|
||
};
|
||
return this.withRecovery('createAgent', { ...body }, () => this.request('/api/makelore/agents', 'POST', body).then(projectDraft));
|
||
}
|
||
|
||
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 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();
|
||
this.requireCurrent(binding);
|
||
await this.journal.remove(binding.accountKey, id, () => this.requireCurrent(binding));
|
||
return result;
|
||
}
|
||
|
||
async recovery() {
|
||
const binding = getWorksSquareAccountBinding();
|
||
if (!binding) throw new CloudAgentsError(401, 'session_expired');
|
||
const state = await this.journal.read(binding.accountKey);
|
||
this.requireCurrent(binding);
|
||
return state;
|
||
}
|
||
|
||
async rememberRecent(value: unknown) {
|
||
const binding = getWorksSquareAccountBinding();
|
||
if (!binding) throw new CloudAgentsError(401, 'session_expired');
|
||
const input = record(value);
|
||
const recent: CloudRecent | null = value === null ? null : {
|
||
slug: slug(textField(input.slug, 64)),
|
||
mode: input.mode === 'preview' ? 'preview' : 'published',
|
||
...(input.thread_id === undefined ? {} : { thread_id: textField(input.thread_id, 128) }),
|
||
...(input.draft_revision === undefined ? {} : { draft_revision: revision(input.draft_revision) }),
|
||
};
|
||
await this.journal.remember(binding.accountKey, recent, () => this.requireCurrent(binding));
|
||
return { saved: true };
|
||
}
|
||
|
||
async resolvePending(value: unknown) {
|
||
const input = record(value);
|
||
const id = textField(input.id, 300);
|
||
const binding = getWorksSquareAccountBinding();
|
||
if (!binding) throw new CloudAgentsError(401, 'session_expired');
|
||
const state = await this.recovery();
|
||
const pending = state.pending.find(item => item.id === id);
|
||
if (!pending) throw new CloudAgentsError(404, 'invalid_input');
|
||
if (input.discard === true) {
|
||
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 };
|
||
}
|
||
|
||
async save(agentSlug: string, value: unknown): Promise<CloudAgentDraft> {
|
||
const input = record(value);
|
||
const body: SaveCloudAgentDraft = {
|
||
expected_revision: revision(input.expected_revision),
|
||
name: textField(input.name, 100).trim(),
|
||
purpose: textField(input.purpose, 2000).trim(),
|
||
system_prompt: textField(input.system_prompt, 32000, false),
|
||
...(input.configuration === undefined ? {} : { configuration: configuration(input.configuration) }),
|
||
};
|
||
return projectDraft(await this.request(`/api/makelore/agents/${slug(agentSlug)}/draft`, 'PATCH', body));
|
||
}
|
||
|
||
private async pollActivity(): Promise<void> {
|
||
if (!this.cached || this.activityPolling) return;
|
||
this.activityPolling = true;
|
||
try {
|
||
const binding = this.cached.binding;
|
||
const result = record(await this.request('/api/makelore/threads', 'GET'));
|
||
const rows = Array.isArray(result.threads) ? result.threads.map(record) : [];
|
||
const next = new Map(rows.filter(row => typeof row.run_id === 'string').map(row => [String(row.run_id), String(row.status)]));
|
||
if (this.activitySeen) {
|
||
const changed = rows.filter(row => row.unread === true && typeof row.run_id === 'string'
|
||
&& ['completed', 'failed', 'interrupted'].includes(String(row.status))
|
||
&& this.activitySeen?.get(row.run_id) !== row.status);
|
||
if (changed.length) {
|
||
const { Notification, BrowserWindow } = await import('electron');
|
||
this.requireCurrent(binding);
|
||
if (Notification.isSupported()) {
|
||
const notice = new Notification({ title: '智能体有新的任务结果', body: changed.some(row => row.status === 'interrupted')
|
||
? '有任务等待你的确认,打开活动查看。' : '打开活动查看完成结果或失败原因。' });
|
||
notice.on('click', () => {
|
||
if (!isCurrentWorksSquareAccountBinding(binding)) return;
|
||
const window = BrowserWindow.getAllWindows().find(w => !w.isDestroyed());
|
||
window?.show(); window?.focus(); window?.webContents.send('navigate', '/cloud-agents?view=activity');
|
||
});
|
||
notice.show();
|
||
}
|
||
}
|
||
}
|
||
this.activitySeen = next;
|
||
} catch(error) {
|
||
if (error instanceof CloudAgentsError && [401, 403, 423].includes(error.status)) this.cached = null;
|
||
// The durable activity list remains the source of truth when notifications or transport are unavailable.
|
||
} finally { this.activityPolling = false; }
|
||
}
|
||
|
||
async execute(value: unknown): Promise<unknown> {
|
||
let plan;
|
||
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',
|
||
'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') {
|
||
// The address is public; both exchanged tokens stay in Main.
|
||
const binding = getWorksSquareAccountBinding();
|
||
if (!binding) throw new CloudAgentsError(401, 'session_expired');
|
||
const session = await runWithDeadline(signal => this.session(binding, signal), 30000);
|
||
this.requireCurrent(binding);
|
||
return { ...projected, api_url: session.apiBaseUrl + '/api/makelore/api/requests' };
|
||
}
|
||
return projected;
|
||
}
|
||
catch (error) {
|
||
if (error instanceof CloudAgentsError) throw error;
|
||
throw new CloudAgentsError(502, 'cloud_service_unavailable');
|
||
}
|
||
}
|
||
|
||
async upload(): Promise<CloudUpload | null> {
|
||
const body = await this.pickUpload();
|
||
if (!body) return null;
|
||
const result = record(await this.request('/api/makelore/attachments/tmp', 'POST', body));
|
||
return {
|
||
object_name: textField(result.object_name, 2048), file_name: textField(result.file_name, 255),
|
||
file_type: typeof result.file_type === 'string' ? result.file_type : 'application/octet-stream',
|
||
parse_supported: result.parse_supported === true,
|
||
parse_methods: Array.isArray(result.parse_methods) ? result.parse_methods.filter((v): v is string => typeof v === 'string') : [],
|
||
};
|
||
}
|
||
|
||
async uploadKnowledge(value: unknown): Promise<unknown> {
|
||
const input = record(value);
|
||
const slug = textField(input.slug, 128), kbId = textField(input.kb_id, 128);
|
||
const operationId = textField(input.operation_id, 64);
|
||
const replacement = input.replaces_file_id === undefined ? '' : textField(input.replaces_file_id, 64);
|
||
if (replacement && !/^[a-zA-Z0-9_-]+$/.test(replacement)) throw new CloudAgentsError(422, 'invalid_input');
|
||
if (!/^ml-[a-f0-9]{32}$/.test(slug) || !/^kb_[a-z0-9]+$/.test(kbId)
|
||
|| !/^[a-f0-9-]{36}$/.test(operationId)) throw new CloudAgentsError(422, 'invalid_input');
|
||
const body = await this.pickUpload();
|
||
if (!body) return null;
|
||
const result = record(await this.request(`/api/makelore/agents/${slug}/knowledge/${kbId}/files?operation_id=${operationId}` + (replacement ? '&replaces_file_id=' + replacement : ''), 'POST', body));
|
||
return Object.fromEntries(['file_id', 'name', 'size', 'status', 'error', 'chunk_count'].map(key => [key, result[key]]));
|
||
}
|
||
|
||
async uploadSkill(): Promise<unknown> {
|
||
const body = await this.pickUpload();
|
||
if (!body) return null;
|
||
const result = record(await this.request('/api/makelore/resources/skills/upload', 'POST', body));
|
||
return { draft_id: result.draft_id, items: result.items };
|
||
}
|
||
|
||
private async pickUpload(): Promise<FormData | null> {
|
||
const binding = getWorksSquareAccountBinding();
|
||
if (!binding) throw new CloudAgentsError(401, 'session_expired');
|
||
const { dialog } = await import('electron');
|
||
const { readFile, stat } = await import('node:fs/promises');
|
||
const { basename } = await import('node:path');
|
||
const picked = await dialog.showOpenDialog({ title: '添加智能体附件(最大 5 MB)', properties: ['openFile'] });
|
||
this.requireCurrent(binding);
|
||
if (picked.canceled || !picked.filePaths[0]) return null;
|
||
const path = picked.filePaths[0];
|
||
if ((await stat(path)).size > 5 * 1024 * 1024) throw new CloudAgentsError(422, 'attachment_too_large');
|
||
const bytes = await readFile(path);
|
||
this.requireCurrent(binding);
|
||
if (bytes.length > 5 * 1024 * 1024) throw new CloudAgentsError(422, 'attachment_too_large');
|
||
const body = new FormData();
|
||
body.append('file', new Blob([new Uint8Array(bytes)]), basename(path));
|
||
return body;
|
||
}
|
||
|
||
async download(value: unknown): Promise<{ saved: boolean }> {
|
||
const input = record(value);
|
||
const threadId = textField(input.thread_id, 128);
|
||
const path = textField(input.path, 2048);
|
||
if (!/^[a-zA-Z0-9_-]+$/.test(threadId)) 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-download-' + crypto.randomUUID());
|
||
try {
|
||
await runWithDeadline(async signal => {
|
||
const session = await this.session(binding, signal);
|
||
this.requireCurrent(binding);
|
||
const url = session.apiBaseUrl + '/api/makelore/threads/' + encodeURIComponent(threadId) + '/artifacts/'
|
||
+ path.replace(/^\/+/, '').split('/').map(encodeURIComponent).join('/') + '?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); }
|
||
}
|
||
|
||
/** 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');
|
||
}
|
||
const binding = getWorksSquareAccountBinding();
|
||
if (!binding) throw new CloudAgentsError(401, 'session_expired');
|
||
const controller = new AbortController();
|
||
const abort = () => controller.abort();
|
||
signal.addEventListener('abort', abort, { once: true });
|
||
if (signal.aborted) abort();
|
||
this.streams.set(controller, binding);
|
||
let cancelReader: (() => Promise<void>) | undefined;
|
||
try {
|
||
const session = await runWithDeadline(s => this.session(binding, s), 30000, controller.signal);
|
||
this.requireCurrent(binding);
|
||
const response = await this.fetchImpl(session.apiBaseUrl + '/api/makelore/runs/' + runId + '/events?after_seq=' + after, {
|
||
method: 'GET', redirect: 'error', signal: controller.signal,
|
||
headers: { Authorization: 'Bearer ' + session.accessToken, Accept: 'text/event-stream' },
|
||
});
|
||
this.requireCurrent(binding);
|
||
if (!response.ok || !response.body) throw new CloudAgentsError(response.status || 502, 'cloud_service_unavailable');
|
||
const reader = response.body.getReader();
|
||
cancelReader = () => reader.cancel();
|
||
while (true) {
|
||
const chunk = await reader.read();
|
||
this.requireCurrent(binding);
|
||
if (chunk.done) break;
|
||
yield chunk.value;
|
||
}
|
||
} finally {
|
||
await cancelReader?.().catch(() => undefined);
|
||
controller.abort();
|
||
signal.removeEventListener('abort', abort);
|
||
this.streams.delete(controller);
|
||
}
|
||
}
|
||
|
||
private async request(path: string, method: string, body?: unknown, worksSquare = false): Promise<unknown> {
|
||
const binding = getWorksSquareAccountBinding();
|
||
if (!binding) throw new CloudAgentsError(401, 'session_expired');
|
||
try {
|
||
return await runWithDeadline(async (signal) => {
|
||
const session = worksSquare ? {
|
||
apiBaseUrl: WORKS_SQUARE_CONFIG.apiBaseUrl,
|
||
accessToken: await getValidWorksSquareAccessToken(),
|
||
} : await this.session(binding, signal);
|
||
this.requireCurrent(binding);
|
||
if (!session.accessToken) throw new CloudAgentsError(401, 'session_expired');
|
||
const result = await this.fetchJson(session.apiBaseUrl + path, {
|
||
method, signal, redirect: 'error',
|
||
headers: { Authorization: `Bearer ${session.accessToken}`, ...(body instanceof FormData ? {} : { 'Content-Type': 'application/json' }) },
|
||
...(body === undefined ? {} : { body: body instanceof FormData ? body : JSON.stringify(body) }),
|
||
});
|
||
this.requireCurrent(binding);
|
||
return result;
|
||
}, 30000);
|
||
} catch (error) {
|
||
this.requireCurrent(binding);
|
||
if (error instanceof CloudAgentsError) {
|
||
if (error.status === 401) this.cached = null;
|
||
throw error;
|
||
}
|
||
throw new CloudAgentsError(502, 'cloud_service_unavailable');
|
||
}
|
||
}
|
||
|
||
private requireCurrent(binding: WorksSquareAccountBinding): void {
|
||
if (!isCurrentWorksSquareAccountBinding(binding)) throw new CloudAgentsError(409, 'account_changed');
|
||
}
|
||
|
||
private async session(binding: WorksSquareAccountBinding, signal: AbortSignal): Promise<Session> {
|
||
if (this.cached && this.cached.expiresAt > Date.now() + 5000
|
||
&& this.cached.binding.accountKey === binding.accountKey && this.cached.binding.epoch === binding.epoch) {
|
||
return this.cached;
|
||
}
|
||
const token = await getValidWorksSquareAccessToken();
|
||
this.requireCurrent(binding);
|
||
if (!token) throw new CloudAgentsError(401, 'session_expired');
|
||
const value = record(await this.fetchJson(`${WORKS_SQUARE_CONFIG.apiBaseUrl}/api/cloud-agents/session`, {
|
||
method: 'POST', signal, redirect: 'error',
|
||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||
}));
|
||
this.requireCurrent(binding);
|
||
const url = new URL(textField(value.api_base_url, 2048));
|
||
if ((url.protocol !== 'https:' && !(url.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname)))
|
||
|| url.username || url.password || url.search || url.hash
|
||
|| value.scope !== 'makelore-agents' || value.token_type !== 'bearer'
|
||
|| typeof value.expires_at !== 'number' || value.expires_at * 1000 <= Date.now()
|
||
|| value.expires_at * 1000 > Date.now() + 300000) {
|
||
throw new CloudAgentsError(502, 'cloud_service_unavailable');
|
||
}
|
||
this.cached = {
|
||
binding, accessToken: textField(value.access_token, 8192),
|
||
apiBaseUrl: url.toString().replace(/\/+$/, ''), expiresAt: value.expires_at * 1000,
|
||
};
|
||
return this.cached;
|
||
}
|
||
|
||
private async fetchJson(url: string, init: RequestInit): Promise<unknown> {
|
||
const response = await this.fetchImpl(url, init);
|
||
const raw = await response.text();
|
||
if (raw.length > 4_000_000) throw new CloudAgentsError(502, 'cloud_service_unavailable');
|
||
const body: unknown = JSON.parse(raw);
|
||
if (!response.ok) {
|
||
const code = record(record(body).detail).code;
|
||
throw new CloudAgentsError(
|
||
response.status, typeof code === 'string' && Object.hasOwn(MESSAGES, code) ? code
|
||
: response.status === 422 ? 'invalid_input' : response.status === 401 ? 'session_expired'
|
||
: response.status === 403 ? 'access_revoked' : 'cloud_service_unavailable',
|
||
);
|
||
}
|
||
return body;
|
||
}
|
||
}
|