aura-web/src/api.ts

755 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import axios from 'axios';
const AURA_API_BASE = 'https://api.redhare.cc/aura/v1';
const isMockAuth = () => typeof localStorage !== 'undefined' && localStorage.getItem('mock-auth') === '1';
export const api = axios.create({
baseURL: 'https://api.hoyidata.com/aura/v1',
timeout: 90000,
withCredentials: true // 关键:跨域请求带 cookie
});
// 401 拦截:自动跳登录
api.interceptors.response.use(
(r) => r,
(err) => {
if (err?.response?.status === 401 && !location.pathname.startsWith('/login') && !isMockAuth()) {
const next = encodeURIComponent(location.pathname + location.search);
location.href = `/login?next=${next}`;
}
return Promise.reject(err);
}
);
export type KnowledgeStatus = 'pending' | 'indexing' | 'ready' | 'failed';
export interface KnowledgeFile {
id: string;
originalName: string;
filename: string;
size: number;
mime: string;
status: KnowledgeStatus;
error?: string;
chunkCount: number;
createdAt: number;
}
export type SkillType = 'prompt' | 'http' | 'js';
export interface SkillBrief {
id: string;
name: string;
filename: string;
description?: string;
type: SkillType;
enabled: number;
createdAt: number;
}
export interface SkillDetail extends SkillBrief {
content: string;
parameters: string;
handler: string;
config: string;
}
export interface Agent {
id: string;
name: string;
description: string;
avatar: string;
prompt: string;
model: string;
temperature: number;
owner_id?: string | null;
team_id?: string | null;
visibility?: 'private' | 'team' | 'public';
fork_count?: number;
forked_from?: string | null;
created_at: number;
updated_at: number;
knowledge?: KnowledgeFile[];
skills?: SkillBrief[];
_access?: 'owner' | 'team' | 'view' | 'none';
}
export interface RetrievedSnippet {
fileName: string;
chunkIndex: number;
score: number;
preview: string;
}
export interface ToolCallTrace {
name: string;
args: any;
result: any;
}
export interface ChatMessage {
id: string;
role: 'user' | 'assistant';
content: string;
parentId?: string | null;
createdAt: number;
meta?: {
retrieved?: RetrievedSnippet[];
toolCalls?: ToolCallTrace[];
aborted?: boolean;
} | null;
}
export interface BranchInfo {
total: number;
activeIndex: number;
ids: string[];
}
export interface ChatHistoryResp {
messages: ChatMessage[];
branches: Record<string, BranchInfo>;
}
export const AgentAPI = {
list: () => api.get<Agent[]>('/agents').then((r) => r.data),
detail: (id: string) => api.get<Agent>(`/agents/${id}`).then((r) => r.data),
create: (payload: Partial<Agent>) => api.post<Agent>('/agents', payload).then((r) => r.data),
update: (id: string, payload: Partial<Agent>) => api.put<Agent>(`/agents/${id}`, payload).then((r) => r.data),
remove: (id: string) => api.delete(`/agents/${id}`).then((r) => r.data),
uploadKnowledge: (id: string, files: File[]) => {
const fd = new FormData();
files.forEach((f) => fd.append('files', f));
return api.post(`/agents/${id}/knowledge`, fd).then((r) => r.data);
},
reindexKnowledge: (agentId: string, fileId: string) =>
api.post(`/agents/${agentId}/knowledge/${fileId}/reindex`).then((r) => r.data),
deleteKnowledge: (agentId: string, fileId: string) =>
api.delete(`/agents/${agentId}/knowledge/${fileId}`).then((r) => r.data),
uploadSkills: (id: string, files: File[]) => {
const fd = new FormData();
files.forEach((f) => fd.append('files', f));
return api.post(`/agents/${id}/skills`, fd).then((r) => r.data);
},
createSkill: (id: string, payload: { name: string; content: string }) =>
api.post(`/agents/${id}/skills/manual`, payload).then((r) => r.data),
getSkill: (agentId: string, skillId: string) =>
api.get<SkillDetail>(`/agents/${agentId}/skills/${skillId}`).then((r) => r.data),
updateSkill: (agentId: string, skillId: string, payload: { content?: string; enabled?: boolean }) =>
api.put(`/agents/${agentId}/skills/${skillId}`, payload).then((r) => r.data),
deleteSkill: (agentId: string, skillId: string) =>
api.delete(`/agents/${agentId}/skills/${skillId}`).then((r) => r.data)
};
export const ChatAPI = {
history: (agentId: string, sessionId?: string) =>
api
.get<ChatHistoryResp>(`/chat/${agentId}/messages`, { params: sessionId ? { sessionId } : {} })
.then((r) => r.data),
send: (
agentId: string,
content: string,
sessionId?: string,
overrides?: ModelOverrides,
attachmentsText?: string,
imageUrls?: string[]
) =>
api
.post<{ user: ChatMessage; assistant: ChatMessage }>(`/chat/${agentId}/messages`, {
content,
sessionId,
overrides,
attachmentsText,
imageUrls
})
.then((r) => r.data),
clear: (agentId: string, sessionId?: string) =>
api
.delete(`/chat/${agentId}/messages`, { params: sessionId ? { sessionId } : {} })
.then((r) => r.data),
/** 切换分支:把 user 消息下的某条 assistant 兄弟设为激活 */
switchBranch: (agentId: string, userMsgId: string, branchId: string) =>
api
.post(`/chat/${agentId}/messages/${userMsgId}/switch-branch`, { branchId })
.then((r) => r.data)
};
export interface ChatAttachment {
name: string;
size: number;
mime: string;
text: string;
truncated: boolean;
}
export const ChatAttachmentsAPI = {
upload: (files: File[]) => {
const fd = new FormData();
files.forEach((f) => fd.append('files', f));
return api
.post<{ files: ChatAttachment[] }>('/chat/attachments', fd)
.then((r) => r.data);
}
};
// ============== MCP ==============
export interface McpServer {
id: string;
name: string;
transport: 'stdio' | 'sse' | 'http';
command: string;
args: string[];
env: Record<string, string>;
url: string;
enabled: boolean;
createdAt: number;
}
export interface McpStatus {
id: string;
name: string;
error?: string;
toolCount: number;
resourceCount: number;
promptCount: number;
capabilities: { tools: boolean; resources: boolean; prompts: boolean };
tools: { name: string; description?: string }[];
resources: { uri: string; name?: string; description?: string; mimeType?: string }[];
prompts: { name: string; description?: string; arguments?: any[] }[];
}
export const McpAPI = {
list: (agentId: string) => api.get<McpServer[]>(`/agents/${agentId}/mcp-servers`).then((r) => r.data),
status: (agentId: string) => api.get<McpStatus[]>(`/agents/${agentId}/mcp-status`).then((r) => r.data),
create: (agentId: string, payload: Partial<McpServer>) =>
api.post(`/agents/${agentId}/mcp-servers`, payload).then((r) => r.data),
update: (agentId: string, serverId: string, payload: Partial<McpServer>) =>
api.put(`/agents/${agentId}/mcp-servers/${serverId}`, payload).then((r) => r.data),
remove: (agentId: string, serverId: string) =>
api.delete(`/agents/${agentId}/mcp-servers/${serverId}`).then((r) => r.data),
restart: (agentId: string) => api.post(`/agents/${agentId}/mcp-restart`).then((r) => r.data),
importJSON: (agentId: string, config: any, replace = false) =>
api.post(`/agents/${agentId}/mcp-import`, { config, replace }).then((r) => r.data),
readResource: (agentId: string, serverId: string, uri: string) =>
api.post(`/agents/${agentId}/mcp-read-resource`, { serverId, uri }).then((r) => r.data),
getPrompt: (agentId: string, serverId: string, name: string, args: Record<string, any> = {}) =>
api.post(`/agents/${agentId}/mcp-get-prompt`, { serverId, name, args }).then((r) => r.data)
};
// ============== 会话 ==============
export interface ChatSession {
id: string;
agentId: string;
title: string;
archived?: boolean;
createdAt: number;
updatedAt: number;
messageCount?: number;
lastPreview?: string;
lastAt?: number;
}
export interface SearchHit {
id: string;
sessionId: string;
sessionTitle: string;
sessionArchived: boolean;
role: 'user' | 'assistant';
content: string;
snippet: string;
createdAt: number;
}
export interface SearchResult {
sessions: { id: string; title: string; archived: boolean; created_at: number; updated_at: number }[];
messages: SearchHit[];
}
export const SessionAPI = {
list: (agentId: string, archived: '0' | '1' | 'all' = '0') =>
api.get<ChatSession[]>(`/agents/${agentId}/sessions`, { params: { archived } }).then((r) => r.data),
create: (agentId: string, title?: string) =>
api.post<ChatSession>(`/agents/${agentId}/sessions`, { title }).then((r) => r.data),
rename: (agentId: string, sessionId: string, title: string) =>
api.put(`/agents/${agentId}/sessions/${sessionId}`, { title }).then((r) => r.data),
remove: (agentId: string, sessionId: string) =>
api.delete(`/agents/${agentId}/sessions/${sessionId}`).then((r) => r.data),
archive: (agentId: string, sessionId: string, archived: boolean) =>
api.post(`/agents/${agentId}/sessions/${sessionId}/archive`, { archived }).then((r) => r.data),
share: (agentId: string, sessionId: string, ttlHours = 0) =>
api
.post<{ token: string; expiresAt?: number }>(`/agents/${agentId}/sessions/${sessionId}/share`, { ttlHours })
.then((r) => r.data),
revokeShare: (agentId: string, sessionId: string) =>
api.delete(`/agents/${agentId}/sessions/${sessionId}/share`).then((r) => r.data),
search: (agentId: string, q: string, opts: { includeArchived?: boolean; limit?: number } = {}) =>
api
.get<SearchResult>(`/agents/${agentId}/sessions/search`, {
params: {
q,
includeArchived: opts.includeArchived ? '1' : '0',
limit: opts.limit ?? 30
}
})
.then((r) => r.data),
/** 导出会话为文件并触发浏览器下载 */
exportSession: (agentId: string, sessionId: string, format: 'md' | 'json' = 'md') => {
const url = `/api/agents/${agentId}/sessions/${sessionId}/export?format=${format}`;
const a = document.createElement('a');
a.href = url;
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
a.remove();
}
};
// 公开会话访问(不需要登录)
export const SharedAPI = {
get: (token: string) =>
api.get<{
agent: { name: string; description: string };
session: { id: string; title: string; createdAt: number; updatedAt: number };
messages: { id: string; role: 'user' | 'assistant'; content: string; createdAt: number }[];
}>(`/shared/${token}`).then((r) => r.data)
};
// 图床
export interface UploadedImage {
url: string;
name: string;
size: number;
mime: string;
}
export const ImageAPI = {
upload: (files: File[]) => {
const fd = new FormData();
files.forEach((f) => fd.append('file', f));
return api.post<{ files: UploadedImage[] }>('/uploads/image', fd).then((r) => r.data);
}
};
// ============== 全局搜索v0.7 命令面板) ==============
export interface GlobalSearchResult {
agents: { id: string; name: string; description: string; model: string }[];
sessions: { id: string; agentId: string; agentName: string; title: string; updatedAt: number; archived: boolean }[];
messages: {
id: string;
sessionId: string;
agentId: string;
agentName: string;
sessionTitle: string;
role: 'user' | 'assistant';
snippet: string;
createdAt: number;
}[];
}
export const SearchAPI = {
global: (q: string, limit = 8) =>
api.get<GlobalSearchResult>('/search', { params: { q, limit } }).then((r) => r.data)
};
// ============== 鉴权 / 用户 / 团队 / 广场 ==============
export interface AuthUser {
id: string;
email: string;
name: string;
role: 'admin' | 'user';
}
export const AuthAPI = {
me: () => api.get<AuthUser>('/auth/me').then((r) => r.data),
verify: async (email: string, password: string) => {
// 根据要求,这里直接请求真实地址,未启动时 mock 返回 true
try {
const res = await axios.post('https://api.hoyidata.com/aura/v1/urser', { email, password }, { timeout: 3000 });
return res.data;
} catch (e) {
console.warn('Backend /urser not available, fallback to mock true', e);
return true;
}
},
login: (email: string, password: string) =>
api.post<AuthUser>('/auth/login', { email, password }).then((r) => r.data),
register: (payload: { email: string; password: string; name: string; inviteCode?: string }) =>
api.post<AuthUser>('/auth/register', payload).then((r) => r.data),
logout: () => api.post('/auth/logout').then((r) => r.data),
listInvites: () => api.get('/auth/invites').then((r) => r.data),
createInvite: (payload: { email?: string; teamId?: string; role?: string; ttlHours?: number }) =>
api.post('/auth/invites', payload).then((r) => r.data),
deleteInvite: (code: string) => api.delete(`/auth/invites/${code}`).then((r) => r.data)
};
export interface Team {
id: string;
name: string;
ownerId: string;
createdAt: number;
myRole?: 'owner' | 'admin' | 'member';
members?: { id: string; email: string; name: string; role: string; joinedAt: number }[];
agentCount?: number;
}
export const TeamAPI = {
list: () => api.get<Team[]>('/teams').then((r) => r.data),
detail: (id: string) => api.get<Team>(`/teams/${id}`).then((r) => r.data),
create: (name: string) => api.post<Team>('/teams', { name }).then((r) => r.data),
rename: (id: string, name: string) => api.put(`/teams/${id}`, { name }).then((r) => r.data),
remove: (id: string) => api.delete(`/teams/${id}`).then((r) => r.data),
removeMember: (id: string, userId: string) =>
api.delete(`/teams/${id}/members/${userId}`).then((r) => r.data)
};
export interface MarketplaceAgent extends Agent {
ownerName?: string;
fork_count: number;
skillCount: number;
kbCount: number;
}
export const MarketplaceAPI = {
list: () => api.get<MarketplaceAgent[]>('/agents/_/marketplace').then((r) => r.data),
detail: (id: string) => api.get(`/agents/_/marketplace/${id}`).then((r) => r.data),
fork: (id: string) => api.post(`/agents/_/marketplace/${id}/fork`).then((r) => r.data)
};
// ============== Prompt 模板库 (v0.8 P1) ==============
export interface PromptTemplate {
id: string;
ownerId: string;
ownerName?: string;
title: string;
body: string;
category: string;
variables: string;
visibility: 'private' | 'public';
useCount: number;
createdAt: number;
updatedAt: number;
}
export const PromptTemplateAPI = {
list: (opts: { scope?: 'mine' | 'public' | 'all'; q?: string } = {}) =>
api
.get<PromptTemplate[]>('/prompt-templates', {
params: { scope: opts.scope ?? 'all', q: opts.q ?? '' }
})
.then((r) => r.data),
create: (payload: Partial<PromptTemplate>) =>
api.post<{ id: string }>('/prompt-templates', payload).then((r) => r.data),
update: (id: string, payload: Partial<PromptTemplate>) =>
api.put(`/prompt-templates/${id}`, payload).then((r) => r.data),
remove: (id: string) => api.delete(`/prompt-templates/${id}`).then((r) => r.data),
use: (id: string) => api.post(`/prompt-templates/${id}/use`).then((r) => r.data)
};
// ============== 调用统计 (v0.8 P1) ==============
export interface StatsOverview {
agentCount: number;
sessionCount: number;
messageCount: number;
daily: { day: string; total: number; user: number; assistant: number }[];
topAgents: { id: string; name: string; messageCount: number }[];
}
export interface AgentStats {
sessionCount: number;
messageCount: number;
avgMessageLen: number;
assistantWithToolCalls: number;
knowledgeFiles: number;
skillCount: number;
daily: { day: string; count: number }[];
}
export const StatsAPI = {
overview: () => api.get<StatsOverview>('/stats/overview').then((r) => r.data),
agent: (id: string) => api.get<AgentStats>(`/stats/agents/${id}`).then((r) => r.data)
};
// ============== 流式(手写 SSE不用 EventSource因为它不能 POST ==============
// ============== LLM 提供商 (v0.9) ==============
export type LLMKind = 'openai' | 'openai-compatible' | 'anthropic' | 'ollama' | 'tencent-tokenplan';
export interface LLMProvider {
id: string;
name: string;
kind: LLMKind;
baseUrl: string;
apiKeyMasked: string;
hasApiKey: boolean;
models: string[];
defaultModel: string;
enabled: boolean;
isDefault: boolean;
createdAt: number;
updatedAt: number;
}
export const LLMProviderAPI = {
list: () => api.get<LLMProvider[]>('/llm-providers').then((r) => r.data),
create: (payload: Partial<LLMProvider> & { apiKey?: string }) =>
api.post<{ id: string }>('/llm-providers', payload).then((r) => r.data),
update: (id: string, payload: Partial<LLMProvider> & { apiKey?: string }) =>
api.put(`/llm-providers/${id}`, payload).then((r) => r.data),
remove: (id: string) => api.delete(`/llm-providers/${id}`).then((r) => r.data),
test: (id: string, model?: string) =>
api.post<{ ok: boolean; reply?: string; error?: string; usage?: any; model?: string }>(
`/llm-providers/${id}/test`,
{ model }
).then((r) => r.data),
setDefault: (id: string) => api.post(`/llm-providers/${id}/default`).then((r) => r.data)
};
export interface StreamEvents {
onMeta?: (data: { userMsgId: string; retrieved: RetrievedSnippet[]; toolsAvailable: number; mcpToolsCount: number }) => void;
onDelta?: (text: string) => void;
onToolCall?: (data: { id: string; name: string; args: any }) => void;
onToolResult?: (data: { id: string; name: string; result: any }) => void;
onDone?: (data: { user: ChatMessage; assistant: ChatMessage }) => void;
onAborted?: (data: { assistant: ChatMessage }) => void;
onError?: (msg: string) => void;
}
export interface ModelOverrides {
model?: string;
temperature?: number;
topP?: number;
maxTokens?: number;
}
export async function streamChat(
agentId: string,
content: string,
handlers: StreamEvents,
signal?: AbortSignal,
sessionId?: string,
overrides?: ModelOverrides,
attachmentsText?: string,
imageUrls?: string[]
) {
const resp = await fetch(`https://api.hoyidata.com/aura/v1/chat/${agentId}/messages/stream`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content,
sessionId,
overrides,
attachmentsText,
imageUrls
}),
signal,
credentials: 'include'
});
return await consumeSSE(resp, handlers, signal);
}
/** 重新生成(开新分支);行为同 streamChat */
export async function regenerateMessage(
agentId: string,
messageId: string,
handlers: StreamEvents,
signal?: AbortSignal,
overrides?: ModelOverrides,
attachmentsText?: string
) {
const resp = await fetch(`https://api.hoyidata.com/aura/v1/chat/${agentId}/messages/${messageId}/regenerate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ overrides, attachmentsText }),
signal,
credentials: 'include'
});
return await consumeSSE(resp, handlers, signal);
}
async function consumeSSE(resp: Response, h: StreamEvents, signal?: AbortSignal) {
if (!resp.ok || !resp.body) {
const txt = await resp.text().catch(() => '');
h.onError?.(`HTTP ${resp.status}: ${txt}`);
return;
}
const reader = resp.body.getReader();
const decoder = new TextDecoder('utf-8');
let buf = '';
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
let idx;
while ((idx = buf.indexOf('\n\n')) !== -1) {
const raw = buf.slice(0, idx);
buf = buf.slice(idx + 2);
if (!raw.trim() || raw.startsWith(':')) continue;
let event = 'message';
let dataStr = '';
for (const line of raw.split('\n')) {
if (line.startsWith('event:')) event = line.slice(6).trim();
else if (line.startsWith('data:')) dataStr += line.slice(5).trim();
}
if (!dataStr) continue;
let data: any;
try {
data = JSON.parse(dataStr);
} catch {
continue;
}
switch (event) {
case 'meta':
h.onMeta?.(data);
break;
case 'delta':
h.onDelta?.(data.content || '');
break;
case 'tool_call':
h.onToolCall?.(data);
break;
case 'tool_result':
h.onToolResult?.(data);
break;
case 'done':
h.onDone?.(data);
break;
case 'aborted':
h.onAborted?.(data);
break;
case 'error':
h.onError?.(data.message || 'stream error');
break;
}
}
}
} catch (e: any) {
if (signal?.aborted || e?.name === 'AbortError') {
// 静默:本地已 abort
return;
}
h.onError?.(e?.message ?? String(e));
}
}
// ============== Workflow (v1.1) ==============
export type WorkflowNodeType = 'agent' | 'skill' | 'http' | 'transform' | 'branch';
export interface WorkflowNode {
id: string;
type: WorkflowNodeType;
name: string;
config: Record<string, any>;
next?: string;
elseNext?: string;
}
export interface WorkflowGraph {
entry: string;
nodes: WorkflowNode[];
variables?: Record<string, any>;
}
export interface Workflow {
id: string;
name: string;
description: string;
graph: WorkflowGraph;
scheduleCron: string;
scheduleEnabled: boolean;
enabled: boolean;
lastRunAt: number;
runCount: number;
createdAt: number;
updatedAt: number;
}
export interface WorkflowRun {
id: string;
workflowId: string;
trigger: 'manual' | 'cron' | 'api';
status: 'running' | 'success' | 'failed' | 'aborted';
error: string;
startedAt: number;
finishedAt: number;
durationMs: number;
}
export interface WorkflowRunStep {
id: string;
nodeId: string;
nodeType: string;
stepIndex: number;
status: 'running' | 'success' | 'failed' | 'skipped';
input: any;
output: any;
error: string;
startedAt: number;
finishedAt: number;
durationMs: number;
}
export interface WorkflowRunDetail extends WorkflowRun {
input: any;
output: any;
steps: WorkflowRunStep[];
}
export const WorkflowAPI = {
list: () => api.get<Workflow[]>('/workflows').then((r) => r.data),
get: (id: string) => api.get<Workflow>(`/workflows/${id}`).then((r) => r.data),
create: (payload: Partial<Workflow>) =>
api.post<{ id: string }>('/workflows', payload).then((r) => r.data),
update: (id: string, payload: Partial<Workflow>) =>
api.put(`/workflows/${id}`, payload).then((r) => r.data),
remove: (id: string) => api.delete(`/workflows/${id}`).then((r) => r.data),
run: (id: string, input?: Record<string, any>) =>
api.post<{ runId: string }>(`/workflows/${id}/run`, { input }).then((r) => r.data),
listRuns: (id: string, limit = 30) =>
api.get<WorkflowRun[]>(`/workflows/${id}/runs`, { params: { limit } }).then((r) => r.data),
getRun: (runId: string) =>
api.get<WorkflowRunDetail>(`/workflows/runs/${runId}`).then((r) => r.data)
};
// SSE 流式执行 workflow用 EventSourcecookie 由浏览器自动带)
export function streamWorkflowRun(
workflowId: string,
input: Record<string, any> | undefined,
handlers: {
onReady?: (data: any) => void;
onStepStart?: (data: any) => void;
onStepFinish?: (data: any) => void;
onRunFinish?: (data: any) => void;
onError?: (msg: string) => void;
}
): () => void {
const qs = input ? `?input=${encodeURIComponent(JSON.stringify(input))}` : '';
const url = `/api/workflows/${workflowId}/run-stream${qs}`;
const es = new EventSource(url, { withCredentials: true } as any);
es.addEventListener('ready', (e: any) => handlers.onReady?.(JSON.parse(e.data)));
es.addEventListener('step_start', (e: any) => handlers.onStepStart?.(JSON.parse(e.data)));
es.addEventListener('step_finish', (e: any) => handlers.onStepFinish?.(JSON.parse(e.data)));
es.addEventListener('run_finish', (e: any) => {
handlers.onRunFinish?.(JSON.parse(e.data));
es.close();
});
es.addEventListener('error', () => {
if (es.readyState === EventSource.CLOSED) return;
handlers.onError?.('stream error');
es.close();
});
return () => es.close();
}