feat(agents): 新增云端智能体入口与草稿编辑
This commit is contained in:
@@ -4,6 +4,7 @@ import { handleAiProxyRoutes } from './routes/ai-proxy';
|
||||
import { handleAiHardwareRoutes } from './routes/ai-hardware';
|
||||
import { handleAppRoutes } from './routes/app';
|
||||
import { handleAuthRoutes } from './routes/auth';
|
||||
import { handleCloudAgentsRoutes } from './routes/cloud-agents';
|
||||
import { handleImageWorkspaceRoutes } from './routes/image-workspace';
|
||||
import { handleImagePromptMuseumRoutes } from './routes/image-prompt-museum';
|
||||
import { handleDataServiceRoutes } from './routes/data-service';
|
||||
@@ -41,6 +42,7 @@ export const hostApiRouteHandlers: readonly HostApiRouteHandler[] = [
|
||||
handleAiHardwareRoutes,
|
||||
handleAppRoutes,
|
||||
handleAuthRoutes,
|
||||
handleCloudAgentsRoutes,
|
||||
handleImageWorkspaceRoutes,
|
||||
handleImagePromptMuseumRoutes,
|
||||
handleDataServiceRoutes,
|
||||
|
||||
34
electron/api/routes/cloud-agents.ts
Normal file
34
electron/api/routes/cloud-agents.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import { CLOUD_AGENTS_PATH } from '../../../shared/cloud-agents';
|
||||
import { CloudAgentsError, CloudAgentsModule } from '../../services/cloud-agents';
|
||||
import { parseJsonBody, sendJson } from '../route-utils';
|
||||
|
||||
const cloudAgents = new CloudAgentsModule();
|
||||
|
||||
export async function handleCloudAgentsRoutes(req: IncomingMessage, res: ServerResponse, url: URL): Promise<boolean> {
|
||||
if (!url.pathname.startsWith(CLOUD_AGENTS_PATH + '/')) return false;
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
const path = url.pathname.slice(CLOUD_AGENTS_PATH.length);
|
||||
try {
|
||||
let data: unknown;
|
||||
if ((path === '/bootstrap' || path === '/agents') && req.method === 'GET') {
|
||||
data = await cloudAgents.list(url.searchParams.get('cursor'));
|
||||
} else if (path === '/agents' && req.method === 'POST') {
|
||||
data = await cloudAgents.create(await parseJsonBody(req));
|
||||
} else {
|
||||
const match = /^\/agents\/(ml-[a-f0-9]{32})(\/draft)?$/.exec(path);
|
||||
if (match && !match[2] && req.method === 'GET') data = await cloudAgents.get(match[1]);
|
||||
else if (match && match[2] && req.method === 'PATCH') data = await cloudAgents.save(match[1], await parseJsonBody(req));
|
||||
else {
|
||||
sendJson(res, 404, { error: '智能体接口不存在' });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
sendJson(res, 200, data);
|
||||
} catch (error) {
|
||||
const failure = error instanceof CloudAgentsError ? error
|
||||
: new CloudAgentsError(error instanceof SyntaxError ? 400 : 502, error instanceof SyntaxError ? 'invalid_input' : 'cloud_service_unavailable');
|
||||
sendJson(res, failure.status, { success: false, error: failure.message, code: failure.code });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
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