200 lines
8.3 KiB
TypeScript
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;
|
|
}
|
|
}
|