955 lines
29 KiB
TypeScript
955 lines
29 KiB
TypeScript
import axios from 'axios';
|
||
|
||
const API_BASE_URL = 'https://api.hoyidata.com/aura/v1';
|
||
const APP_BASE = (import.meta.env.BASE_URL || '/').replace(/\/$/, '');
|
||
const withAppBase = (path: string) => `${APP_BASE}${path.startsWith('/') ? path : `/${path}`}`;
|
||
const withApiBase = (path: string) => `${API_BASE_URL}${path.startsWith('/') ? path : `/${path}`}`;
|
||
const isMockAuth = () => typeof localStorage !== 'undefined' && localStorage.getItem('mock-auth') === '1';
|
||
|
||
export const api = axios.create({
|
||
baseURL: API_BASE_URL,
|
||
timeout: 90000,
|
||
withCredentials: true // 关键:跨域请求带 cookie
|
||
});
|
||
|
||
// 401 拦截:自动跳登录
|
||
api.interceptors.response.use(
|
||
(r) => r,
|
||
(err) => {
|
||
const isLoginPage = location.pathname === '/login' || location.pathname === withAppBase('/login');
|
||
if (err?.response?.status === 401 && !isLoginPage && !isMockAuth()) {
|
||
const next = encodeURIComponent(location.pathname + location.search);
|
||
location.href = `${withAppBase('/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;
|
||
reasoning?: string | null;
|
||
parentId?: string | null;
|
||
createdAt: number;
|
||
meta?: {
|
||
retrieved?: RetrievedSnippet[];
|
||
toolCalls?: ToolCallTrace[];
|
||
aborted?: boolean;
|
||
reasoning?: string;
|
||
error?: string;
|
||
} | null;
|
||
}
|
||
|
||
export interface BranchInfo {
|
||
total: number;
|
||
activeIndex: number;
|
||
ids: string[];
|
||
}
|
||
|
||
export interface ChatHistoryResp {
|
||
messages: ChatMessage[];
|
||
branches: Record<string, BranchInfo>;
|
||
}
|
||
|
||
export interface AiModel {
|
||
model_id: string;
|
||
model_name: string;
|
||
icon: string;
|
||
model_ratio: number;
|
||
completion_ratio: number;
|
||
}
|
||
|
||
export const ModelAPI = {
|
||
list: () => api.get<{ data: AiModel[] }>('/models').then((r) => r.data.data),
|
||
};
|
||
|
||
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),
|
||
|
||
updateAvatar: (id: string, avatar: string) => api.patch<Agent>(`/agents/${id}/avatar`, { avatar }).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),
|
||
|
||
getKnowledgeStatus: (agentId: string) =>
|
||
api.get<{ files: KnowledgeFile[] }>(`/agents/${agentId}/knowledge/status`).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,
|
||
model?: string,
|
||
imageUrls?: string[]
|
||
) =>
|
||
api
|
||
.post<{ user: ChatMessage; assistant: ChatMessage }>(`/chat/${agentId}/messages`, {
|
||
content,
|
||
sessionId,
|
||
model,
|
||
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 = withApiBase(`/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)
|
||
};
|
||
|
||
// ============== 积分商城 (v1) ==============
|
||
|
||
export interface PointsMallMe {
|
||
points: number;
|
||
level?: string;
|
||
totalSpentUSD?: number;
|
||
}
|
||
|
||
export interface PointsExchangeRequest {
|
||
productId: string;
|
||
recipientName: string;
|
||
phone: string;
|
||
province: string;
|
||
city: string;
|
||
district: string;
|
||
address: string;
|
||
zipCode?: string;
|
||
}
|
||
|
||
export interface PointsExchangeResponse {
|
||
orderId: string;
|
||
pointsDeducted: number;
|
||
remainingPoints: number;
|
||
}
|
||
|
||
export interface PointsMallCategory {
|
||
id: string;
|
||
name: string;
|
||
sort: number;
|
||
}
|
||
|
||
export interface PointsMallAnnouncement {
|
||
id: string;
|
||
title: string;
|
||
content: string;
|
||
linkUrl?: string;
|
||
}
|
||
|
||
export interface PointsMallBanner {
|
||
id: string;
|
||
title: string;
|
||
subtitle?: string;
|
||
imageUrl: string;
|
||
linkUrl?: string;
|
||
}
|
||
|
||
export interface PointsMallPromoEntry {
|
||
id: string;
|
||
title: string;
|
||
subtitle?: string;
|
||
iconUrl?: string;
|
||
linkUrl?: string;
|
||
}
|
||
|
||
export interface PointsMallProduct {
|
||
id: string;
|
||
categoryId: string;
|
||
name: string;
|
||
subtitle?: string;
|
||
coverUrl: string;
|
||
pointsPrice: number;
|
||
stock: number;
|
||
sold: number;
|
||
tags?: string[];
|
||
}
|
||
|
||
export interface PointsMallOverview {
|
||
me: PointsMallMe;
|
||
categories: PointsMallCategory[];
|
||
announcements: PointsMallAnnouncement[];
|
||
banners: PointsMallBanner[];
|
||
promoEntries: PointsMallPromoEntry[];
|
||
}
|
||
|
||
export interface PointsMallProductsResponse {
|
||
page: number;
|
||
pageSize: number;
|
||
total: number;
|
||
items: PointsMallProduct[];
|
||
}
|
||
|
||
export const PointsMallAPI = {
|
||
overview: () => api.get<PointsMallOverview>('/points-mall/overview').then((r) => r.data),
|
||
products: (opts: {
|
||
categoryId?: string;
|
||
q?: string;
|
||
sort?: string;
|
||
page?: number;
|
||
pageSize?: number;
|
||
} = {}) =>
|
||
api
|
||
.get<PointsMallProductsResponse>('/points-mall/products', {
|
||
params: {
|
||
categoryId: opts.categoryId,
|
||
q: opts.q ?? '',
|
||
sort: opts.sort ?? 'popular',
|
||
page: opts.page ?? 1,
|
||
pageSize: opts.pageSize ?? 24
|
||
}
|
||
})
|
||
.then((r) => r.data),
|
||
exchange: (productId: string, shippingInfo: Omit<PointsExchangeRequest, 'productId'>) =>
|
||
api.post<PointsExchangeResponse>('/points-mall/exchange', {
|
||
productId,
|
||
...shippingInfo
|
||
}).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 interface AgentTokenStatsByModel {
|
||
providerKind: string;
|
||
model: string;
|
||
calls: number;
|
||
promptTokens: number;
|
||
completionTokens: number;
|
||
totalTokens: number;
|
||
costUSD: number;
|
||
}
|
||
|
||
export interface AgentTokenStatsDaily {
|
||
day: string;
|
||
calls: number;
|
||
promptTokens: number;
|
||
completionTokens: number;
|
||
totalTokens: number;
|
||
costUSD: number;
|
||
}
|
||
|
||
export interface AgentTokenStats {
|
||
agentId: string;
|
||
days: number;
|
||
promptTokens: number;
|
||
completionTokens: number;
|
||
totalTokens: number;
|
||
costUSD: number;
|
||
byModel: AgentTokenStatsByModel[];
|
||
daily: AgentTokenStatsDaily[];
|
||
}
|
||
|
||
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),
|
||
agentTokens: (id: string, opts: { days?: number; limit?: number } = {}) =>
|
||
api
|
||
.get<AgentTokenStats>(`/stats/agents/${id}/tokens`, {
|
||
params: { days: opts.days ?? 30, limit: opts.limit ?? 10 }
|
||
})
|
||
.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 interface AgentAllowedLLMResponse {
|
||
agentId: string;
|
||
model: string;
|
||
}
|
||
|
||
export const LLMProviderAPI = {
|
||
list: () => api.get<LLMProvider[]>('/llm-providers').then((r) => r.data),
|
||
allowedModels: (agentId: string) =>
|
||
api
|
||
.get<AgentAllowedLLMResponse>('/llm-providers', { params: { agent_id: agentId } })
|
||
.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: any) => void;
|
||
onReasoningDelta?: (text: string) => void;
|
||
onDelta?: (text: string) => void;
|
||
onRetry?: (data: any) => 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;
|
||
model_id?: string;
|
||
temperature?: number;
|
||
topP?: number;
|
||
maxTokens?: number;
|
||
}
|
||
|
||
export async function streamChat(
|
||
agentId: string,
|
||
content: string,
|
||
handlers: StreamEvents,
|
||
signal?: AbortSignal,
|
||
sessionId?: string,
|
||
model?: string,
|
||
modelId?: string,
|
||
imageUrls?: string[]
|
||
) {
|
||
const resp = await fetch(`https://api.hoyidata.com/aura/v1/chat/${agentId}/messages/stream`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream' },
|
||
body: JSON.stringify({
|
||
content,
|
||
sessionId,
|
||
model,
|
||
model_id: modelId,
|
||
imageUrls: 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', Accept: 'text/event-stream' },
|
||
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 = '';
|
||
let hasError = false; // 标记是否已发生错误,避免后续事件继续处理
|
||
|
||
try {
|
||
while (true) {
|
||
const { value, done } = await reader.read();
|
||
if (done || hasError) break;
|
||
|
||
buf += decoder.decode(value, { stream: true });
|
||
buf = buf.replace(/\r\n/g, '\n');
|
||
|
||
let idx;
|
||
while ((idx = buf.indexOf('\n\n')) !== -1 && !hasError) {
|
||
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:')) {
|
||
let part = line.slice(5);
|
||
if (part.startsWith(' ')) part = part.slice(1);
|
||
dataStr += (dataStr ? '\n' : '') + part;
|
||
}
|
||
}
|
||
if (!dataStr) continue;
|
||
let data: any;
|
||
try {
|
||
data = JSON.parse(dataStr);
|
||
} catch {
|
||
continue;
|
||
}
|
||
switch (event) {
|
||
case 'meta':
|
||
h.onMeta?.(data);
|
||
break;
|
||
case 'retry':
|
||
h.onRetry?.(data);
|
||
break;
|
||
case 'reasoning_delta':
|
||
h.onReasoningDelta?.(data.content || '');
|
||
break;
|
||
case 'delta':
|
||
h.onDelta?.(data.content || '');
|
||
break;
|
||
case 'tool_call':
|
||
h.onToolCall?.(data);
|
||
break;
|
||
case 'tool_result':
|
||
h.onToolResult?.(data);
|
||
break;
|
||
case 'done':
|
||
// 只有在没有错误的情况下才处理 done 事件
|
||
if (!hasError) {
|
||
h.onDone?.(data);
|
||
}
|
||
break;
|
||
case 'aborted':
|
||
h.onAborted?.(data);
|
||
break;
|
||
case 'error':
|
||
hasError = true;
|
||
h.onError?.(data.message || 'stream error');
|
||
// 发生错误后立即停止读取
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
} catch (e: any) {
|
||
if (signal?.aborted || e?.name === 'AbortError') {
|
||
// 静默:本地已 abort
|
||
return;
|
||
}
|
||
h.onError?.(e?.message ?? String(e));
|
||
} finally {
|
||
// 确保 reader 被释放
|
||
reader.cancel().catch(() => {});
|
||
}
|
||
}
|
||
|
||
// ============== 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(用 EventSource,cookie 由浏览器自动带)
|
||
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 = withApiBase(`/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();
|
||
}
|