feat(agents): 完善恢复费用与云资源交互
This commit is contained in:
@@ -35,16 +35,24 @@ export async function handleCloudAgentsRoutes(req: IncomingMessage, res: ServerR
|
||||
res.end();
|
||||
return true;
|
||||
}
|
||||
if (path === '/attachments/pick' && req.method === 'POST') {
|
||||
if (path === '/recovery' && req.method === 'GET') {
|
||||
data = await cloudAgents.recovery();
|
||||
} else if (path === '/recovery/recent' && req.method === 'PUT') {
|
||||
data = await cloudAgents.rememberRecent(await parseJsonBody(req));
|
||||
} else if (path === '/recovery/pending' && req.method === 'POST') {
|
||||
data = await cloudAgents.resolvePending(await parseJsonBody(req));
|
||||
} else if (path === '/attachments/pick' && req.method === 'POST') {
|
||||
data = await cloudAgents.upload();
|
||||
} else if (path === '/knowledge/pick' && req.method === 'POST') {
|
||||
data = await cloudAgents.uploadKnowledge(await parseJsonBody(req));
|
||||
} else if (path === '/skills/pick' && req.method === 'POST') {
|
||||
data = await cloudAgents.uploadSkill();
|
||||
} else if (path === '/files/save' && req.method === 'POST') {
|
||||
data = await cloudAgents.download(await parseJsonBody(req));
|
||||
} else if (path === '/actions' && req.method === 'POST') {
|
||||
data = await cloudAgents.execute(await parseJsonBody(req));
|
||||
} else if ((path === '/bootstrap' || path === '/agents') && req.method === 'GET') {
|
||||
data = await cloudAgents.list(url.searchParams.get('cursor'));
|
||||
data = await cloudAgents.list(url.searchParams.get('cursor'), url.searchParams.get('archived') === 'true');
|
||||
} else if (path === '/agents' && req.method === 'POST') {
|
||||
data = await cloudAgents.create(await parseJsonBody(req));
|
||||
} else {
|
||||
|
||||
42
electron/services/cloud-agent-journal.ts
Normal file
42
electron/services/cloud-agent-journal.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { CloudPendingOperation, CloudRecoveryState, CloudRecent } from '../../shared/cloud-agents';
|
||||
|
||||
interface JournalStore {
|
||||
get(key: string, fallback: CloudRecoveryState): CloudRecoveryState;
|
||||
set(key: string, value: CloudRecoveryState): void;
|
||||
}
|
||||
let storePromise: Promise<JournalStore> | undefined;
|
||||
const loadStore = () => storePromise ??= import('electron-store').then(({ default: Store }) =>
|
||||
new Store<Record<string, CloudRecoveryState>>({ name: 'makelore-cloud-recovery' }));
|
||||
|
||||
/** Main 保存当前账号的未确认业务输入;不保存凭据,也不自动重放。 */
|
||||
export class CloudAgentJournal {
|
||||
constructor(private readonly store = loadStore) {}
|
||||
|
||||
async read(accountKey: string): Promise<CloudRecoveryState> {
|
||||
return (await this.store()).get(accountKey, { recent: null, pending: [] });
|
||||
}
|
||||
|
||||
private async update(accountKey: string, validate: () => void, change: (value: CloudRecoveryState) => CloudRecoveryState) {
|
||||
const store = await this.store();
|
||||
validate();
|
||||
store.set(accountKey, change(store.get(accountKey, { recent: null, pending: [] })));
|
||||
}
|
||||
|
||||
remember(accountKey: string, recent: CloudRecent | null, validate: () => void) {
|
||||
return this.update(accountKey, validate, value => ({ ...value, recent }));
|
||||
}
|
||||
|
||||
put(accountKey: string, entry: CloudPendingOperation, validate: () => void) {
|
||||
return this.update(accountKey, validate, value => {
|
||||
const existing = value.pending.find(item => item.id === entry.id);
|
||||
if (existing && JSON.stringify(existing.input) !== JSON.stringify(entry.input)) {
|
||||
throw new Error('同一次待确认操作不能更改输入');
|
||||
}
|
||||
return { ...value, pending: existing ? value.pending : [...value.pending, entry] };
|
||||
});
|
||||
}
|
||||
|
||||
remove(accountKey: string, id: string, validate: () => void) {
|
||||
return this.update(accountKey, validate, value => ({ ...value, pending: value.pending.filter(item => item.id !== id) }));
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,31 @@ const request = ['request_id', 'thread_id', 'run_id', 'status', 'version'];
|
||||
const schedule = ['name', 'prompt', 'cron_expression', 'timezone', 'enabled'];
|
||||
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'];
|
||||
const draft = ['slug', 'name', 'purpose', 'system_prompt', 'draft_revision', 'updated_at', 'configuration', 'published_version', 'enabled', 'archived'];
|
||||
const mcpInput = ['name', 'description', 'transport', 'url', 'headers'];
|
||||
const mcpOutput = ['slug', 'name', 'description', 'transport', 'url', 'enabled', 'has_credentials'];
|
||||
const child = ['name', 'purpose', 'system_prompt'];
|
||||
/** 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'] },
|
||||
resources: { method: 'GET', path: '/resources', output: ['mcps', 'skills', 'subagents'] },
|
||||
createMcp: { method: 'POST', path: '/resources/mcps', body: [...mcpInput, 'operation_id'], output: mcpOutput },
|
||||
updateMcp: { method: 'PUT', path: '/resources/mcps/:key', body: mcpInput, output: mcpOutput },
|
||||
enableMcp: { method: 'PUT', path: '/resources/mcps/:key/enabled', body: ['enabled'], output: mcpOutput },
|
||||
deleteMcp: { method: 'DELETE', path: '/resources/mcps/:key', output: ['deleted'] },
|
||||
createChild: { method: 'POST', path: '/resources/subagents', body: [...child, 'operation_id'], output: ['slug', ...child] },
|
||||
updateChild: { method: 'PUT', path: '/resources/subagents/:key', body: child, output: ['slug', ...child] },
|
||||
confirmSkill: { method: 'POST', path: '/resources/skills/drafts/:draft_id/confirm', output: ['items'] },
|
||||
deleteSkill: { method: 'DELETE', path: '/resources/skills/:key', output: ['deleted'] },
|
||||
deleteKnowledge: { method: 'DELETE', path: '/agents/:slug/knowledge/:kb_id', output: ['deleted'] },
|
||||
deleteKnowledgeFile: { method: 'DELETE', path: '/agents/:slug/knowledge/:kb_id/files/:file_id', output: ['deleted'] },
|
||||
importKnowledgeAttachment: { method: 'POST', path: '/agents/:slug/knowledge/:kb_id/import-attachment', body: ['operation_id', 'thread_id', 'attachment_id'], output: ['file_id', 'name', 'size', 'status', 'error', 'chunk_count'] },
|
||||
archiveAgent: { method: 'PUT', path: '/agents/:slug/archive', body: ['archived'], output: draft },
|
||||
archiveThread: { method: 'PUT', path: '/threads/:thread_id/archive', body: ['archived'], output: ['thread_id', 'archived'] },
|
||||
version: { method: 'GET', path: '/agents/:slug/versions/:version', output: ['version', 'created_at', 'name', 'purpose', 'system_prompt', 'configuration'] },
|
||||
restoreVersion: { method: 'POST', path: '/agents/:slug/versions/:version/restore', body: ['expected_revision'], output: draft },
|
||||
scheduleContext: { method: 'GET', path: '/agents/:slug/schedule-context', output: ['version', 'enabled', 'configuration', 'result_destination', 'payer'] },
|
||||
knowledge: { method: 'GET', path: '/agents/:slug/knowledge', output: ['databases', 'models'] },
|
||||
createKnowledge: { method: 'POST', path: '/agents/:slug/knowledge', body: ['operation_id', 'name', 'embedding_model'], output: ['kb_id', 'name', 'description', 'embedding_model'] },
|
||||
knowledgeFiles: { method: 'GET', path: '/agents/:slug/knowledge/:kb_id/files', query: { offset: 'offset' }, output: ['files', 'next_offset'] },
|
||||
@@ -28,10 +51,12 @@ const operations: Record<keyof CloudAgentOperations, OperationSpec> = {
|
||||
keys: { method: 'GET', path: '/applications/:application_id/keys', output: ['keys'] },
|
||||
createKey: { method: 'POST', path: '/applications/:application_id/keys', body: ['operation_id'], output: ['id', 'prefix', 'secret'] },
|
||||
revokeKey: { method: 'DELETE', path: '/applications/:application_id/keys/:key_id', output: ['revoked'] },
|
||||
costs: { method: 'GET', cloud: 'ws', path: '/costs', query: { slug: 'agent_slug' }, output: ['unit', 'items'] },
|
||||
threads: { method: 'GET', path: '/threads', query: { slug: 'slug', offset: 'offset' }, output: ['threads', 'next_offset'] },
|
||||
costs: { method: 'GET', cloud: 'ws', path: '/costs', query: { slug: 'agent_slug', offset: 'offset', started_at: 'started_at', ended_at: 'ended_at', source: 'source', application_id: 'application_id' }, output: ['unit', 'items', 'next_offset', 'summary'] },
|
||||
budget: { method: 'GET', cloud: 'ws', path: '/agents/:slug/budget', output: budget },
|
||||
saveBudget: { method: 'PUT', cloud: 'ws', path: '/agents/:slug/budget', body: ['request_limit_points', 'daily_limit_points'], output: budget },
|
||||
threads: { method: 'GET', path: '/threads', query: { slug: 'slug', offset: 'offset', archived: 'archived' }, output: ['threads', 'next_offset'] },
|
||||
createThread: { method: 'POST', path: '/agents/:slug/threads', body: ['thread_id', 'preview', 'expected_revision'], output: ['thread_id', 'client_thread_id'] },
|
||||
history: { method: 'GET', path: '/threads/:thread_id', query: { offset: 'offset' }, output: ['thread_id', 'messages', 'run', 'next_offset'] },
|
||||
history: { method: 'GET', path: '/threads/:thread_id', query: { offset: 'offset' }, output: ['thread_id', 'messages', 'run', 'queued_requests', 'schedule_proposals', 'next_offset'] },
|
||||
viewed: { method: 'POST', path: '/threads/:thread_id/viewed', body: ['run_id'], output: ['viewed'] },
|
||||
submit: { method: 'POST', path: '/agents/:slug/requests', body: prompt, output: request },
|
||||
preview: { method: 'POST', path: '/agents/:slug/preview', body: prompt, output: request },
|
||||
@@ -59,6 +84,7 @@ 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>;
|
||||
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)
|
||||
@@ -69,12 +95,15 @@ export function operationPlan(value: unknown) {
|
||||
for (const [key, name] of Object.entries(spec.query ?? {})) {
|
||||
const v = args[key];
|
||||
if (v === undefined) continue;
|
||||
if (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' ? !Number.isSafeInteger(v) || Number(v) < 0 : typeof v !== 'string' || v.length > (key === 'path' ? 2048 : 100)) {
|
||||
throw new Error('invalid_query');
|
||||
}
|
||||
query.set(name, String(v));
|
||||
}
|
||||
return {
|
||||
operation,
|
||||
input: Object.fromEntries([...new Set([...routeKeys, ...(spec.body ?? []), ...Object.keys(spec.query ?? {})])]
|
||||
.filter(key => args[key] !== undefined).map(key => [key, args[key]])),
|
||||
...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) => {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
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 {
|
||||
@@ -29,6 +31,10 @@ const MESSAGES: Record<string, string> = {
|
||||
key_revoked: '此凭据已撤销,请新建凭据',
|
||||
insufficient_balance: '创建者的词元点数不足',
|
||||
request_conflict: '该请求已用于其他输入,请开始新的请求',
|
||||
thread_archived: '请先恢复已归档的对话再提交任务',
|
||||
thread_active: '请先处理待回答或审批的任务,并停止运行及排队任务,再归档对话',
|
||||
knowledge_processing: '知识库有文档正在处理,请完成后再维护',
|
||||
agent_archived: '请先从归档恢复智能体,再启用',
|
||||
attachment_too_large: '附件最大支持 5 MB',
|
||||
download_failed: '文件保存失败,请重试',
|
||||
};
|
||||
@@ -75,6 +81,7 @@ function projectDraft(value: unknown): CloudAgentDraft {
|
||||
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'); }
|
||||
}
|
||||
|
||||
@@ -114,7 +121,8 @@ export class CloudAgentsModule {
|
||||
private activityPolling = false;
|
||||
private activitySeen: Map<string, string> | null = null;
|
||||
|
||||
constructor(private readonly fetchImpl: (input: string, init?: RequestInit) => Promise<Response> = proxyAwareFetch) {
|
||||
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;
|
||||
@@ -135,9 +143,12 @@ export class CloudAgentsModule {
|
||||
this.unsubscribe();
|
||||
}
|
||||
|
||||
async list(cursor: string | null = null): Promise<CloudAgentPage> {
|
||||
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 body = record(await this.request('/api/makelore/agents' + (cursor ? `?cursor=${cursor}` : ''), 'GET'));
|
||||
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');
|
||||
@@ -160,7 +171,58 @@ export class CloudAgentsModule {
|
||||
name: textField(input.name, 100).trim(),
|
||||
purpose: textField(input.purpose, 2000).trim(),
|
||||
};
|
||||
return projectDraft(await this.request('/api/makelore/agents', 'POST', body));
|
||||
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 id = [operation, input.slug ?? input.application_id ?? input.run_id ?? '', input.request_id ?? input.operation_id].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 };
|
||||
}
|
||||
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> {
|
||||
@@ -213,7 +275,9 @@ export class CloudAgentsModule {
|
||||
let plan;
|
||||
try { plan = operationPlan(value); }
|
||||
catch { throw new CloudAgentsError(422, 'invalid_input'); }
|
||||
const result = await this.request(plan.path, plan.method, plan.body, plan.cloud === 'ws');
|
||||
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());
|
||||
try {
|
||||
const projected = plan.project(result);
|
||||
if (record(value).operation === 'access') {
|
||||
@@ -248,14 +312,23 @@ export class CloudAgentsModule {
|
||||
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}`, 'POST', body));
|
||||
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');
|
||||
|
||||
Reference in New Issue
Block a user