Files
makelore/electron/services/cloud-agents.ts

200 lines
8.3 KiB
TypeScript

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;
}
}