feat(agents): 新增云端智能体入口与草稿编辑
This commit is contained in:
199
electron/services/cloud-agents.ts
Normal file
199
electron/services/cloud-agents.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import type { CloudAgentDraft, CloudAgentPage, CreateCloudAgent, SaveCloudAgentDraft } 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: '请检查名称、用途和配置内容',
|
||||
};
|
||||
|
||||
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),
|
||||
}; } catch { throw new CloudAgentsError(502, 'cloud_service_unavailable'); }
|
||||
}
|
||||
|
||||
type Session = {
|
||||
binding: WorksSquareAccountBinding;
|
||||
accessToken: string;
|
||||
apiBaseUrl: string;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
/** Main owns both clouds' credentials; public methods return only Agent drafts. */
|
||||
export class CloudAgentsModule {
|
||||
private cached: Session | null = null;
|
||||
private readonly unsubscribe: () => void;
|
||||
|
||||
constructor(private readonly fetchImpl: (input: string, init?: RequestInit) => Promise<Response> = proxyAwareFetch) {
|
||||
this.unsubscribe = subscribeWorksSquareSession(() => {
|
||||
if (this.cached && !isCurrentWorksSquareAccountBinding(this.cached.binding)) this.cached = null;
|
||||
});
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.cached = null;
|
||||
this.unsubscribe();
|
||||
}
|
||||
|
||||
async list(cursor: string | null = null): 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'));
|
||||
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 projectDraft(await this.request('/api/makelore/agents', 'POST', body));
|
||||
}
|
||||
|
||||
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),
|
||||
};
|
||||
return projectDraft(await this.request(`/api/makelore/agents/${slug(agentSlug)}/draft`, 'PATCH', body));
|
||||
}
|
||||
|
||||
private async request(path: string, method: string, body?: unknown): Promise<unknown> {
|
||||
const binding = getWorksSquareAccountBinding();
|
||||
if (!binding) throw new CloudAgentsError(401, 'session_expired');
|
||||
try {
|
||||
return await runWithDeadline(async (signal) => {
|
||||
const session = await this.session(binding, signal);
|
||||
this.requireCurrent(binding);
|
||||
const result = await this.fetchJson(session.apiBaseUrl + path, {
|
||||
method, signal, redirect: 'error',
|
||||
headers: { Authorization: `Bearer ${session.accessToken}`, 'Content-Type': 'application/json' },
|
||||
...(body === undefined ? {} : { 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;
|
||||
if (error.status === 422) throw new CloudAgentsError(502, 'cloud_service_unavailable');
|
||||
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-agent-drafts' || 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 : 'cloud_service_unavailable',
|
||||
);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user