diff --git a/src/api.ts b/src/api.ts index c75fb1f..1c7b7a0 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,969 +1 @@ -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; -} - -export interface AiModel { - 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('/agents').then((r) => r.data), - detail: (id: string) => api.get(`/agents/${id}`).then((r) => r.data), - create: (payload: Partial) => api.post('/agents', payload).then((r) => r.data), - update: (id: string, payload: Partial) => api.put(`/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(`/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(`/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(`/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; - 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(`/agents/${agentId}/mcp-servers`).then((r) => r.data), - status: (agentId: string) => api.get(`/agents/${agentId}/mcp-status`).then((r) => r.data), - create: (agentId: string, payload: Partial) => - api.post(`/agents/${agentId}/mcp-servers`, payload).then((r) => r.data), - update: (agentId: string, serverId: string, payload: Partial) => - 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 = {}) => - 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(`/agents/${agentId}/sessions`, { params: { archived } }).then((r) => r.data), - create: (agentId: string, title?: string) => - api.post(`/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(`/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('/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('/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('/auth/login', { email, password }).then((r) => r.data), - register: (payload: { email: string; password: string; name: string; inviteCode?: string }) => - api.post('/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('/teams').then((r) => r.data), - detail: (id: string) => api.get(`/teams/${id}`).then((r) => r.data), - create: (name: string) => api.post('/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('/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('/prompt-templates', { - params: { scope: opts.scope ?? 'all', q: opts.q ?? '' } - }) - .then((r) => r.data), - create: (payload: Partial) => - api.post<{ id: string }>('/prompt-templates', payload).then((r) => r.data), - update: (id: string, payload: Partial) => - 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 PointsExchangePrepareResponse { - orderId: string; - pointsDeducted: number; - remainingPoints: number; - expiresAt?: string; -} - -export type PointsExchangeShippingRequest = Omit; - -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('/points-mall/overview').then((r) => r.data), - products: (opts: { - categoryId?: string; - q?: string; - sort?: string; - page?: number; - pageSize?: number; - } = {}) => - api - .get('/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), - exchangePrepare: (productId: string) => - api.post('/points-mall/exchange/prepare', { productId }).then((r) => r.data), - exchangeSubmitShipping: (orderId: string, shippingInfo: PointsExchangeShippingRequest) => - api.post(`/points-mall/exchange/${orderId}/shipping`, shippingInfo).then((r) => r.data), - exchange: (productId: string, shippingInfo: Omit) => - api - .post('/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('/stats/overview').then((r) => r.data), - agent: (id: string) => api.get(`/stats/agents/${id}`).then((r) => r.data), - agentTokens: (id: string, opts: { days?: number; limit?: number } = {}) => - api - .get(`/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('/llm-providers').then((r) => r.data), - allowedModels: (agentId: string) => - api - .get('/llm-providers', { params: { agent_id: agentId } }) - .then((r) => r.data), - create: (payload: Partial & { apiKey?: string }) => - api.post<{ id: string }>('/llm-providers', payload).then((r) => r.data), - update: (id: string, payload: Partial & { 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; - next?: string; - elseNext?: string; -} - -export interface WorkflowGraph { - entry: string; - nodes: WorkflowNode[]; - variables?: Record; -} - -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('/workflows').then((r) => r.data), - get: (id: string) => api.get(`/workflows/${id}`).then((r) => r.data), - create: (payload: Partial) => - api.post<{ id: string }>('/workflows', payload).then((r) => r.data), - update: (id: string, payload: Partial) => - 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) => - api.post<{ runId: string }>(`/workflows/${id}/run`, { input }).then((r) => r.data), - listRuns: (id: string, limit = 30) => - api.get(`/workflows/${id}/runs`, { params: { limit } }).then((r) => r.data), - getRun: (runId: string) => - api.get(`/workflows/runs/${runId}`).then((r) => r.data) -}; - -// SSE 流式执行 workflow(用 EventSource,cookie 由浏览器自动带) -export function streamWorkflowRun( - workflowId: string, - input: Record | 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(); -} +export * from './api/index'; diff --git a/src/api/agents.ts b/src/api/agents.ts new file mode 100644 index 0000000..fc278b0 --- /dev/null +++ b/src/api/agents.ts @@ -0,0 +1,87 @@ +import { api } from './http'; + +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 const AgentAPI = { + list: () => api.get('/agents').then((r) => r.data), + detail: (id: string) => api.get(`/agents/${id}`).then((r) => r.data), + create: (payload: Partial) => api.post('/agents', payload).then((r) => r.data), + update: (id: string, payload: Partial) => api.put(`/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(`/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(`/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) +}; + diff --git a/src/api/auth.ts b/src/api/auth.ts new file mode 100644 index 0000000..6306ba6 --- /dev/null +++ b/src/api/auth.ts @@ -0,0 +1,30 @@ +import axios from 'axios'; +import { api } from './http'; + +export interface AuthUser { + id: string; + email: string; + name: string; + role: 'admin' | 'user'; +} + +export const AuthAPI = { + me: () => api.get('/auth/me').then((r) => r.data), + verify: async (email: string, password: string) => { + 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('/auth/login', { email, password }).then((r) => r.data), + register: (payload: { email: string; password: string; name: string; inviteCode?: string }) => + api.post('/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) +}; + diff --git a/src/api/chat.ts b/src/api/chat.ts new file mode 100644 index 0000000..85a877b --- /dev/null +++ b/src/api/chat.ts @@ -0,0 +1,76 @@ +import { api } from './http'; + +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; +} + +export const ChatAPI = { + history: (agentId: string, sessionId?: string) => + api.get(`/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), + 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); + } +}; + diff --git a/src/api/http.ts b/src/api/http.ts new file mode 100644 index 0000000..464e9a3 --- /dev/null +++ b/src/api/http.ts @@ -0,0 +1,26 @@ +import axios from 'axios'; + +export const API_BASE_URL = 'https://api.hoyidata.com/aura/v1'; +const APP_BASE = (import.meta.env.BASE_URL || '/').replace(/\/$/, ''); +export const withAppBase = (path: string) => `${APP_BASE}${path.startsWith('/') ? path : `/${path}`}`; +export 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 +}); + +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); + } +); + diff --git a/src/api/index.ts b/src/api/index.ts new file mode 100644 index 0000000..af2c0e2 --- /dev/null +++ b/src/api/index.ts @@ -0,0 +1,18 @@ +export * from './http'; +export * from './models'; +export * from './agents'; +export * from './chat'; +export * from './mcp'; +export * from './sessions'; +export * from './uploads'; +export * from './search'; +export * from './auth'; +export * from './teams'; +export * from './marketplace'; +export * from './promptTemplates'; +export * from './pointsMall'; +export * from './stats'; +export * from './llmProviders'; +export * from './streamChat'; +export * from './workflows'; + diff --git a/src/api/llmProviders.ts b/src/api/llmProviders.ts new file mode 100644 index 0000000..b65d996 --- /dev/null +++ b/src/api/llmProviders.ts @@ -0,0 +1,35 @@ +import { api } from './http'; + +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('/llm-providers').then((r) => r.data), + allowedModels: (agentId: string) => api.get('/llm-providers', { params: { agent_id: agentId } }).then((r) => r.data), + create: (payload: Partial & { apiKey?: string }) => api.post<{ id: string }>('/llm-providers', payload).then((r) => r.data), + update: (id: string, payload: Partial & { 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) +}; + diff --git a/src/api/marketplace.ts b/src/api/marketplace.ts new file mode 100644 index 0000000..0b22c32 --- /dev/null +++ b/src/api/marketplace.ts @@ -0,0 +1,16 @@ +import { api } from './http'; +import type { Agent } from './agents'; + +export interface MarketplaceAgent extends Agent { + ownerName?: string; + fork_count: number; + skillCount: number; + kbCount: number; +} + +export const MarketplaceAPI = { + list: () => api.get('/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) +}; + diff --git a/src/api/mcp.ts b/src/api/mcp.ts new file mode 100644 index 0000000..1738696 --- /dev/null +++ b/src/api/mcp.ts @@ -0,0 +1,41 @@ +import { api } from './http'; + +export interface McpServer { + id: string; + name: string; + transport: 'stdio' | 'sse' | 'http'; + command: string; + args: string[]; + env: Record; + 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(`/agents/${agentId}/mcp-servers`).then((r) => r.data), + status: (agentId: string) => api.get(`/agents/${agentId}/mcp-status`).then((r) => r.data), + create: (agentId: string, payload: Partial) => api.post(`/agents/${agentId}/mcp-servers`, payload).then((r) => r.data), + update: (agentId: string, serverId: string, payload: Partial) => + 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 = {}) => + api.post(`/agents/${agentId}/mcp-get-prompt`, { serverId, name, args }).then((r) => r.data) +}; + diff --git a/src/api/models.ts b/src/api/models.ts new file mode 100644 index 0000000..f23f464 --- /dev/null +++ b/src/api/models.ts @@ -0,0 +1,14 @@ +import { api } from './http'; + +export interface AiModel { + 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) +}; + diff --git a/src/api/pointsMall.ts b/src/api/pointsMall.ts new file mode 100644 index 0000000..9c913f4 --- /dev/null +++ b/src/api/pointsMall.ts @@ -0,0 +1,112 @@ +import { api } from './http'; + +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 PointsExchangePrepareResponse { + orderId: string; + pointsDeducted: number; + remainingPoints: number; + expiresAt?: string; +} + +export type PointsExchangeShippingRequest = Omit; + +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('/points-mall/overview').then((r) => r.data), + products: (opts: { categoryId?: string; q?: string; sort?: string; page?: number; pageSize?: number } = {}) => + api + .get('/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), + exchangePrepare: (productId: string) => + api.post('/points-mall/exchange/prepare', { productId }).then((r) => r.data), + exchangeSubmitShipping: (orderId: string, shippingInfo: PointsExchangeShippingRequest) => + api.post(`/points-mall/exchange/${orderId}/shipping`, shippingInfo).then((r) => r.data), + exchange: (productId: string, shippingInfo: Omit) => + api.post('/points-mall/exchange', { productId, ...shippingInfo }).then((r) => r.data) +}; + diff --git a/src/api/promptTemplates.ts b/src/api/promptTemplates.ts new file mode 100644 index 0000000..498429c --- /dev/null +++ b/src/api/promptTemplates.ts @@ -0,0 +1,29 @@ +import { api } from './http'; + +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('/prompt-templates', { + params: { scope: opts.scope ?? 'all', q: opts.q ?? '' } + }) + .then((r) => r.data), + create: (payload: Partial) => api.post<{ id: string }>('/prompt-templates', payload).then((r) => r.data), + update: (id: string, payload: Partial) => 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) +}; + diff --git a/src/api/search.ts b/src/api/search.ts new file mode 100644 index 0000000..0dab1e1 --- /dev/null +++ b/src/api/search.ts @@ -0,0 +1,21 @@ +import { api } from './http'; + +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('/search', { params: { q, limit } }).then((r) => r.data) +}; + diff --git a/src/api/sessions.ts b/src/api/sessions.ts new file mode 100644 index 0000000..bc03ae0 --- /dev/null +++ b/src/api/sessions.ts @@ -0,0 +1,73 @@ +import { api, withApiBase } from './http'; + +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(`/agents/${agentId}/sessions`, { params: { archived } }).then((r) => r.data), + create: (agentId: string, title?: string) => api.post(`/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(`/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) +}; + diff --git a/src/api/stats.ts b/src/api/stats.ts new file mode 100644 index 0000000..3b179a2 --- /dev/null +++ b/src/api/stats.ts @@ -0,0 +1,57 @@ +import { api } from './http'; + +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 { + days?: number; + limit?: number; + calls?: number; + promptTokens?: number; + completionTokens?: number; + totalTokens?: number; + costUSD?: number; + byModel: AgentTokenStatsByModel[]; + daily: AgentTokenStatsDaily[]; +} + +export const StatsAPI = { + overview: () => api.get('/stats/overview').then((r) => r.data), + agent: (id: string) => api.get(`/stats/agents/${id}`).then((r) => r.data), + agentTokens: (id: string, opts: { days?: number; limit?: number } = {}) => + api.get(`/stats/agents/${id}/tokens`, { params: { days: opts.days ?? 30, limit: opts.limit ?? 10 } }).then((r) => r.data) +}; diff --git a/src/api/streamChat.ts b/src/api/streamChat.ts new file mode 100644 index 0000000..4b28176 --- /dev/null +++ b/src/api/streamChat.ts @@ -0,0 +1,152 @@ +import type { ChatMessage } from './chat'; + +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); +} + +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': + 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') { + return; + } + h.onError?.(e?.message ?? String(e)); + } finally { + reader.cancel().catch(() => {}); + } +} + diff --git a/src/api/teams.ts b/src/api/teams.ts new file mode 100644 index 0000000..4609ae8 --- /dev/null +++ b/src/api/teams.ts @@ -0,0 +1,21 @@ +import { api } from './http'; + +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('/teams').then((r) => r.data), + detail: (id: string) => api.get(`/teams/${id}`).then((r) => r.data), + create: (name: string) => api.post('/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) +}; + diff --git a/src/api/uploads.ts b/src/api/uploads.ts new file mode 100644 index 0000000..69bef02 --- /dev/null +++ b/src/api/uploads.ts @@ -0,0 +1,17 @@ +import { api } from './http'; + +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); + } +}; + diff --git a/src/api/workflows.ts b/src/api/workflows.ts new file mode 100644 index 0000000..0c60901 --- /dev/null +++ b/src/api/workflows.ts @@ -0,0 +1,104 @@ +import { api, withApiBase } from './http'; + +export type WorkflowNodeType = 'agent' | 'skill' | 'http' | 'transform' | 'branch'; + +export interface WorkflowNode { + id: string; + type: WorkflowNodeType; + name: string; + config: Record; + next?: string; + elseNext?: string; +} + +export interface WorkflowGraph { + entry: string; + nodes: WorkflowNode[]; + variables?: Record; +} + +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('/workflows').then((r) => r.data), + get: (id: string) => api.get(`/workflows/${id}`).then((r) => r.data), + create: (payload: Partial) => api.post<{ id: string }>('/workflows', payload).then((r) => r.data), + update: (id: string, payload: Partial) => 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) => api.post<{ runId: string }>(`/workflows/${id}/run`, { input }).then((r) => r.data), + listRuns: (id: string, limit = 30) => api.get(`/workflows/${id}/runs`, { params: { limit } }).then((r) => r.data), + getRun: (runId: string) => api.get(`/workflows/runs/${runId}`).then((r) => r.data) +}; + +export function streamWorkflowRun( + workflowId: string, + input: Record | 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(); +} + diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 14904e1..ece3201 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -1,1484 +1,2 @@ -import { useEffect, useRef, useState } from 'react'; -import { Button, Input, Space, Tag, App as AntApp, Popconfirm, Empty, Collapse, Switch, Drawer, Slider, InputNumber, Upload, Tooltip, Modal, Image as AntImage, Divider, Dropdown, Select } from 'antd'; -import { SettingOutlined, ApiOutlined, EditOutlined, DeleteOutlined, PaperClipOutlined, BookOutlined, ArrowUpOutlined, CloseOutlined, DownOutlined } from '@ant-design/icons'; -import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; -import ReactMarkdown from 'react-markdown'; -import { - Agent, - AgentAPI, - BranchInfo, - ChatAPI, - ChatAttachment, - ChatAttachmentsAPI, - ChatMessage, - ImageAPI, - ModelOverrides, - RetrievedSnippet, - regenerateMessage, - streamChat, - ToolCallTrace -} from '../api'; -import SessionSidebar from '../components/SessionSidebar'; -import McpResourcesDrawer from '../components/McpResourcesDrawer'; -import PromptLibraryPage from './PromptLibraryPage'; - -interface StreamingState { - active: boolean; - reasoningText: string; - answerText: string; - errorMessage: string | null; - retryInfo: any | null; - retrieved: RetrievedSnippet[]; - toolCalls: ToolCallTrace[]; -} - -interface AgentModelOption { - id: string; - name: string; -} - -const parseAgentModels = (value?: string): AgentModelOption[] => { - if (!value) return []; - // 尝试解析 JSON 格式 - try { - const parsed = JSON.parse(value); - if (Array.isArray(parsed)) { - return parsed.map((item: any) => ({ - id: typeof item === 'object' ? item.id : String(item), - name: typeof item === 'object' ? item.name : String(item) - })); - } - } catch { - // 兼容旧格式:逗号分隔的字符串 - } - return String(value || '') - .split(',') - .map((item) => item.trim()) - .filter(Boolean) - .map((item) => ({ id: item, name: item })); -}; - -export default function ChatPage() { - const { id } = useParams(); - const navigate = useNavigate(); - const [searchParams, setSearchParams] = useSearchParams(); - const { message: msg } = AntApp.useApp(); - const [agent, setAgent] = useState(null); - const [messages, setMessages] = useState([]); - const [branches, setBranches] = useState>({}); - const [input, setInput] = useState(''); - const [sending, setSending] = useState(false); - const [useStream, setUseStream] = useState(true); - const [sessionId, setSessionId] = useState(null); - const [highlightId, setHighlightId] = useState(null); - const [sessionRefresh, setSessionRefresh] = useState(0); - const [agentList, setAgentList] = useState([]); - const [historyDrawerOpen, setHistoryDrawerOpen] = useState(false); - const [mcpDrawerOpen, setMcpDrawerOpen] = useState(false); - const [paramsDrawerOpen, setParamsDrawerOpen] = useState(false); - const [tplDrawerOpen, setTplDrawerOpen] = useState(false); - const [overrides, setOverrides] = useState({}); - const [attachments, setAttachments] = useState([]); - const [imageUrls, setImageUrls] = useState([]); - const [uploadingAtt, setUploadingAtt] = useState(false); - const [streaming, setStreaming] = useState({ - active: false, - reasoningText: '', - answerText: '', - errorMessage: null, - retryInfo: null, - retrieved: [], - toolCalls: [] - }); - const bodyRef = useRef(null); - const abortRef = useRef(null); - const autoScrollRef = useRef(true); - const initialScrollDoneRef = useRef(false); - const scrollRafRef = useRef(null); - const scrollProgrammaticAtRef = useRef(0); - const lastScrollTopRef = useRef(0); - const userScrollLockRef = useRef(false); - - // URL 参数 ?session=xxx&msg=yyy - useEffect(() => { - const s = searchParams.get('session'); - const m = searchParams.get('msg'); - if (s) { - setSessionId(s); - if (m) setHighlightId(m); - const next = new URLSearchParams(searchParams); - next.delete('session'); - next.delete('msg'); - setSearchParams(next, { replace: true }); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [searchParams]); - - const scrollBottom = (force = false) => { - if (force) { - userScrollLockRef.current = false; - autoScrollRef.current = true; - } - if (!force && (!autoScrollRef.current || userScrollLockRef.current)) return; - if (scrollRafRef.current) { - cancelAnimationFrame(scrollRafRef.current); - scrollRafRef.current = null; - } - scrollRafRef.current = requestAnimationFrame(() => { - scrollRafRef.current = null; - const el = bodyRef.current; - if (!el) return; - scrollProgrammaticAtRef.current = Date.now(); - el.scrollTop = el.scrollHeight; - }); - }; - - useEffect(() => { - const el = bodyRef.current; - if (!el) return; - const onScroll = () => { - const now = Date.now(); - const isProgrammatic = now - scrollProgrammaticAtRef.current < 50; - const nextTop = el.scrollTop; - const lastTop = lastScrollTopRef.current; - const scrollHeight = el.scrollHeight; - const clientHeight = el.clientHeight; - const distance = scrollHeight - nextTop - clientHeight; - - if (!isProgrammatic) { - const scrollDelta = nextTop - lastTop; - if (scrollDelta < -5) { - userScrollLockRef.current = true; - } - } - lastScrollTopRef.current = nextTop; - - if (distance < 8) { - userScrollLockRef.current = false; - } - autoScrollRef.current = distance < 32 && !userScrollLockRef.current; - if ((!autoScrollRef.current || userScrollLockRef.current) && scrollRafRef.current) { - cancelAnimationFrame(scrollRafRef.current); - scrollRafRef.current = null; - } - }; - el.addEventListener('scroll', onScroll, { passive: true }); - setTimeout(onScroll, 100); - return () => el.removeEventListener('scroll', onScroll); - }, []); - - const loadAgent = async () => { - if (!id) { - setAgent(null); - setMessages([]); - return; - } - const a = await AgentAPI.detail(id); - setAgent(a); - const models = parseAgentModels(a.model); - const firstModel = models[0]; - if (firstModel) { - setOverrides((o) => ({ - ...o, - model: o.model || firstModel.name, - model_id: o.model_id || firstModel.id - })); - } - }; - - const loadAgentList = async () => { - try { - const list = await AgentAPI.list(); - setAgentList(list); - } catch (e) { - // ignore - } - }; - - const loadMessages = async () => { - if (!id) return; - const his = await ChatAPI.history(id, sessionId || undefined); - setMessages(Array.isArray(his.messages) ? his.messages : []); - setBranches(his.branches || {}); - requestAnimationFrame(() => { - if (highlightId) { - const el = document.getElementById('msg-' + highlightId); - if (el) { - el.scrollIntoView({ behavior: 'smooth', block: 'center' }); - setTimeout(() => setHighlightId(null), 3000); - return; - } - } - if (!initialScrollDoneRef.current) { - autoScrollRef.current = true; - scrollBottom(true); - initialScrollDoneRef.current = true; - } else { - scrollBottom(); - } - }); - }; - - useEffect(() => { - loadAgentList(); - }, []); - - useEffect(() => { - if (!id) { - setAgent(null); - setMessages([]); - setOverrides({}); - setSessionId(null); - return () => abortRef.current?.abort(); - } - loadAgent(); - setOverrides({}); - return () => abortRef.current?.abort(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [id]); - - useEffect(() => { - initialScrollDoneRef.current = false; - }, [id]); - - useEffect(() => { - if (!id) return; - loadMessages(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [sessionId, highlightId, id]); - - /** 把附件文本拼成 system 注入字符串 */ - const buildAttachmentsText = () => { - if (!attachments.length) return ''; - return attachments - .map( - (a, i) => - `### 附件 ${i + 1}: ${a.name}${a.truncated ? ' (已截断)' : ''}\n${a.text}` - ) - .join('\n\n---\n\n'); - }; - - const handleSendStream = async (text: string) => { - if (!id) return; - const tempUser: ChatMessage = { - id: 'tmp-' + Date.now(), - role: 'user', - content: text, - createdAt: Date.now() - }; - setMessages((m) => [...(m || []), tempUser]); - setStreaming({ - active: true, - reasoningText: '', - answerText: '', - errorMessage: null, - retryInfo: null, - retrieved: [], - toolCalls: [] - }); - scrollBottom(true); - - abortRef.current?.abort(); - const ctrl = new AbortController(); - abortRef.current = ctrl; - - const models = parseAgentModels(agent?.model); - const model = overrides.model || models[0]?.name || ''; - const modelId = overrides.model_id || models[0]?.id || ''; - const attText = buildAttachmentsText(); - const content = attText ? `${text}\n\n${attText}` : text; - - try { - await streamChat( - id, - content, - { - onMeta: (m) => setStreaming((s) => ({ ...s, retrieved: m.retrieved || [] })), - onRetry: (data) => { - setStreaming((s) => ({ ...s, retryInfo: data })); - if (data?.stage === 'fallback_model' && data?.toModel) { - setOverrides((o) => ({ ...o, model: String(data.toModel) })); - } - }, - onReasoningDelta: (chunk) => - setStreaming((s) => { - const next = { ...s, reasoningText: s.reasoningText + chunk }; - scrollBottom(); - return next; - }), - onDelta: (chunk) => - setStreaming((s) => { - const next = { ...s, answerText: s.answerText + chunk }; - scrollBottom(); - return next; - }), - onToolCall: (data) => - setStreaming((s) => ({ - ...s, - toolCalls: [...s.toolCalls, { name: data.name, args: data.args, result: { pending: true } }] - })), - onToolResult: (data) => - setStreaming((s) => { - const list = [...s.toolCalls]; - for (let i = list.length - 1; i >= 0; i--) { - if (list[i].name === data.name && (list[i].result as any)?.pending) { - list[i] = { ...list[i], result: data.result }; - break; - } - } - return { ...s, toolCalls: list }; - }), - onDone: (data) => { - setStreaming((s) => { - const assistant: any = data.assistant; - const reasoningText = - assistant?.reasoning || - assistant?.meta?.reasoning || - assistant?.meta?.reasoningText || - s.reasoningText; - const nextAssistant: ChatMessage = { - ...data.assistant, - meta: { - ...(data.assistant.meta || {}), - retrieved: data.assistant.meta?.retrieved || s.retrieved, - toolCalls: data.assistant.meta?.toolCalls || s.toolCalls, - reasoning: reasoningText || undefined - } - }; - setMessages((m) => [...(m || []).filter((x) => x.id !== tempUser.id), data.user, nextAssistant]); - return { - active: false, - reasoningText: '', - answerText: '', - errorMessage: null, - retryInfo: null, - retrieved: [], - toolCalls: [] - }; - }); - setSessionRefresh((t) => t + 1); - setAttachments([]); // 用完即清 - setImageUrls([]); - scrollBottom(); - }, - onAborted: (data) => { - // 已停止:保留已生成内容;user 消息也留下(用 tempUser 占位) - const assistant: ChatMessage = { - ...data.assistant, - meta: { - ...(data.assistant.meta || {}), - aborted: true - } - }; - setMessages((m) => [ - ...(m || []).filter((x) => x.id !== tempUser.id), - { ...tempUser, id: 'u-' + data.assistant.id }, // 占位 - assistant - ]); - setStreaming({ - active: false, - reasoningText: '', - answerText: '', - errorMessage: null, - retryInfo: null, - retrieved: [], - toolCalls: [] - }); - setSessionRefresh((t) => t + 1); - // 重新拉一次确保和后端一致(user 真实 id 在那边) - loadMessages(); - }, - onError: (errMsg) => { - msg.error('流式失败:' + errMsg); - // 创建错误消息作为助手回复,保留用户消息 - const errorMessage: ChatMessage = { - id: 'error-' + Date.now(), - role: 'assistant', - content: `❌ 请求失败:${errMsg}`, - createdAt: Date.now(), - meta: { error: errMsg } - }; - setMessages((m) => [...(m || []), errorMessage]); - setStreaming({ - active: false, - reasoningText: '', - answerText: '', - errorMessage: errMsg, - retryInfo: null, - retrieved: [], - toolCalls: [] - }); - } - }, - ctrl.signal, - sessionId || undefined, - model, - modelId, - imageUrls - ); - } catch (e: any) { - if (e?.name !== 'AbortError') { - msg.error('请求失败:' + (e?.message ?? e)); - // 创建错误消息作为助手回复,保留用户消息 - const errorMessage: ChatMessage = { - id: 'error-' + Date.now(), - role: 'assistant', - content: `❌ 请求失败:${e?.message ?? String(e)}`, - createdAt: Date.now(), - meta: { error: e?.message ?? String(e) } - }; - setMessages((m) => [...(m || []), errorMessage]); - } - setStreaming({ - active: false, - reasoningText: '', - answerText: '', - errorMessage: e?.message ?? String(e), - retryInfo: null, - retrieved: [], - toolCalls: [] - }); - } - }; - - const handleSendNonStream = async (text: string) => { - if (!id) return; - const tempUser: ChatMessage = { - id: 'tmp-' + Date.now(), - role: 'user', - content: text, - createdAt: Date.now() - }; - setMessages((m) => [...(m || []), tempUser]); - scrollBottom(true); - const attText = buildAttachmentsText(); - const content = attText ? `${text}\n\n${attText}` : text; - const models = parseAgentModels(agent?.model); - const model = overrides.model || models[0]?.name || ''; - try { - const res = await ChatAPI.send( - id, - content, - sessionId || undefined, - model, - imageUrls - ); - setMessages((m) => [...(m || []).filter((x) => x.id !== tempUser.id), res.user, res.assistant]); - setSessionRefresh((t) => t + 1); - setAttachments([]); - setImageUrls([]); - scrollBottom(); - } catch (e: any) { - msg.error('发送失败:' + (e?.message ?? e)); - setMessages((m) => (m || []).filter((x) => x.id !== tempUser.id)); - } - }; - - const handleSend = async () => { - const text = input.trim(); - if (!text || !id || sending) return; - setInput(''); - setSending(true); - try { - if (useStream) await handleSendStream(text); - else await handleSendNonStream(text); - } finally { - setSending(false); - } - }; - - const handleStop = () => { - abortRef.current?.abort(); - setSending(false); - }; - - const handleClear = async () => { - if (!id) return; - await ChatAPI.clear(id, sessionId || undefined); - setMessages([]); - setBranches({}); - msg.success('对话已清空'); - }; - - const handleRegenerate = async (assistantId: string) => { - if (!id || sending) return; - setSending(true); - setStreaming({ - active: true, - reasoningText: '', - answerText: '', - errorMessage: null, - retryInfo: null, - retrieved: [], - toolCalls: [] - }); - abortRef.current?.abort(); - const ctrl = new AbortController(); - abortRef.current = ctrl; - try { - await regenerateMessage( - id, - assistantId, - { - onMeta: (m) => setStreaming((s) => ({ ...s, retrieved: m.retrieved || [] })), - onRetry: (data) => { - setStreaming((s) => ({ ...s, retryInfo: data })); - if (data?.stage === 'fallback_model' && data?.toModel) { - setOverrides((o) => ({ ...o, model: String(data.toModel) })); - } - }, - onReasoningDelta: (chunk) => - setStreaming((s) => { - const next = { ...s, reasoningText: s.reasoningText + chunk }; - scrollBottom(); - return next; - }), - onDelta: (chunk) => - setStreaming((s) => { - const next = { ...s, answerText: s.answerText + chunk }; - scrollBottom(); - return next; - }), - onToolCall: (data) => - setStreaming((s) => ({ - ...s, - toolCalls: [...s.toolCalls, { name: data.name, args: data.args, result: { pending: true } }] - })), - onToolResult: (data) => - setStreaming((s) => { - const list = [...s.toolCalls]; - for (let i = list.length - 1; i >= 0; i--) { - if (list[i].name === data.name && (list[i].result as any)?.pending) { - list[i] = { ...list[i], result: data.result }; - break; - } - } - return { ...s, toolCalls: list }; - }), - onDone: () => { - setStreaming({ - active: false, - reasoningText: '', - answerText: '', - errorMessage: null, - retryInfo: null, - retrieved: [], - toolCalls: [] - }); - loadMessages(); - }, - onAborted: () => { - setStreaming({ - active: false, - reasoningText: '', - answerText: '', - errorMessage: null, - retryInfo: null, - retrieved: [], - toolCalls: [] - }); - loadMessages(); - }, - onError: (errMsg) => { - msg.error('重新生成失败:' + errMsg); - setStreaming({ - active: false, - reasoningText: '', - answerText: '', - errorMessage: errMsg, - retryInfo: null, - retrieved: [], - toolCalls: [] - }); - loadMessages(); - } - }, - ctrl.signal, - overrides - ); - } finally { - setSending(false); - } - }; - - const handleSwitchBranch = async (userMsgId: string, branchId: string) => { - if (!id) return; - await ChatAPI.switchBranch(id, userMsgId, branchId); - await loadMessages(); - }; - - const handleAttach = async (files: File[]) => { - if (!files.length) return; - setUploadingAtt(true); - try { - // v1.0: 自动按类型分流。图片 → 图床;其他 → 文本附件 - const images = files.filter((f) => f.type.startsWith('image/')); - const docs = files.filter((f) => !f.type.startsWith('image/')); - let imgN = 0, docN = 0; - if (images.length) { - const r = await ImageAPI.upload(images); - setImageUrls((arr) => [...arr, ...r.files.map((f) => f.url)]); - imgN = r.files.length; - } - if (docs.length) { - const r = await ChatAttachmentsAPI.upload(docs); - setAttachments((a) => [...a, ...r.files]); - docN = r.files.length; - } - const parts = []; - if (imgN) parts.push(`${imgN} 张图片`); - if (docN) parts.push(`${docN} 个文档`); - if (parts.length) msg.success(`已附加 ${parts.join(' + ')}`); - } catch (e: any) { - msg.error('附件上传失败:' + (e?.message ?? e)); - } finally { - setUploadingAtt(false); - } - }; - - const isImageUrl = (url: string) => url?.startsWith('http') || url?.startsWith('/'); - const agentModel = agent?.model || ''; - const agentModels = parseAgentModels(agentModel); - const modelOptions = agentModels.map((model) => ({ - value: model.id, - label: model.name - })); - const activeModelValue = overrides.model_id || ''; - - return ( -
- {/* 1. 二级侧边栏:智能体列表 */} - - - {/* 2. 主聊天区 */} -
- {!agent ? ( -
- -
- ) : ( - <> - {/* Header */} -
-
-
{agent.name}
-
- {agent.model || '默认模型'} · T={agent.temperature} -
-
- -
- 流式输出 - -
- - , onClick: () => setParamsDrawerOpen(true) }, - { key: 'mcp', label: 'MCP 资源', icon: , onClick: () => setMcpDrawerOpen(true) }, - { key: 'edit', label: '管理 Agent', icon: , onClick: () => navigate(`/agents/${id}`) }, - { type: 'divider' }, - { key: 'clear', label: '清空对话', icon: , danger: true, onClick: () => { - Modal.confirm({ - title: '清空当前会话所有消息?', - onOk: handleClear - }); - } - } - ] - }} - > - - -
-
- - {/* Body */} -
-
- {messages.length === 0 && !streaming.active ? ( -
-
- {isImageUrl(agent.avatar) ? ( - avatar - ) : ( - (agent.name?.charAt(0) || '?').toUpperCase() - )} -
-

- 你好,今天想一起完成什么? -

-

- {agent.description || '我是你的专属 AI 助手,随时准备为你服务。'} -

-
- ) : ( - <> - {messages.map((m) => ( - { - navigator.clipboard?.writeText(text).then(() => msg.success('已复制')); - }} - /> - ))} - {streaming.active && ( -
-
-
- {!!streaming.retryInfo?.message && ( -
-
- - {streaming.retryInfo.stage === 'fallback_model' ? '自动切换模型' : '自动重试'} - - {streaming.retryInfo.stage === 'fallback_model' ? ( - - {String(streaming.retryInfo.fromModel || '')} → {String(streaming.retryInfo.toModel || '')} - - ) : ( - - {String(streaming.retryInfo.model || '')} - {streaming.retryInfo.attempt ? ` · 第${streaming.retryInfo.attempt}次` : ''} - - )} -
-
- {String(streaming.retryInfo.message)} -
- {!!streaming.retryInfo.reason && ( -
- {String(streaming.retryInfo.reason)} -
- )} -
- )} -
-
- 推理过程 -
- {streaming.reasoningText ? ( - {streaming.reasoningText + '▍'} - ) : ( - 等待推理… - )} -
- -
-
- 正式回答 -
- {streaming.answerText ? ( - {streaming.answerText + '▍'} - ) : ( - 等待输出… - )} -
-
-
- {(streaming.retrieved.length > 0 || streaming.toolCalls.length > 0) && ( -
- {streaming.retrieved.length > 0 && ( - - )} - {streaming.toolCalls.length > 0 && ( - - )} -
- )} -
- )} - - )} -
-
- - {/* Input Footer */} -
- {/* Attachments Preview */} -
- {attachments.map((a, i) => ( - setAttachments((arr) => arr.filter((_, j) => j !== i))} - > - 📎 {a.name} - - ))} - {imageUrls.map((u, i) => ( -
- -
- ))} -
- -
-
- setInput(e.target.value)} - placeholder="问我任何问题..." - autoSize={{ minRows: 3, maxRows: 10 }} - onKeyDown={(e) => { - if (e.key !== 'Enter') return; - if ((e as any).isComposing) return; - - if (e.metaKey || e.ctrlKey) { - e.preventDefault(); - const el = e.currentTarget; - const start = el.selectionStart ?? input.length; - const end = el.selectionEnd ?? input.length; - const next = input.slice(0, start) + '\n' + input.slice(end); - setInput(next); - requestAnimationFrame(() => { - el.selectionStart = el.selectionEnd = start + 1; - }); - return; - } - - if (!e.shiftKey && !e.altKey) { - e.preventDefault(); - handleSend(); - } - }} - className="chat-input-textarea" - disabled={sending} - /> - -
-
- - - - - - k.value === form.getFieldValue('kind'))?.hint} - > - - - - - - - - - - - - - -
- ); -} diff --git a/src/pages/LLMProvidersPage/LLMProvidersPage.tsx b/src/pages/LLMProvidersPage/LLMProvidersPage.tsx new file mode 100644 index 0000000..8d79ff3 --- /dev/null +++ b/src/pages/LLMProvidersPage/LLMProvidersPage.tsx @@ -0,0 +1,108 @@ +import { useEffect, useState } from 'react'; +import { App as AntApp, Form } from 'antd'; +import type { LLMProvider } from '../../api'; +import { LLMProviderAPI } from '../../api'; +import ProvidersHero from './components/ProvidersHero'; +import ProviderGrid from './components/ProviderGrid'; +import ProviderEditorModal from './components/ProviderEditorModal'; + +export default function LLMProvidersPage() { + const { message } = AntApp.useApp(); + const [list, setList] = useState([]); + const [editorOpen, setEditorOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [form] = Form.useForm(); + const [testing, setTesting] = useState(null); + + const load = async () => { + try { + setList(await LLMProviderAPI.list()); + } catch (e: any) { + message.error('加载失败:' + (e?.message ?? e)); + } + }; + + useEffect(() => { + load(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const openCreate = () => { + setEditing(null); + form.resetFields(); + form.setFieldsValue({ kind: 'openai', enabled: true }); + setEditorOpen(true); + }; + + const openEdit = (p: LLMProvider) => { + setEditing(p); + form.setFieldsValue({ + name: p.name, + kind: p.kind, + baseUrl: p.baseUrl, + models: p.models?.join(', ') || '', + defaultModel: p.defaultModel, + enabled: p.enabled, + isDefault: p.isDefault + }); + setEditorOpen(true); + }; + + const onSave = async () => { + const v = await form.validateFields(); + const payload = { + ...v, + models: typeof v.models === 'string' ? v.models.split(/[,,]/).map((s: string) => s.trim()).filter(Boolean) : v.models + }; + try { + if (editing) { + await LLMProviderAPI.update(editing.id, payload); + message.success('已更新'); + } else { + await LLMProviderAPI.create(payload); + message.success('已创建'); + } + setEditorOpen(false); + load(); + } catch (e: any) { + message.error('保存失败:' + (e?.response?.data?.error ?? e?.message ?? e)); + } + }; + + const onDelete = async (p: LLMProvider) => { + await LLMProviderAPI.remove(p.id); + message.success('已删除'); + load(); + }; + + const onTest = async (p: LLMProvider) => { + setTesting(p.id); + try { + const r = await LLMProviderAPI.test(p.id, p.defaultModel); + if (r.ok) { + message.success(`✅ 连通:${r.model} · 用量 ${(r as any).usage?.TotalTokens ?? '?'} tokens`); + } else { + message.error('❌ 失败:' + (r.error || 'unknown')); + } + } catch (e: any) { + message.error('测试失败:' + (e?.response?.data?.error ?? e?.message ?? e)); + } finally { + setTesting(null); + } + }; + + const onSetDefault = async (p: LLMProvider) => { + await LLMProviderAPI.setDefault(p.id); + message.success('已设为默认'); + load(); + }; + + return ( +
+ + + setEditorOpen(false)} onOk={onSave} /> +
+ ); +} + diff --git a/src/pages/LLMProvidersPage/components/ProviderCard.tsx b/src/pages/LLMProvidersPage/components/ProviderCard.tsx new file mode 100644 index 0000000..0d69eda --- /dev/null +++ b/src/pages/LLMProvidersPage/components/ProviderCard.tsx @@ -0,0 +1,158 @@ +import { CheckCircleOutlined, LockOutlined, RocketOutlined, StarFilled } from '@ant-design/icons'; +import { Button, Popconfirm, Space, Tag, Tooltip } from 'antd'; +import type { LLMProvider } from '../../../api'; + +export default function ProviderCard(props: { + provider: LLMProvider; + testing: boolean; + onTest: () => void; + onSetDefault: () => void; + onEdit: () => void; + onDelete: () => void; +}) { + const { provider: p, testing, onTest, onSetDefault, onEdit, onDelete } = props; + + return ( +
+
+
+
+ {p.name} + {p.isDefault && ( + } + style={{ background: 'var(--color-success-soft)', color: 'var(--color-success)', borderRadius: 999, margin: 0 }} + > + 默认 + + )} + {!p.enabled && ( + + 已禁用 + + )} +
+
{p.baseUrl}
+
+ + + + + + {!p.isDefault && ( + + + + )} + +
+ +
+
+
接入类型
+ + {p.kind} + +
+
+
默认模型
+
{p.defaultModel || '未设置'}
+
+
+ +
+
+ + 密钥状态 +
+
+ {p.hasApiKey ? ( + + + 已配置 {p.apiKeyMasked} + + ) : ( + 尚未配置 API Key + )} +
+
{p.baseUrl}
+
+ + {p.models?.length > 0 && ( +
+
候选模型
+ + {p.models.map((m) => ( + + {m} + + ))} + +
+ )} + +
+ + {p.enabled ? '可用于聊天、工作流和测试连接' : '当前已暂停使用,需要重新启用'} + + + + + + +
+
+ ); +} + diff --git a/src/pages/LLMProvidersPage/components/ProviderEditorModal.tsx b/src/pages/LLMProvidersPage/components/ProviderEditorModal.tsx new file mode 100644 index 0000000..8087e93 --- /dev/null +++ b/src/pages/LLMProvidersPage/components/ProviderEditorModal.tsx @@ -0,0 +1,71 @@ +import { Form, Input, Modal, Select } from 'antd'; +import type { FormInstance } from 'antd'; +import type { LLMProvider } from '../../../api'; +import { KIND_OPTIONS } from '../constants'; + +export default function ProviderEditorModal(props: { + open: boolean; + editing: LLMProvider | null; + form: FormInstance; + onCancel: () => void; + onOk: () => void; +}) { + const { open, editing, form, onCancel, onOk } = props; + + return ( + +
{ + if (changed.kind) { + const opt = KIND_OPTIONS.find((o) => o.value === changed.kind); + if (opt?.baseUrl && !form.getFieldValue('baseUrl')) { + form.setFieldsValue({ baseUrl: opt.baseUrl }); + } + } + }} + > + + + + + + + k.value === form.getFieldValue('kind'))?.hint} + > + + + + + + + + + + + +
+
+ ); +} + diff --git a/src/pages/LLMProvidersPage/components/ProviderGrid.tsx b/src/pages/LLMProvidersPage/components/ProviderGrid.tsx new file mode 100644 index 0000000..207c8d6 --- /dev/null +++ b/src/pages/LLMProvidersPage/components/ProviderGrid.tsx @@ -0,0 +1,46 @@ +import { Empty } from 'antd'; +import type { LLMProvider } from '../../../api'; +import ProviderCard from './ProviderCard'; + +export default function ProviderGrid(props: { + list: LLMProvider[]; + testingId: string | null; + onTest: (p: LLMProvider) => void; + onSetDefault: (p: LLMProvider) => void; + onEdit: (p: LLMProvider) => void; + onDelete: (p: LLMProvider) => void; +}) { + const { list, testingId, onTest, onSetDefault, onEdit, onDelete } = props; + + if (list.length === 0) { + return ( +
+ +
+ ); + } + + return ( +
+ {list.map((p) => ( + onTest(p)} + onSetDefault={() => onSetDefault(p)} + onEdit={() => onEdit(p)} + onDelete={() => onDelete(p)} + /> + ))} +
+ ); +} + diff --git a/src/pages/LLMProvidersPage/components/ProvidersHero.tsx b/src/pages/LLMProvidersPage/components/ProvidersHero.tsx new file mode 100644 index 0000000..177e466 --- /dev/null +++ b/src/pages/LLMProvidersPage/components/ProvidersHero.tsx @@ -0,0 +1,104 @@ +import { ApiOutlined, PlusOutlined } from '@ant-design/icons'; +import { Button } from 'antd'; +import type { LLMProvider } from '../../../api'; + +export default function ProvidersHero(props: { list: LLMProvider[]; onAdd: () => void }) { + const { list, onAdd } = props; + + return ( +
+
+
+
+ + 模型接入中心 +
+

+ LLM 提供商 +

+
+ 把不同的模型能力接入到同一个工作台里。这里不只是存放接口配置,更是管理默认模型、候选模型与稳定连接状态的地方。 +
+
+ +
+ +
+ {[ + { label: '已接入提供商', value: list.length, tone: 'rgba(8, 145, 178, 0.10)', color: 'var(--color-brand)' }, + { label: '可用连接数', value: list.filter((item) => item.enabled).length, tone: 'rgba(34, 197, 94, 0.10)', color: 'var(--color-success)' }, + { label: '默认模型源', value: list.filter((item) => item.isDefault).length, tone: 'rgba(249, 115, 22, 0.10)', color: 'var(--color-warning)' } + ].map((item) => ( +
+
{item.label}
+
+ {item.value} + + 实时状态 + +
+
+ ))} +
+
+ ); +} + diff --git a/src/pages/LLMProvidersPage/constants.ts b/src/pages/LLMProvidersPage/constants.ts new file mode 100644 index 0000000..e7a1df4 --- /dev/null +++ b/src/pages/LLMProvidersPage/constants.ts @@ -0,0 +1,14 @@ +import type { LLMKind } from '../../api'; + +export const KIND_OPTIONS: { value: LLMKind; label: string; baseUrl: string; hint?: string }[] = [ + { value: 'openai', label: 'OpenAI 官方', baseUrl: 'https://api.openai.com/v1' }, + { + value: 'openai-compatible', + label: 'OpenAI 兼容(GLM/通义/DeepSeek/腾讯 Token Plan)', + baseUrl: '', + hint: '智谱:https://open.bigmodel.cn/api/paas/v4 · 通义:https://dashscope.aliyuncs.com/compatible-mode/v1 · DeepSeek:https://api.deepseek.com' + }, + { value: 'anthropic', label: 'Anthropic Claude', baseUrl: 'https://api.anthropic.com' }, + { value: 'ollama', label: 'Ollama 本地', baseUrl: 'http://localhost:11434' } +]; + diff --git a/src/pages/PointsMallPage.tsx b/src/pages/PointsMallPage.tsx index cf4398f..5c2d307 100644 --- a/src/pages/PointsMallPage.tsx +++ b/src/pages/PointsMallPage.tsx @@ -1,495 +1,2 @@ -import { useEffect, useState } from 'react'; -import { Button, Card, Empty, Input, Select, Space, Spin, Tag, Modal, Form, message } from 'antd'; -import { SearchOutlined } from '@ant-design/icons'; -import { PointsMallAPI, PointsMallCategory, PointsMallOverview, PointsMallProduct, PointsMallProductsResponse } from '../api'; +export { default } from './PointsMallPage/PointsMallPage'; -type SortKey = 'popular' | 'price_asc' | 'price_desc' | 'newest'; - -const MOCK_OVERVIEW: PointsMallOverview = { - me: { points: 1280, level: 'Lv.2' }, - categories: [ - { id: 'all', name: '全部', sort: 0 }, - { id: 'digital', name: '虚拟权益', sort: 1 }, - { id: 'tool', name: '工具周边', sort: 2 }, - { id: 'gift', name: '礼品卡券', sort: 3 }, - { id: 'limited', name: '限时活动', sort: 4 } - ], - announcements: [], - banners: [ - { id: 'b1', title: '本期活动', subtitle: 'Up to 25% Off', imageUrl: '', linkUrl: '' } - ], - promoEntries: [ - { id: 'p1', title: '促销活动', subtitle: '本周精选', linkUrl: '' }, - { id: 'p2', title: '积分任务', subtitle: '快速涨积分', linkUrl: '' } - ] -}; - -const MOCK_PRODUCTS: PointsMallProduct[] = Array.from({ length: 12 }).map((_, i) => ({ - id: String(i + 1), - categoryId: i % 2 === 0 ? 'digital' : 'tool', - name: `商品 ${i + 1}`, - subtitle: '这里是商品简短描述', - coverUrl: '', - pointsPrice: 199 + i * 10, - stock: 99, - sold: 12 + i, - tags: i % 3 === 0 ? ['限时'] : i % 3 === 1 ? ['热卖'] : [] -})); - -interface ExchangeFormValues { - recipientName: string; - phone: string; - province: string; - city: string; - district: string; - address: string; - zipCode?: string; -} - -export default function PointsMallPage() { - const [overviewLoading, setOverviewLoading] = useState(false); - const [productsLoading, setProductsLoading] = useState(false); - const [overview, setOverview] = useState(null); - const [categories, setCategories] = useState([]); - - const [categoryId, setCategoryId] = useState('all'); - const [q, setQ] = useState(''); - const [sort, setSort] = useState('popular'); - const [page, setPage] = useState(1); - const [pageSize, setPageSize] = useState(24); - const [productsRes, setProductsRes] = useState(null); - - const [exchangeModalVisible, setExchangeModalVisible] = useState(false); - const [selectedProduct, setSelectedProduct] = useState(null); - const [pendingOrderId, setPendingOrderId] = useState(null); - const [exchangeLoading, setExchangeLoading] = useState(false); - const [form] = Form.useForm(); - - const loadOverview = async () => { - setOverviewLoading(true); - try { - const data = await PointsMallAPI.overview(); - setOverview(data); - setCategories(data.categories || []); - if (!data.categories?.some((c) => c.id === categoryId) && data.categories?.[0]?.id) { - setCategoryId(data.categories[0].id); - } - } catch { - message.error('获取积分信息失败,请稍后重试'); - setOverview({ - ...MOCK_OVERVIEW, - me: { points: 0, level: 'Lv.0' } - }); - setCategories(MOCK_OVERVIEW.categories); - } finally { - setOverviewLoading(false); - } - }; - - const loadProducts = async () => { - setProductsLoading(true); - try { - const res = await PointsMallAPI.products({ - categoryId: categoryId === 'all' ? undefined : categoryId, - q, - sort, - page, - pageSize - }); - setProductsRes(res); - } catch { - const filtered = MOCK_PRODUCTS.filter((p) => (categoryId === 'all' ? true : p.categoryId === categoryId)) - .filter((p) => (q ? (p.name + p.subtitle).toLowerCase().includes(q.toLowerCase()) : true)); - setProductsRes({ - page, - pageSize, - total: filtered.length, - items: filtered.slice((page - 1) * pageSize, page * pageSize) - }); - } finally { - setProductsLoading(false); - } - }; - - useEffect(() => { - loadOverview(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - useEffect(() => { - loadProducts(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [categoryId, q, sort, page, pageSize]); - - const banner = overview?.banners?.[0]; - const promoEntries = overview?.promoEntries || []; - - const products = productsRes?.items || []; - const total = productsRes?.total || 0; - - const handleExchangeClick = async (product: PointsMallProduct) => { - if (userPoints < product.pointsPrice) return; - - setExchangeLoading(true); - try { - const res = await PointsMallAPI.exchangePrepare(product.id); - setSelectedProduct(product); - setPendingOrderId(res.orderId); - setExchangeModalVisible(true); - form.resetFields(); - setOverview((prev) => { - if (!prev) return prev; - return { ...prev, me: { ...prev.me, points: res.remainingPoints } }; - }); - message.success('积分扣减成功,请填写收件信息完成兑换'); - } catch (e: any) { - message.error(e?.message || '兑换失败,请稍后重试'); - } finally { - setExchangeLoading(false); - } - }; - - const handleExchangeSubmit = async () => { - if (!selectedProduct || !pendingOrderId) return; - - try { - await form.validateFields(); - setExchangeLoading(true); - - await PointsMallAPI.exchangeSubmitShipping(pendingOrderId, form.getFieldsValue()); - - message.success('兑换成功!我们将尽快为您安排发货'); - setExchangeModalVisible(false); - setPendingOrderId(null); - - // 刷新积分余额 - loadOverview(); - } catch (error: any) { - if (error?.errorFields) return; - message.error(error?.message || '提交失败,请稍后重试'); - } finally { - setExchangeLoading(false); - } - }; - - const userPoints = overview?.me?.points || 0; - const canAfford = selectedProduct ? userPoints >= selectedProduct.pointsPrice : false; - - return ( -
-
-
-
-

积分商城

-

- 使用积分兑换权益、工具和活动礼包。积分通过 API 调用消费自动累积,1 美元 = 1000 积分。 -

-
-
- {overviewLoading ? ( - - ) : ( -
-
-
我的积分
-
{userPoints.toLocaleString()}
-
- - {String(overview?.me?.level || 'Lv.0')} - -
- )} -
-
-
- - -
-
- 商品分类 - {categories.map((c) => ( - - ))} -
-
-
- -
-
-
-
-
{banner?.title || '本期活动'}
-
{banner?.subtitle || 'Up to 25% Off'}
-
- -
-
banner 图片与跳转链接由后端配置
-
- -
- {promoEntries.slice(0, 2).map((p) => ( -
-
-
{p.title}
-
{p.subtitle}
-
- -
- ))} - {promoEntries.length < 2 && ( -
促销入口由后端配置
- )} -
-
- - -
- - { - setPage(1); - setQ(e.target.value); - }} - prefix={} - placeholder="搜索商品" - allowClear - className="points-mall-search-input" - /> - { - setPage(1); - setPageSize(v); - }} - options={[ - { value: 12, label: '每页 12' }, - { value: 24, label: '每页 24' }, - { value: 48, label: '每页 48' } - ]} - /> - -
- 共 {total.toLocaleString()} 件商品 -
-
- - {productsLoading ? ( - - ) : products.length === 0 ? ( - - ) : ( -
- {products.map((p) => ( -
-
-
-
-
-
{p.name}
-
{p.subtitle}
-
- {p.tags?.length ? ( - - {p.tags[0]} - - ) : null} -
-
-
- {Number(p.pointsPrice).toLocaleString()} - 积分 -
- -
-
- 库存 {p.stock} - 已兑 {p.sold} -
-
-
- ))} -
- )} - - {!!productsRes && ( -
- - - - 第 {page} 页 - - - -
- )} - - - {/* 兑换弹窗 */} - { - setExchangeModalVisible(false); - setPendingOrderId(null); - }} - footer={null} - width={500} - className="points-exchange-modal" - destroyOnClose - > - {selectedProduct && ( - <> -
-
{selectedProduct.name}
-
{selectedProduct.pointsPrice.toLocaleString()} 积分
-
- - {!canAfford && ( -
- 积分不足!当前余额:{userPoints.toLocaleString()} 积分,还需:{(selectedProduct.pointsPrice - userPoints).toLocaleString()} 积分 -
- )} - -
- 收货人姓名} - rules={[{ required: true, message: '请输入收货人姓名' }]} - className="points-exchange-form-item" - > - - - - 手机号码} - rules={[ - { required: true, message: '请输入手机号码' }, - { pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号码' } - ]} - className="points-exchange-form-item" - > - - - -
- 省份} - rules={[{ required: true, message: '请选择省份' }]} - className="points-exchange-form-item points-exchange-form-item-small" - > - - - - 城市} - rules={[{ required: true, message: '请选择城市' }]} - className="points-exchange-form-item points-exchange-form-item-small" - > - - - - 区/县} - rules={[{ required: true, message: '请选择区/县' }]} - className="points-exchange-form-item points-exchange-form-item-small" - > - - -
- - 详细地址} - rules={[{ required: true, message: '请输入详细地址' }]} - className="points-exchange-form-item" - > - - - - 邮政编码(选填)} - className="points-exchange-form-item" - > - - - - -
- - )} -
-
- ); -} diff --git a/src/pages/PointsMallPage/PointsMallPage.tsx b/src/pages/PointsMallPage/PointsMallPage.tsx new file mode 100644 index 0000000..54cd7db --- /dev/null +++ b/src/pages/PointsMallPage/PointsMallPage.tsx @@ -0,0 +1,336 @@ +import { useEffect, useState } from 'react'; +import { Button, Card, Empty, Input, Select, Space, Spin, Tag, Form, message } from 'antd'; +import { SearchOutlined } from '@ant-design/icons'; +import { PointsMallAPI, PointsMallCategory, PointsMallOverview, PointsMallProduct, PointsMallProductsResponse } from '../../api'; +import { MOCK_OVERVIEW, MOCK_PRODUCTS } from './mocks'; +import type { ExchangeFormValues, SortKey } from './types'; +import ExchangeModal from './components/ExchangeModal'; + +export default function PointsMallPage() { + const [overviewLoading, setOverviewLoading] = useState(false); + const [productsLoading, setProductsLoading] = useState(false); + const [overview, setOverview] = useState(null); + const [categories, setCategories] = useState([]); + + const [categoryId, setCategoryId] = useState('all'); + const [q, setQ] = useState(''); + const [sort, setSort] = useState('popular'); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(24); + const [productsRes, setProductsRes] = useState(null); + + const [exchangeModalVisible, setExchangeModalVisible] = useState(false); + const [selectedProduct, setSelectedProduct] = useState(null); + const [pendingOrderId, setPendingOrderId] = useState(null); + const [exchangeLoading, setExchangeLoading] = useState(false); + const [form] = Form.useForm(); + + const loadOverview = async () => { + setOverviewLoading(true); + try { + const data = await PointsMallAPI.overview(); + setOverview(data); + setCategories(data.categories || []); + if (!data.categories?.some((c) => c.id === categoryId) && data.categories?.[0]?.id) { + setCategoryId(data.categories[0].id); + } + } catch { + message.error('获取积分信息失败,请稍后重试'); + setOverview({ ...MOCK_OVERVIEW, me: { points: 0, level: 'Lv.0' } }); + setCategories(MOCK_OVERVIEW.categories); + } finally { + setOverviewLoading(false); + } + }; + + const loadProducts = async () => { + setProductsLoading(true); + try { + const res = await PointsMallAPI.products({ + categoryId: categoryId === 'all' ? undefined : categoryId, + q, + sort, + page, + pageSize + }); + setProductsRes(res); + } catch { + const filtered = MOCK_PRODUCTS.filter((p) => (categoryId === 'all' ? true : p.categoryId === categoryId)).filter((p) => + q ? (p.name + p.subtitle).toLowerCase().includes(q.toLowerCase()) : true + ); + setProductsRes({ + page, + pageSize, + total: filtered.length, + items: filtered.slice((page - 1) * pageSize, page * pageSize) + }); + } finally { + setProductsLoading(false); + } + }; + + useEffect(() => { + loadOverview(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + loadProducts(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [categoryId, q, sort, page, pageSize]); + + const banner = overview?.banners?.[0]; + const promoEntries = overview?.promoEntries || []; + + const products = productsRes?.items || []; + const total = productsRes?.total || 0; + + const userPoints = overview?.me?.points || 0; + const canAfford = selectedProduct ? userPoints >= selectedProduct.pointsPrice : false; + + const handleExchangeClick = async (product: PointsMallProduct) => { + if (userPoints < product.pointsPrice) return; + + setExchangeLoading(true); + try { + const res = await PointsMallAPI.exchangePrepare(product.id); + setSelectedProduct(product); + setPendingOrderId(res.orderId); + setExchangeModalVisible(true); + form.resetFields(); + setOverview((prev) => { + if (!prev) return prev; + return { ...prev, me: { ...prev.me, points: res.remainingPoints } }; + }); + message.success('积分扣减成功,请填写收件信息完成兑换'); + } catch (e: any) { + message.error(e?.message || '兑换失败,请稍后重试'); + } finally { + setExchangeLoading(false); + } + }; + + const handleExchangeSubmit = async () => { + if (!selectedProduct || !pendingOrderId) return; + + try { + await form.validateFields(); + setExchangeLoading(true); + await PointsMallAPI.exchangeSubmitShipping(pendingOrderId, form.getFieldsValue()); + message.success('兑换成功!我们将尽快为您安排发货'); + setExchangeModalVisible(false); + setPendingOrderId(null); + loadOverview(); + } catch (error: any) { + if (error?.errorFields) return; + message.error(error?.message || '提交失败,请稍后重试'); + } finally { + setExchangeLoading(false); + } + }; + + return ( +
+
+
+
+

积分商城

+

使用积分兑换权益、工具和活动礼包。积分通过 API 调用消费自动累积,1 美元 = 1000 积分。

+
+
+ {overviewLoading ? ( + + ) : ( +
+
+
我的积分
+
{userPoints.toLocaleString()}
+
+ + {String(overview?.me?.level || 'Lv.0')} + +
+ )} +
+
+
+ + +
+
+ 商品分类 + {categories.map((c) => ( + + ))} +
+
+
+ +
+
+
+
+
{banner?.title || '本期活动'}
+
{banner?.subtitle || 'Up to 25% Off'}
+
+ +
+
banner 图片与跳转链接由后端配置
+
+ +
+ {promoEntries.slice(0, 2).map((p) => ( +
+
+
{p.title}
+
{p.subtitle}
+
+ +
+ ))} + {promoEntries.length < 2 &&
促销入口由后端配置
} +
+
+ + +
+ + { + setPage(1); + setQ(e.target.value); + }} + prefix={} + placeholder="搜索商品" + allowClear + className="points-mall-search-input" + /> + { + setPage(1); + setPageSize(v); + }} + options={[ + { value: 12, label: '每页 12' }, + { value: 24, label: '每页 24' }, + { value: 48, label: '每页 48' } + ]} + /> + +
共 {total.toLocaleString()} 件商品
+
+ + {productsLoading ? ( + + ) : products.length === 0 ? ( + + ) : ( +
+ {products.map((p) => ( +
+
+
+
+
+
{p.name}
+
{p.subtitle}
+
+ {p.tags?.length ? ( + + {p.tags[0]} + + ) : null} +
+
+
+ {Number(p.pointsPrice).toLocaleString()} + 积分 +
+ +
+
+ 库存 {p.stock} + 已兑 {p.sold} +
+
+
+ ))} +
+ )} + + {!!productsRes && ( +
+ + + + 第 {page} 页 + + + +
+ )} + + + { + setExchangeModalVisible(false); + setPendingOrderId(null); + }} + /> +
+ ); +} + diff --git a/src/pages/PointsMallPage/components/ExchangeModal.tsx b/src/pages/PointsMallPage/components/ExchangeModal.tsx new file mode 100644 index 0000000..a613603 --- /dev/null +++ b/src/pages/PointsMallPage/components/ExchangeModal.tsx @@ -0,0 +1,128 @@ +import { Button, Form, Input, Modal, Select } from 'antd'; +import type { FormInstance } from 'antd'; +import type { PointsMallProduct } from '../../../api'; +import type { ExchangeFormValues } from '../types'; + +export default function ExchangeModal(props: { + open: boolean; + product: PointsMallProduct | null; + canAfford: boolean; + userPoints: number; + loading: boolean; + form: FormInstance; + onSubmit: () => void; + onCancel: () => void; +}) { + const { open, product, canAfford, userPoints, loading, form, onSubmit, onCancel } = props; + + return ( + + {product && ( + <> +
+
{product.name}
+
{product.pointsPrice.toLocaleString()} 积分
+
+ + {!canAfford && ( +
+ 积分不足!当前余额:{userPoints.toLocaleString()} 积分,还需:{(product.pointsPrice - userPoints).toLocaleString()} 积分 +
+ )} + +
+ 收货人姓名} + rules={[{ required: true, message: '请输入收货人姓名' }]} + className="points-exchange-form-item" + > + + + + 手机号码} + rules={[{ required: true, message: '请输入手机号码' }, { pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号码' }]} + className="points-exchange-form-item" + > + + + +
+ 省份} + rules={[{ required: true, message: '请选择省份' }]} + className="points-exchange-form-item points-exchange-form-item-small" + > + + + + 城市} + rules={[{ required: true, message: '请选择城市' }]} + className="points-exchange-form-item points-exchange-form-item-small" + > + + + + 区/县} + rules={[{ required: true, message: '请选择区/县' }]} + className="points-exchange-form-item points-exchange-form-item-small" + > + + +
+ + 详细地址} + rules={[{ required: true, message: '请输入详细地址' }]} + className="points-exchange-form-item" + > + + + + 邮政编码(选填)} + className="points-exchange-form-item" + > + + + + +
+ + )} +
+ ); +} + diff --git a/src/pages/PointsMallPage/mocks.ts b/src/pages/PointsMallPage/mocks.ts new file mode 100644 index 0000000..c88552e --- /dev/null +++ b/src/pages/PointsMallPage/mocks.ts @@ -0,0 +1,31 @@ +import type { PointsMallOverview, PointsMallProduct } from '../../api'; + +export const MOCK_OVERVIEW: PointsMallOverview = { + me: { points: 1280, level: 'Lv.2' }, + categories: [ + { id: 'all', name: '全部', sort: 0 }, + { id: 'digital', name: '虚拟权益', sort: 1 }, + { id: 'tool', name: '工具周边', sort: 2 }, + { id: 'gift', name: '礼品卡券', sort: 3 }, + { id: 'limited', name: '限时活动', sort: 4 } + ], + announcements: [], + banners: [{ id: 'b1', title: '本期活动', subtitle: 'Up to 25% Off', imageUrl: '', linkUrl: '' }], + promoEntries: [ + { id: 'p1', title: '促销活动', subtitle: '本周精选', linkUrl: '' }, + { id: 'p2', title: '积分任务', subtitle: '快速涨积分', linkUrl: '' } + ] +}; + +export const MOCK_PRODUCTS: PointsMallProduct[] = Array.from({ length: 12 }).map((_, i) => ({ + id: String(i + 1), + categoryId: i % 2 === 0 ? 'digital' : 'tool', + name: `商品 ${i + 1}`, + subtitle: '这里是商品简短描述', + coverUrl: '', + pointsPrice: 199 + i * 10, + stock: 99, + sold: 12 + i, + tags: i % 3 === 0 ? ['限时'] : i % 3 === 1 ? ['热卖'] : [] +})); + diff --git a/src/pages/PointsMallPage/types.ts b/src/pages/PointsMallPage/types.ts new file mode 100644 index 0000000..fe1416f --- /dev/null +++ b/src/pages/PointsMallPage/types.ts @@ -0,0 +1,19 @@ +import type { PointsMallOverview, PointsMallProduct } from '../../api'; + +export type SortKey = 'popular' | 'price_asc' | 'price_desc' | 'newest'; + +export interface ExchangeFormValues { + recipientName: string; + phone: string; + province: string; + city: string; + district: string; + address: string; + zipCode?: string; +} + +export interface PointsMallMockData { + overview: PointsMallOverview; + products: PointsMallProduct[]; +} + diff --git a/src/pages/PromptLibraryPage.tsx b/src/pages/PromptLibraryPage.tsx index 82d3541..97f1fed 100644 --- a/src/pages/PromptLibraryPage.tsx +++ b/src/pages/PromptLibraryPage.tsx @@ -1,499 +1,2 @@ -import { useEffect, useState } from 'react'; -import { - AppstoreOutlined, - ClockCircleOutlined, - CopyOutlined, - EditOutlined, - GlobalOutlined, - LockOutlined, - PlusOutlined, - SearchOutlined -} from '@ant-design/icons'; -import { - Button, - Input, - Modal, - Form, - Select, - Space, - Tag, - App as AntApp, - Empty, - Tooltip, - Popconfirm, - Spin -} from 'antd'; -import { PromptTemplate, PromptTemplateAPI } from '../api'; +export { default } from './PromptLibraryPage/PromptLibraryPage'; -const CATEGORIES = ['通用', '编程', '写作', '翻译', '分析', '客服', '其他']; -const SCOPE_OPTIONS: Array<{ key: 'all' | 'mine' | 'public'; label: string }> = [ - { key: 'all', label: '全部模板' }, - { key: 'mine', label: '我的沉淀' }, - { key: 'public', label: '公开灵感' } -]; - -const CATEGORY_STYLES: Record = { - 通用: { bg: 'rgba(8, 145, 178, 0.10)', text: '#0f766e' }, - 编程: { bg: 'rgba(59, 130, 246, 0.10)', text: '#1d4ed8' }, - 写作: { bg: 'rgba(217, 70, 239, 0.10)', text: '#a21caf' }, - 翻译: { bg: 'rgba(249, 115, 22, 0.10)', text: '#c2410c' }, - 分析: { bg: 'rgba(14, 165, 233, 0.10)', text: '#0369a1' }, - 客服: { bg: 'rgba(34, 197, 94, 0.10)', text: '#15803d' }, - 其他: { bg: 'rgba(100, 116, 139, 0.12)', text: '#475569' } -}; - -export default function PromptLibraryPage({ - onSelect -}: { - onSelect?: (tpl: PromptTemplate) => void; -}) { - const { message } = AntApp.useApp(); - const [scope, setScope] = useState<'all' | 'mine' | 'public'>('all'); - const [q, setQ] = useState(''); - const [list, setList] = useState([]); - const [loading, setLoading] = useState(false); - const [editorOpen, setEditorOpen] = useState(false); - const [editing, setEditing] = useState(null); - const [form] = Form.useForm(); - - const load = async () => { - setLoading(true); - try { - const data = await PromptTemplateAPI.list({ scope, q }); - setList(data); - } catch (e: any) { - message.error('加载失败:' + (e?.message ?? e)); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - load(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [scope]); - - const openCreate = () => { - setEditing(null); - form.resetFields(); - form.setFieldsValue({ visibility: 'private', category: '通用' }); - setEditorOpen(true); - }; - const openEdit = (t: PromptTemplate) => { - setEditing(t); - form.setFieldsValue({ - title: t.title, - body: t.body, - category: t.category, - visibility: t.visibility - }); - setEditorOpen(true); - }; - const onSave = async () => { - const v = await form.validateFields(); - try { - if (editing) { - await PromptTemplateAPI.update(editing.id, v); - message.success('已更新'); - } else { - await PromptTemplateAPI.create(v); - message.success('已创建'); - } - setEditorOpen(false); - load(); - } catch (e: any) { - message.error('保存失败:' + (e?.response?.data?.error ?? e?.message ?? e)); - } - }; - const onUse = async (t: PromptTemplate) => { - await PromptTemplateAPI.use(t.id).catch(() => {}); - if (onSelect) onSelect(t); - else { - navigator.clipboard?.writeText(t.body).then(() => message.success('内容已复制到剪贴板')); - } - }; - const onDelete = async (t: PromptTemplate) => { - await PromptTemplateAPI.remove(t.id); - message.success('已删除'); - load(); - }; - - const formatDate = (ts: number) => - new Date(ts).toLocaleDateString('zh-CN', { - month: 'numeric', - day: 'numeric' - }); - - return ( -
-
-
-
-
-
- - 灵感模板中心 -
- -

- Prompt 模板库 -

-
- 把高质量提示词沉淀成可复用模板,像挑选灵感卡片一样快速使用,而不是面对一页页生硬的配置项。 -
-
- - -
- -
- setQ(e.target.value)} - onPressEnter={load} - prefix={} - suffix={ - q ? ( - - ) : null - } - style={{ height: 48, borderRadius: 14, background: 'rgba(255,255,255,0.88)' }} - allowClear - /> - -
- {SCOPE_OPTIONS.map((item) => { - const active = scope === item.key; - return ( - - ); - })} -
-
-
- - {loading ? ( -
- -
- ) : list.length === 0 ? ( -
- -
- ) : ( -
- {list.map((t) => ( -
-
-
- - {t.category || '其他'} - - : } - style={{ - margin: 0, - borderRadius: 999, - paddingInline: 10, - height: 28, - lineHeight: '28px', - fontSize: 12, - background: 'var(--color-surface-2)', - color: 'var(--color-text-secondary)' - }} - > - {t.visibility === 'public' ? '公开灵感' : '仅自己可见'} - -
- - - - -
- -
-
-
- {t.title} -
-
- by {t.ownerName || '我'} -
-
- -
- 使用 {t.useCount} -
-
- -
-
-
- {t.body.slice(0, 220)} - {t.body.length > 220 ? '…' : ''} -
-
- -
- - 最近更新于 {formatDate(t.updatedAt)} - - - - onDelete(t)}> - - - -
-
- ))} -
- )} - - setEditorOpen(false)} - onOk={onSave} - width={680} - okText="保存" - cancelText="取消" - destroyOnHidden - > -
- - - - - - - - - - - - -
-
-
-
- -
-
- ); -} diff --git a/src/pages/PromptLibraryPage/PromptLibraryPage.tsx b/src/pages/PromptLibraryPage/PromptLibraryPage.tsx new file mode 100644 index 0000000..9e80dbf --- /dev/null +++ b/src/pages/PromptLibraryPage/PromptLibraryPage.tsx @@ -0,0 +1,112 @@ +import { useEffect, useState } from 'react'; +import { App as AntApp, Empty, Form } from 'antd'; +import type { PromptTemplate } from '../../api'; +import { PromptTemplateAPI } from '../../api'; +import PromptLibraryHero from './components/PromptLibraryHero'; +import PromptTemplateGrid from './components/PromptTemplateGrid'; +import PromptTemplateEditorModal from './components/PromptTemplateEditorModal'; + +export default function PromptLibraryPage(props: { onSelect?: (tpl: PromptTemplate) => void }) { + const { onSelect } = props; + const { message } = AntApp.useApp(); + const [scope, setScope] = useState<'all' | 'mine' | 'public'>('all'); + const [q, setQ] = useState(''); + const [list, setList] = useState([]); + const [loading, setLoading] = useState(false); + const [editorOpen, setEditorOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [form] = Form.useForm(); + + const load = async () => { + setLoading(true); + try { + const data = await PromptTemplateAPI.list({ scope, q }); + setList(data); + } catch (e: any) { + message.error('加载失败:' + (e?.message ?? e)); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + load(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [scope]); + + const openCreate = () => { + setEditing(null); + form.resetFields(); + form.setFieldsValue({ visibility: 'private', category: '通用' }); + setEditorOpen(true); + }; + + const openEdit = (t: PromptTemplate) => { + setEditing(t); + form.setFieldsValue({ + title: t.title, + body: t.body, + category: t.category, + visibility: t.visibility + }); + setEditorOpen(true); + }; + + const onSave = async () => { + const v = await form.validateFields(); + try { + if (editing) { + await PromptTemplateAPI.update(editing.id, v); + message.success('已更新'); + } else { + await PromptTemplateAPI.create(v); + message.success('已创建'); + } + setEditorOpen(false); + load(); + } catch (e: any) { + message.error('保存失败:' + (e?.response?.data?.error ?? e?.message ?? e)); + } + }; + + const onUse = async (t: PromptTemplate) => { + await PromptTemplateAPI.use(t.id).catch(() => {}); + if (onSelect) onSelect(t); + else { + navigator.clipboard?.writeText(t.body).then(() => message.success('内容已复制到剪贴板')); + } + }; + + const onDelete = async (t: PromptTemplate) => { + await PromptTemplateAPI.remove(t.id); + message.success('已删除'); + load(); + }; + + const formatDate = (ts: number) => + new Date(ts).toLocaleDateString('zh-CN', { + month: 'numeric', + day: 'numeric' + }); + + return ( +
+
+ + + setEditorOpen(false)} onOk={onSave} /> +
+
+ +
+
+ ); +} diff --git a/src/pages/PromptLibraryPage/components/PromptLibraryHero.tsx b/src/pages/PromptLibraryPage/components/PromptLibraryHero.tsx new file mode 100644 index 0000000..e35d297 --- /dev/null +++ b/src/pages/PromptLibraryPage/components/PromptLibraryHero.tsx @@ -0,0 +1,108 @@ +import { AppstoreOutlined, PlusOutlined, SearchOutlined } from '@ant-design/icons'; +import { Button, Input } from 'antd'; +import { SCOPE_OPTIONS } from '../constants'; + +export default function PromptLibraryHero(props: { + scope: 'all' | 'mine' | 'public'; + q: string; + onChangeScope: (v: 'all' | 'mine' | 'public') => void; + onChangeQ: (v: string) => void; + onSearch: () => void; + onCreate: () => void; +}) { + const { scope, q, onChangeScope, onChangeQ, onSearch, onCreate } = props; + + return ( +
+
+
+
+ + 灵感模板中心 +
+ +

+ Prompt 模板库 +

+
+ 把高质量提示词沉淀成可复用模板,像挑选灵感卡片一样快速使用,而不是面对一页页生硬的配置项。 +
+
+ + +
+ +
+ onChangeQ(e.target.value)} + onPressEnter={onSearch} + prefix={} + suffix={ + q ? ( + + ) : null + } + style={{ height: 48, borderRadius: 14, background: 'rgba(255,255,255,0.88)' }} + allowClear + /> + +
+ {SCOPE_OPTIONS.map((item) => { + const active = scope === item.key; + return ( + + ); + })} +
+
+
+ ); +} + diff --git a/src/pages/PromptLibraryPage/components/PromptTemplateCard.tsx b/src/pages/PromptLibraryPage/components/PromptTemplateCard.tsx new file mode 100644 index 0000000..d80cec4 --- /dev/null +++ b/src/pages/PromptLibraryPage/components/PromptTemplateCard.tsx @@ -0,0 +1,151 @@ +import { ClockCircleOutlined, CopyOutlined, EditOutlined, GlobalOutlined, LockOutlined } from '@ant-design/icons'; +import { Button, Popconfirm, Space, Tag, Tooltip } from 'antd'; +import type { PromptTemplate } from '../../../api'; +import { CATEGORY_STYLES } from '../constants'; + +export default function PromptTemplateCard(props: { + template: PromptTemplate; + hasOnSelect: boolean; + onUse: () => void; + onEdit: () => void; + onDelete: () => void; + formatDate: (ts: number) => string; +}) { + const { template: t, hasOnSelect, onUse, onEdit, onDelete, formatDate } = props; + const tone = CATEGORY_STYLES[t.category || '其他'] || CATEGORY_STYLES['其他']; + + return ( +
+
+
+ + {t.category || '其他'} + + : } + style={{ + margin: 0, + borderRadius: 999, + paddingInline: 10, + height: 28, + lineHeight: '28px', + fontSize: 12, + background: 'var(--color-surface-2)', + color: 'var(--color-text-secondary)' + }} + > + {t.visibility === 'public' ? '公开灵感' : '仅自己可见'} + +
+ + + + +
+ +
+
+
{t.title}
+
by {t.ownerName || '我'}
+
+ +
+ 使用 {t.useCount} +
+
+ +
+
+
+ {t.body.slice(0, 220)} + {t.body.length > 220 ? '…' : ''} +
+
+ +
+ + 最近更新于 {formatDate(t.updatedAt)} + + + + + + + +
+
+ ); +} + diff --git a/src/pages/PromptLibraryPage/components/PromptTemplateEditorModal.tsx b/src/pages/PromptLibraryPage/components/PromptTemplateEditorModal.tsx new file mode 100644 index 0000000..b2e192d --- /dev/null +++ b/src/pages/PromptLibraryPage/components/PromptTemplateEditorModal.tsx @@ -0,0 +1,57 @@ +import { Form, Input, Modal, Select } from 'antd'; +import type { FormInstance } from 'antd'; +import type { PromptTemplate } from '../../../api'; +import { CATEGORIES } from '../constants'; + +export default function PromptTemplateEditorModal(props: { + open: boolean; + editing: PromptTemplate | null; + form: FormInstance; + onCancel: () => void; + onOk: () => void; +}) { + const { open, editing, form, onCancel, onOk } = props; + + return ( + +
+ + + + + + + + + + + + +
+
+ ); +} + diff --git a/src/pages/PromptLibraryPage/components/PromptTemplateGrid.tsx b/src/pages/PromptLibraryPage/components/PromptTemplateGrid.tsx new file mode 100644 index 0000000..30b306a --- /dev/null +++ b/src/pages/PromptLibraryPage/components/PromptTemplateGrid.tsx @@ -0,0 +1,58 @@ +import { Empty, Spin } from 'antd'; +import type { PromptTemplate } from '../../../api'; +import PromptTemplateCard from './PromptTemplateCard'; + +export default function PromptTemplateGrid(props: { + loading: boolean; + list: PromptTemplate[]; + hasOnSelect: boolean; + onUse: (t: PromptTemplate) => void; + onEdit: (t: PromptTemplate) => void; + onDelete: (t: PromptTemplate) => void; + formatDate: (ts: number) => string; +}) { + const { loading, list, hasOnSelect, onUse, onEdit, onDelete, formatDate } = props; + + if (loading) { + return ( +
+ +
+ ); + } + + if (list.length === 0) { + return ( +
+ +
+ ); + } + + return ( +
+ {list.map((t) => ( + onUse(t)} + onEdit={() => onEdit(t)} + onDelete={() => onDelete(t)} + formatDate={formatDate} + /> + ))} +
+ ); +} + diff --git a/src/pages/PromptLibraryPage/constants.ts b/src/pages/PromptLibraryPage/constants.ts new file mode 100644 index 0000000..498553c --- /dev/null +++ b/src/pages/PromptLibraryPage/constants.ts @@ -0,0 +1,18 @@ +export const CATEGORIES = ['通用', '编程', '写作', '翻译', '分析', '客服', '其他']; + +export const SCOPE_OPTIONS: Array<{ key: 'all' | 'mine' | 'public'; label: string }> = [ + { key: 'all', label: '全部模板' }, + { key: 'mine', label: '我的沉淀' }, + { key: 'public', label: '公开灵感' } +]; + +export const CATEGORY_STYLES: Record = { + 通用: { bg: 'rgba(8, 145, 178, 0.10)', text: '#0f766e' }, + 编程: { bg: 'rgba(59, 130, 246, 0.10)', text: '#1d4ed8' }, + 写作: { bg: 'rgba(217, 70, 239, 0.10)', text: '#a21caf' }, + 翻译: { bg: 'rgba(249, 115, 22, 0.10)', text: '#c2410c' }, + 分析: { bg: 'rgba(14, 165, 233, 0.10)', text: '#0369a1' }, + 客服: { bg: 'rgba(34, 197, 94, 0.10)', text: '#15803d' }, + 其他: { bg: 'rgba(100, 116, 139, 0.12)', text: '#475569' } +}; + diff --git a/src/pages/StatsPage.tsx b/src/pages/StatsPage.tsx index 6f1ee6b..98326ea 100644 --- a/src/pages/StatsPage.tsx +++ b/src/pages/StatsPage.tsx @@ -27,7 +27,8 @@ export default function StatsPage() { const enhanced = res as StatsOverviewWithPoints; // 模拟积分数据:1 USD = 1000 积分 enhanced.totalSpentUSD = tokenData?.costUSD || 0; - enhanced.totalPoints = Math.floor(enhanced.totalSpentUSD * 1000); + const spent = enhanced.totalSpentUSD || 0; + enhanced.totalPoints = Math.floor(spent * 1000); enhanced.pointsEarned = enhanced.totalPoints; setData(enhanced); if (!tokenAgentId && res.topAgents?.[0]?.id) { @@ -48,11 +49,12 @@ export default function StatsPage() { // 更新总消费和积分 setData((prev) => { if (prev) { + const spent = res.costUSD || 0; return { ...prev, - totalSpentUSD: res.costUSD, - totalPoints: Math.floor(res.costUSD * 1000), - pointsEarned: Math.floor(res.costUSD * 1000) + totalSpentUSD: spent, + totalPoints: Math.floor(spent * 1000), + pointsEarned: Math.floor(spent * 1000) }; } return prev; diff --git a/src/pages/WorkflowsPage.tsx b/src/pages/WorkflowsPage.tsx index fc8ca61..d5467ef 100644 --- a/src/pages/WorkflowsPage.tsx +++ b/src/pages/WorkflowsPage.tsx @@ -1,780 +1,2 @@ -import { useEffect, useMemo, useState } from 'react'; -import { - ApartmentOutlined, - ClockCircleOutlined, - PlayCircleOutlined, - PlusOutlined, - ThunderboltOutlined -} from '@ant-design/icons'; -import { - Button, - Card, - Drawer, - Empty, - Form, - Input, - Modal, - Select, - Space, - Switch, - Table, - Tag, - Tooltip, - Typography, - App as AntApp, - Popconfirm, - Tabs -} from 'antd'; -import { - Workflow, - WorkflowAPI, - WorkflowGraph, - WorkflowNode, - WorkflowNodeType, - WorkflowRun, - WorkflowRunDetail, - streamWorkflowRun -} from '../api'; +export { default } from './WorkflowsPage/WorkflowsPage'; -const { Text, Paragraph } = Typography; - -const NODE_TYPE_LABEL: Record = { - agent: '🤖 Agent', - skill: '🛠️ Skill', - http: '🌐 HTTP', - transform: '🔧 Transform', - branch: '🔀 Branch' -}; - -const STATUS_COLOR: Record = { - running: 'blue', - success: 'green', - failed: 'red', - aborted: 'orange', - skipped: 'default' -}; - -const SAMPLE_GRAPH: WorkflowGraph = { - entry: 'fetch', - variables: { topic: 'Go 1.23 新特性' }, - nodes: [ - { - id: 'fetch', - type: 'agent', - name: '搜集资料', - config: { - agentId: '', - prompt: '请围绕主题"{{vars.topic}}"列出 5 条最近的关键信息,要点形式。' - }, - next: 'summarize' - }, - { - id: 'summarize', - type: 'agent', - name: '总结成稿', - config: { - agentId: '', - prompt: '基于以下要点写一段 200 字日报:\n{{steps.fetch.output.text}}' - }, - next: '' - } - ] -}; - -export default function WorkflowsPage() { - const { message, modal } = AntApp.useApp(); - const [list, setList] = useState([]); - const [loading, setLoading] = useState(false); - const [editorOpen, setEditorOpen] = useState(false); - const [editing, setEditing] = useState(null); - const [runsOpen, setRunsOpen] = useState(false); - const [runsFor, setRunsFor] = useState(null); - - const load = async () => { - setLoading(true); - try { - const data = await WorkflowAPI.list(); - setList(data); - } catch (e: any) { - message.error('加载失败:' + (e?.message ?? e)); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - load(); - }, []); - - const onCreate = () => { - setEditing({ - id: '', - name: '新工作流', - description: '', - graph: SAMPLE_GRAPH, - scheduleCron: '', - scheduleEnabled: false, - enabled: true, - lastRunAt: 0, - runCount: 0, - createdAt: 0, - updatedAt: 0 - }); - setEditorOpen(true); - }; - - const onEdit = (w: Workflow) => { - setEditing(w); - setEditorOpen(true); - }; - - const onDelete = async (w: Workflow) => { - await WorkflowAPI.remove(w.id); - message.success('已删除'); - load(); - }; - - return ( -
-
-
-
-
-
- - 自动化编排中心 -
-

工作流编排

-
- 让多个 Agent、技能、HTTP 请求与数据转换连成一条可运行的自动化链路。这里更像一个流程画廊,而不是传统表格后台。 -
-
- -
- -
- {[ - { label: '工作流数量', value: list.length, tone: 'rgba(8, 145, 178, 0.10)', color: 'var(--color-brand)' }, - { label: '启用中', value: list.filter((item) => item.enabled).length, tone: 'rgba(34, 197, 94, 0.10)', color: 'var(--color-success)' }, - { label: '定时运行', value: list.filter((item) => item.scheduleEnabled && item.scheduleCron).length, tone: 'rgba(249, 115, 22, 0.10)', color: 'var(--color-warning)' } - ].map((item) => ( -
-
{item.label}
-
- {item.value} - - 实时状态 - -
-
- ))} -
-
- - {loading ? null : list.length === 0 ? ( -
- -
- ) : ( -
- {list.map((r) => ( -
-
-
-
{r.name}
-
- {r.description || '还没有补充描述,可以说明这个流程负责什么自动化任务。'} -
-
- - {r.enabled ? '已启用' : '已停用'} - -
- -
-
-
节点数
-
{r.graph?.nodes?.length ?? 0}
-
-
-
运行次数
-
{r.runCount}
-
-
-
触发方式
-
- {r.scheduleEnabled && r.scheduleCron ? '定时' : '手动'} -
-
-
- -
-
- - 调度与运行 -
- {r.scheduleEnabled && r.scheduleCron ? ( - - cron: {r.scheduleCron} - - ) : ( - - 手动触发 - - )} -
- 上次运行 {r.lastRunAt ? new Date(r.lastRunAt).toLocaleString() : '尚未运行'} -
-
- -
- - - onDelete(r)}> - - -
-
- ))} -
- )} - - {editing && ( - setEditorOpen(false)} - onSaved={() => { - setEditorOpen(false); - load(); - }} - /> - )} - - {runsFor && ( - setRunsOpen(false)} - /> - )} -
-
- -
-
- ); -} - -// ================== 编辑器 ================== - -function WorkflowEditor({ - open, workflow, onClose, onSaved -}: { - open: boolean; - workflow: Workflow; - onClose: () => void; - onSaved: () => void; -}) { - const { message } = AntApp.useApp(); - const [form] = Form.useForm(); - const [graphText, setGraphText] = useState(JSON.stringify(workflow.graph, null, 2)); - const [saving, setSaving] = useState(false); - - useEffect(() => { - form.setFieldsValue({ - name: workflow.name, - description: workflow.description, - scheduleCron: workflow.scheduleCron, - scheduleEnabled: workflow.scheduleEnabled, - enabled: workflow.enabled - }); - setGraphText(JSON.stringify(workflow.graph, null, 2)); - }, [workflow]); - - const onSave = async () => { - const values = await form.validateFields(); - let graph: WorkflowGraph; - try { - graph = JSON.parse(graphText); - } catch (e: any) { - message.error('Graph JSON 解析失败:' + e.message); - return; - } - if (!graph.entry || !Array.isArray(graph.nodes)) { - message.error('Graph 必须包含 entry + nodes 数组'); - return; - } - setSaving(true); - try { - const payload = { ...values, graph }; - if (workflow.id) { - await WorkflowAPI.update(workflow.id, payload); - } else { - await WorkflowAPI.create(payload); - } - message.success('已保存'); - onSaved(); - } catch (e: any) { - message.error('保存失败:' + (e?.message ?? e)); - } finally { - setSaving(false); - } - }; - - return ( - - 保存 - - } - > -
- - - - - - - - - - - - - - - - Cron 表达式 - 5 段:分 时 日 月 周,例如 "*/30 * * * *" - - } - name="scheduleCron" - > - - -
- - { - try { - const g: WorkflowGraph = JSON.parse(graphText); - g.nodes = [...(g.nodes || []), node]; - if (!g.entry) g.entry = node.id; - setGraphText(JSON.stringify(g, null, 2)); - } catch { - message.error('Graph JSON 不合法,无法追加'); - } - }} - /> - - Graph JSON(节点列表)} - style={{ marginTop: 12, borderRadius: 16, boxShadow: 'var(--shadow-xs)' }} - extra={ - - - - } - > - setGraphText(e.target.value)} - style={{ fontFamily: 'ui-monospace, monospace', fontSize: 12 }} - /> - - 支持模板 {'{{input.x}} {{vars.x}} {{steps..output...}}'}
- 节点类型:agent / skill / http / transform / branch;branch 节点用 condition(返回 bool 的 JS 片段),引擎按 next/elseNext 走。 -
-
-
- ); -} - -function NodeQuickAdd({ onAdd }: { onAdd: (n: WorkflowNode) => void }) { - const [type, setType] = useState('agent'); - const [id, setId] = useState(''); - return ( - - - setId(e.target.value)} - style={{ width: 200 }} - /> - - - - ); -} - -function defaultConfig(type: WorkflowNodeType): Record { - switch (type) { - case 'agent': - return { agentId: '', prompt: '请帮我处理:{{input.text}}' }; - case 'skill': - return { skillId: '', args: {} }; - case 'http': - return { method: 'GET', url: 'https://example.com/api', headers: {}, body: null }; - case 'transform': - return { code: 'return { value: input.text };' }; - case 'branch': - return { condition: 'steps.prev?.output?.value === true' }; - } -} - -// ================== 运行抽屉 ================== - -function RunsDrawer({ - open, workflow, onClose -}: { - open: boolean; - workflow: Workflow; - onClose: () => void; -}) { - const { message } = AntApp.useApp(); - const [tab, setTab] = useState('run'); - const [runs, setRuns] = useState([]); - const [detail, setDetail] = useState(null); - const [streaming, setStreaming] = useState(false); - const [steps, setSteps] = useState([]); - const [finalRun, setFinalRun] = useState(null); - const [inputJson, setInputJson] = useState('{}'); - - const loadRuns = async () => { - try { - const data = await WorkflowAPI.listRuns(workflow.id, 30); - setRuns(data); - } catch (e: any) { - message.error('加载历史失败:' + (e?.message ?? e)); - } - }; - - useEffect(() => { - if (open) loadRuns(); - }, [open]); - - const onRunStream = () => { - let input: any = undefined; - if (inputJson.trim()) { - try { - input = JSON.parse(inputJson); - } catch (e: any) { - message.error('input JSON 不合法'); - return; - } - } - setSteps([]); - setFinalRun(null); - setStreaming(true); - streamWorkflowRun(workflow.id, input, { - onStepStart: (d) => { - setSteps((s) => [...s, { ...d, status: 'running' }]); - }, - onStepFinish: (d) => { - setSteps((s) => { - const idx = [...s].reverse().findIndex((x) => x.nodeId === d.nodeId && x.status === 'running'); - if (idx === -1) return [...s, d]; - const realIdx = s.length - 1 - idx; - const cp = [...s]; - cp[realIdx] = d; - return cp; - }); - }, - onRunFinish: (d) => { - setStreaming(false); - setFinalRun(d); - loadRuns(); - }, - onError: (msg) => { - setStreaming(false); - message.error(msg); - } - }); - }; - - const showDetail = async (runId: string) => { - try { - const d = await WorkflowAPI.getRun(runId); - setDetail(d); - } catch (e: any) { - message.error('加载详情失败:' + (e?.message ?? e)); - } - }; - - return ( - { setDetail(null); onClose(); }} - styles={{ body: { background: '#fcfcfd' }, header: { background: '#fff', borderBottom: '1px solid var(--color-border)' } }} - > - -
- - setInputJson(e.target.value)} - placeholder='{"text":"hello"}' - /> - -
- - - - - - {steps.length > 0 && ( - - {steps.map((s, i) => ( -
- - {s.status} - {s.nodeId} - {s.nodeType} - {s.durationMs != null && {s.durationMs}ms} - - {s.error && {s.error}} - {s.output != null && ( -
-                            {JSON.stringify(s.output, null, 2)}
-                          
- )} -
- ))} -
- )} - - {finalRun && ( - -
-                      {JSON.stringify(finalRun, null, 2)}
-                    
-
- )} -
- ) - }, - { - key: 'history', - label: `历史 (${runs.length})`, - children: ( -
- - - rowKey="id" - size="small" - pagination={false} - dataSource={runs} - columns={[ - { title: '状态', dataIndex: 'status', render: (s) => {s}, width: 100 }, - { title: '触发', dataIndex: 'trigger', width: 80 }, - { title: '开始', dataIndex: 'startedAt', render: (v) => new Date(v).toLocaleString(), width: 180 }, - { title: '耗时', dataIndex: 'durationMs', render: (v) => `${v} ms`, width: 100 }, - { title: '', render: (_, r) => } - ]} - /> - {detail && ( - setDetail(null)} - onOk={() => setDetail(null)} - width={680} - > -

状态:{detail.status}{detail.error && {detail.error}}

-

耗时:{detail.durationMs} ms

-

Input:

-
{JSON.stringify(detail.input, null, 2)}
-

Steps:

- {detail.steps.map((s) => ( - - - {s.status} - {s.nodeId} - {s.nodeType} - {s.durationMs} ms - - {s.error &&

{s.error}

} - {s.output != null && ( -
-                            {JSON.stringify(s.output, null, 2)}
-                          
- )} -
- ))} -
- )} -
- ) - } - ]} - /> - - ); -} diff --git a/src/pages/WorkflowsPage/WorkflowsPage.tsx b/src/pages/WorkflowsPage/WorkflowsPage.tsx new file mode 100644 index 0000000..df6992b --- /dev/null +++ b/src/pages/WorkflowsPage/WorkflowsPage.tsx @@ -0,0 +1,287 @@ +import { useEffect, useState } from 'react'; +import { ApartmentOutlined, ClockCircleOutlined, PlayCircleOutlined, PlusOutlined, ThunderboltOutlined } from '@ant-design/icons'; +import { Button, Empty, Tag, App as AntApp, Popconfirm } from 'antd'; +import type { Workflow } from '../../api'; +import { WorkflowAPI } from '../../api'; +import { SAMPLE_GRAPH } from './constants'; +import WorkflowEditorDrawer from './components/WorkflowEditorDrawer'; +import RunsDrawer from './components/RunsDrawer'; + +export default function WorkflowsPage() { + const { message } = AntApp.useApp(); + const [list, setList] = useState([]); + const [loading, setLoading] = useState(false); + const [editorOpen, setEditorOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [runsOpen, setRunsOpen] = useState(false); + const [runsFor, setRunsFor] = useState(null); + + const load = async () => { + setLoading(true); + try { + const data = await WorkflowAPI.list(); + setList(data); + } catch (e: any) { + message.error('加载失败:' + (e?.message ?? e)); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + load(); + }, []); + + const onCreate = () => { + setEditing({ + id: '', + name: '新工作流', + description: '', + graph: SAMPLE_GRAPH, + scheduleCron: '', + scheduleEnabled: false, + enabled: true, + lastRunAt: 0, + runCount: 0, + createdAt: 0, + updatedAt: 0 + }); + setEditorOpen(true); + }; + + const onEdit = (w: Workflow) => { + setEditing(w); + setEditorOpen(true); + }; + + const onDelete = async (w: Workflow) => { + await WorkflowAPI.remove(w.id); + message.success('已删除'); + load(); + }; + + return ( +
+
+
+
+
+
+ + 自动化编排中心 +
+

+ 工作流编排 +

+
+ 让多个 Agent、技能、HTTP 请求与数据转换连成一条可运行的自动化链路。这里更像一个流程画廊,而不是传统表格后台。 +
+
+ +
+ +
+ {[ + { label: '工作流数量', value: list.length, tone: 'rgba(8, 145, 178, 0.10)', color: 'var(--color-brand)' }, + { label: '启用中', value: list.filter((item) => item.enabled).length, tone: 'rgba(34, 197, 94, 0.10)', color: 'var(--color-success)' }, + { + label: '定时运行', + value: list.filter((item) => item.scheduleEnabled && item.scheduleCron).length, + tone: 'rgba(249, 115, 22, 0.10)', + color: 'var(--color-warning)' + } + ].map((item) => ( +
+
{item.label}
+
+ {item.value} + + 实时状态 + +
+
+ ))} +
+
+ + {loading ? null : list.length === 0 ? ( +
+ +
+ ) : ( +
+ {list.map((r) => ( +
+
+
+
{r.name}
+
+ {r.description || '还没有补充描述,可以说明这个流程负责什么自动化任务。'} +
+
+ + {r.enabled ? '已启用' : '已停用'} + +
+ +
+
+
节点数
+
{r.graph?.nodes?.length ?? 0}
+
+
+
运行次数
+
{r.runCount}
+
+
+
触发方式
+
{r.scheduleEnabled && r.scheduleCron ? '定时' : '手动'}
+
+
+ +
+
+ + 调度与运行 +
+ {r.scheduleEnabled && r.scheduleCron ? ( + + cron: {r.scheduleCron} + + ) : ( + + 手动触发 + + )} +
+ 上次运行 {r.lastRunAt ? new Date(r.lastRunAt).toLocaleString() : '尚未运行'} +
+
+ +
+ + + onDelete(r)}> + + +
+
+ ))} +
+ )} + + {editing && ( + setEditorOpen(false)} + onSaved={() => { + setEditorOpen(false); + load(); + }} + /> + )} + + {runsFor && setRunsOpen(false)} />} +
+ +
+ +
+
+ ); +} + diff --git a/src/pages/WorkflowsPage/components/RunsDrawer.tsx b/src/pages/WorkflowsPage/components/RunsDrawer.tsx new file mode 100644 index 0000000..957c772 --- /dev/null +++ b/src/pages/WorkflowsPage/components/RunsDrawer.tsx @@ -0,0 +1,224 @@ +import { useEffect, useState } from 'react'; +import { Button, Card, Drawer, Form, Input, Modal, Space, Table, Tabs, Tag, Typography, App as AntApp } from 'antd'; +import type { Workflow, WorkflowRun, WorkflowRunDetail } from '../../../api'; +import { WorkflowAPI, streamWorkflowRun } from '../../../api'; +import { STATUS_COLOR } from '../constants'; + +const { Text, Paragraph } = Typography; + +export default function RunsDrawer(props: { open: boolean; workflow: Workflow; onClose: () => void }) { + const { open, workflow, onClose } = props; + const { message } = AntApp.useApp(); + const [tab, setTab] = useState('run'); + const [runs, setRuns] = useState([]); + const [detail, setDetail] = useState(null); + const [streaming, setStreaming] = useState(false); + const [steps, setSteps] = useState([]); + const [finalRun, setFinalRun] = useState(null); + const [inputJson, setInputJson] = useState('{}'); + + const loadRuns = async () => { + try { + const data = await WorkflowAPI.listRuns(workflow.id, 30); + setRuns(data); + } catch (e: any) { + message.error('加载历史失败:' + (e?.message ?? e)); + } + }; + + useEffect(() => { + if (open) loadRuns(); + }, [open]); + + const onRunStream = () => { + let input: any = undefined; + if (inputJson.trim()) { + try { + input = JSON.parse(inputJson); + } catch { + message.error('input JSON 不合法'); + return; + } + } + setSteps([]); + setFinalRun(null); + setStreaming(true); + streamWorkflowRun(workflow.id, input, { + onStepStart: (d) => setSteps((s) => [...s, { ...d, status: 'running' }]), + onStepFinish: (d) => { + setSteps((s) => { + const idx = [...s].reverse().findIndex((x) => x.nodeId === d.nodeId && x.status === 'running'); + if (idx === -1) return [...s, d]; + const realIdx = s.length - 1 - idx; + const cp = [...s]; + cp[realIdx] = d; + return cp; + }); + }, + onRunFinish: (d) => { + setStreaming(false); + setFinalRun(d); + loadRuns(); + }, + onError: (msg) => { + setStreaming(false); + message.error(msg); + } + }); + }; + + const showDetail = async (runId: string) => { + try { + const d = await WorkflowAPI.getRun(runId); + setDetail(d); + } catch (e: any) { + message.error('加载详情失败:' + (e?.message ?? e)); + } + }; + + return ( + { + setDetail(null); + onClose(); + }} + styles={{ body: { background: '#fcfcfd' }, header: { background: '#fff', borderBottom: '1px solid var(--color-border)' } }} + > + +
+ + setInputJson(e.target.value)} placeholder='{"text":"hello"}' /> + +
+ + + + + + {steps.length > 0 && ( + + {steps.map((s, i) => ( +
+ + {s.status} + {s.nodeId} + {s.nodeType} + {s.durationMs != null && {s.durationMs}ms} + + {s.error && ( + + {s.error} + + )} + {s.output != null && ( +
+                            {JSON.stringify(s.output, null, 2)}
+                          
+ )} +
+ ))} +
+ )} + + {finalRun && ( + +
{JSON.stringify(finalRun, null, 2)}
+
+ )} +
+ ) + }, + { + key: 'history', + label: `历史 (${runs.length})`, + children: ( +
+ + + rowKey="id" + size="small" + pagination={false} + dataSource={runs} + columns={[ + { title: '状态', dataIndex: 'status', render: (s) => {s}, width: 100 }, + { title: '触发', dataIndex: 'trigger', width: 80 }, + { title: '开始', dataIndex: 'startedAt', render: (v) => new Date(v).toLocaleString(), width: 180 }, + { title: '耗时', dataIndex: 'durationMs', render: (v) => `${v} ms`, width: 100 }, + { title: '', render: (_, r) => } + ]} + /> + {detail && ( + setDetail(null)} onOk={() => setDetail(null)} width={680}> +

+ 状态: + {detail.status} + {detail.error && ( + + {detail.error} + + )} +

+

+ 耗时: + {detail.durationMs} ms +

+

+ Input: +

+
{JSON.stringify(detail.input, null, 2)}
+

+ Steps: +

+ {detail.steps.map((s) => ( + + + {s.status} + {s.nodeId} + {s.nodeType} + {s.durationMs} ms + + {s.error &&

{s.error}

} + {s.output != null && ( +
{JSON.stringify(s.output, null, 2)}
+ )} +
+ ))} +
+ )} +
+ ) + } + ]} + /> + + ); +} + diff --git a/src/pages/WorkflowsPage/components/WorkflowEditorDrawer.tsx b/src/pages/WorkflowsPage/components/WorkflowEditorDrawer.tsx new file mode 100644 index 0000000..76b2e11 --- /dev/null +++ b/src/pages/WorkflowsPage/components/WorkflowEditorDrawer.tsx @@ -0,0 +1,191 @@ +import { useEffect, useState } from 'react'; +import { Button, Card, Drawer, Form, Input, Select, Space, Switch, Tooltip, Typography, App as AntApp } from 'antd'; +import type { Workflow, WorkflowGraph, WorkflowNode, WorkflowNodeType } from '../../../api'; +import { WorkflowAPI } from '../../../api'; +import { NODE_TYPE_LABEL, SAMPLE_GRAPH } from '../constants'; + +const { Text, Paragraph } = Typography; + +export default function WorkflowEditorDrawer(props: { + open: boolean; + workflow: Workflow; + onClose: () => void; + onSaved: () => void; +}) { + const { open, workflow, onClose, onSaved } = props; + const { message } = AntApp.useApp(); + const [form] = Form.useForm(); + const [graphText, setGraphText] = useState(JSON.stringify(workflow.graph, null, 2)); + const [saving, setSaving] = useState(false); + + useEffect(() => { + form.setFieldsValue({ + name: workflow.name, + description: workflow.description, + scheduleCron: workflow.scheduleCron, + scheduleEnabled: workflow.scheduleEnabled, + enabled: workflow.enabled + }); + setGraphText(JSON.stringify(workflow.graph, null, 2)); + }, [workflow]); + + const onSave = async () => { + const values = await form.validateFields(); + let graph: WorkflowGraph; + try { + graph = JSON.parse(graphText); + } catch (e: any) { + message.error('Graph JSON 解析失败:' + e.message); + return; + } + if (!graph.entry || !Array.isArray(graph.nodes)) { + message.error('Graph 必须包含 entry + nodes 数组'); + return; + } + setSaving(true); + try { + const payload = { ...values, graph }; + if (workflow.id) { + await WorkflowAPI.update(workflow.id, payload); + } else { + await WorkflowAPI.create(payload); + } + message.success('已保存'); + onSaved(); + } catch (e: any) { + message.error('保存失败:' + (e?.message ?? e)); + } finally { + setSaving(false); + } + }; + + return ( + + 保存 + + } + > +
+ + + + + + + + + + + + + + + + Cron 表达式 + + 5 段:分 时 日 月 周,例如 \"*/30 * * * *\" + + + } + name="scheduleCron" + > + + +
+ + { + try { + const g: WorkflowGraph = JSON.parse(graphText); + g.nodes = [...(g.nodes || []), node]; + if (!g.entry) g.entry = node.id; + setGraphText(JSON.stringify(g, null, 2)); + } catch { + message.error('Graph JSON 不合法,无法追加'); + } + }} + /> + + Graph JSON(节点列表)} + style={{ marginTop: 12, borderRadius: 16, boxShadow: 'var(--shadow-xs)' }} + extra={ + + + + } + > + setGraphText(e.target.value)} + style={{ fontFamily: 'ui-monospace, monospace', fontSize: 12 }} + /> + + 支持模板 {'{{input.x}} {{vars.x}} {{steps..output...}}'}。 +
+ 节点类型:agent / skill / http / transform / branch;branch 节点用 condition(返回 bool 的 JS 片段),引擎按 next/elseNext 走。 +
+
+
+ ); +} + +function NodeQuickAdd({ onAdd }: { onAdd: (n: WorkflowNode) => void }) { + const [type, setType] = useState('agent'); + const [id, setId] = useState(''); + return ( + + + setId(e.target.value)} style={{ width: 200 }} /> + + + + ); +} + +function defaultConfig(type: WorkflowNodeType): Record { + switch (type) { + case 'agent': + return { agentId: '', prompt: '请帮我处理:{{input.text}}' }; + case 'skill': + return { skillId: '', args: {} }; + case 'http': + return { method: 'GET', url: 'https://example.com/api', headers: {}, body: null }; + case 'transform': + return { code: 'return { value: input.text };' }; + case 'branch': + return { condition: 'steps.prev?.output?.value === true' }; + } +} + diff --git a/src/pages/WorkflowsPage/constants.ts b/src/pages/WorkflowsPage/constants.ts new file mode 100644 index 0000000..9f757a5 --- /dev/null +++ b/src/pages/WorkflowsPage/constants.ts @@ -0,0 +1,45 @@ +import type { WorkflowGraph, WorkflowNodeType } from '../../api'; + +export const NODE_TYPE_LABEL: Record = { + agent: '🤖 Agent', + skill: '🛠️ Skill', + http: '🌐 HTTP', + transform: '🔧 Transform', + branch: '🔀 Branch' +}; + +export const STATUS_COLOR: Record = { + running: 'blue', + success: 'green', + failed: 'red', + aborted: 'orange', + skipped: 'default' +}; + +export const SAMPLE_GRAPH: WorkflowGraph = { + entry: 'fetch', + variables: { topic: 'Go 1.23 新特性' }, + nodes: [ + { + id: 'fetch', + type: 'agent', + name: '搜集资料', + config: { + agentId: '', + prompt: '请围绕主题\"{{vars.topic}}\"列出 5 条最近的关键信息,要点形式。' + }, + next: 'summarize' + }, + { + id: 'summarize', + type: 'agent', + name: '总结成稿', + config: { + agentId: '', + prompt: '基于以下要点写一段 200 字日报:\\n{{steps.fetch.output.text}}' + }, + next: '' + } + ] +}; + diff --git a/src/pages/chat/ChatPage.tsx b/src/pages/chat/ChatPage.tsx new file mode 100644 index 0000000..6fd57b5 --- /dev/null +++ b/src/pages/chat/ChatPage.tsx @@ -0,0 +1,159 @@ +import { useEffect, useRef, useState } from 'react'; +import { App as AntApp, Empty } from 'antd'; +import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; +import type { ModelOverrides } from '../../api'; +import AgentSidebar from './components/AgentSidebar'; +import ChatHeader from './components/ChatHeader'; +import ChatBody from './components/ChatBody'; +import ChatInput from './components/ChatInput'; +import ChatDrawers from './components/ChatDrawers'; +import { useChatScroll } from './hooks/useChatScroll'; +import { useChatData } from './hooks/useChatData'; +import { useChatSender } from './hooks/useChatSender'; + +export default function ChatPage() { + const { id } = useParams(); + const navigate = useNavigate(); + const [searchParams, setSearchParams] = useSearchParams(); + const { message } = AntApp.useApp(); + + const [sessionId, setSessionId] = useState(null); + const [highlightId, setHighlightId] = useState(null); + const [overrides, setOverrides] = useState({}); + + const [historyDrawerOpen, setHistoryDrawerOpen] = useState(false); + const [mcpDrawerOpen, setMcpDrawerOpen] = useState(false); + const [paramsDrawerOpen, setParamsDrawerOpen] = useState(false); + const [tplDrawerOpen, setTplDrawerOpen] = useState(false); + + const abortRef = useRef(null); + const { bodyRef, scrollBottom, initialScrollDoneRef } = useChatScroll(); + + useEffect(() => { + const s = searchParams.get('session'); + const m = searchParams.get('msg'); + if (s) { + setSessionId(s); + if (m) setHighlightId(m); + const next = new URLSearchParams(searchParams); + next.delete('session'); + next.delete('msg'); + setSearchParams(next, { replace: true }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchParams]); + + const { agent, agentList, messages, setMessages, branches, setBranches, loadMessages } = useChatData({ + agentId: id, + sessionId, + highlightId, + setHighlightId, + scrollBottom, + initialScrollDoneRef, + setOverrides: (updater) => setOverrides(updater), + abort: () => abortRef.current?.abort() + }); + + const sender = useChatSender({ + agentId: id, + agent, + sessionId, + overrides, + setOverrides: (updater) => setOverrides(updater), + messages, + setMessages, + setBranches, + loadMessages, + scrollBottom, + notify: { success: (t) => message.success(t), error: (t) => message.error(t) }, + abortRef + }); + + return ( +
+ navigate('/agents/new')} + onSelect={(aid) => navigate(`/chat/${aid}`)} + /> + +
+ {!agent ? ( +
+ +
+ ) : ( + <> + setHistoryDrawerOpen(true)} + onOpenParams={() => setParamsDrawerOpen(true)} + onOpenMcp={() => setMcpDrawerOpen(true)} + onManageAgent={() => navigate(`/agents/${id}`)} + onClear={sender.handleClear} + /> + + navigator.clipboard?.writeText(text).then(() => message.success('已复制'))} + /> + + sender.setAttachments(updater)} + imageUrls={sender.imageUrls} + setImageUrls={(updater) => sender.setImageUrls(updater)} + onSend={sender.handleSend} + onStop={sender.handleStop} + onAttach={sender.handleAttach} + onOpenTpl={() => setTplDrawerOpen(true)} + modelOptions={sender.modelOptions} + activeModelValue={sender.activeModelValue} + onChangeModel={(modelId) => { + const selected = sender.modelOptions.find((m) => m.value === modelId); + setOverrides((o) => ({ ...o, model_id: String(modelId), model: selected?.label || String(modelId) })); + }} + /> + + )} +
+ + sender.setInput(updater(sender.input))} + overrides={overrides} + setOverrides={(updater) => setOverrides(updater)} + sessionId={sessionId} + setSessionId={setSessionId} + setHighlightId={setHighlightId} + sessionRefresh={sender.sessionRefresh} + mcpDrawerOpen={mcpDrawerOpen} + setMcpDrawerOpen={setMcpDrawerOpen} + tplDrawerOpen={tplDrawerOpen} + setTplDrawerOpen={setTplDrawerOpen} + paramsDrawerOpen={paramsDrawerOpen} + setParamsDrawerOpen={setParamsDrawerOpen} + historyDrawerOpen={historyDrawerOpen} + setHistoryDrawerOpen={setHistoryDrawerOpen} + notify={{ success: (t) => message.success(t) }} + /> +
+ ); +} + diff --git a/src/pages/chat/components/AgentSidebar.tsx b/src/pages/chat/components/AgentSidebar.tsx new file mode 100644 index 0000000..b9649f4 --- /dev/null +++ b/src/pages/chat/components/AgentSidebar.tsx @@ -0,0 +1,77 @@ +import { Button } from 'antd'; +import type { Agent } from '../../../api'; + +const isImageUrl = (url: string) => url?.startsWith('http') || url?.startsWith('/'); + +export default function AgentSidebar(props: { + agentList: Agent[]; + activeAgentId?: string; + onCreate: () => void; + onSelect: (agentId: string) => void; +}) { + const { agentList, activeAgentId, onCreate, onSelect } = props; + + return ( + + ); +} + diff --git a/src/pages/chat/components/ChatBody.tsx b/src/pages/chat/components/ChatBody.tsx new file mode 100644 index 0000000..e689c77 --- /dev/null +++ b/src/pages/chat/components/ChatBody.tsx @@ -0,0 +1,116 @@ +import { Divider, Tag } from 'antd'; +import ReactMarkdown from 'react-markdown'; +import type { Agent, BranchInfo, ChatMessage } from '../../../api'; +import type { StreamingState } from '../hooks/useChatSender'; +import MessageItem from './messages/MessageItem'; +import { RetrievedView, ToolCallView } from './messages/MetaViews'; + +const isImageUrl = (url: string) => url?.startsWith('http') || url?.startsWith('/'); + +export default function ChatBody(props: { + bodyRef: React.RefObject; + agent: Agent; + messages: ChatMessage[]; + branches: Record; + highlightId: string | null; + sending: boolean; + streaming: StreamingState; + onRegenerate: (assistantId: string) => void; + onSwitchBranch: (userMsgId: string, branchId: string) => void; + onCopy: (text: string) => void; +}) { + const { bodyRef, agent, messages, branches, highlightId, sending, streaming, onRegenerate, onSwitchBranch, onCopy } = props; + + return ( +
+
+ {messages.length === 0 && !streaming.active ? ( +
+
+ {isImageUrl(agent.avatar) ? avatar : (agent.name?.charAt(0) || '?').toUpperCase()} +
+

你好,今天想一起完成什么?

+

{agent.description || '我是你的专属 AI 助手,随时准备为你服务。'}

+
+ ) : ( + <> + {messages.map((m) => ( + + ))} + + {streaming.active && ( +
+
+
+ {!!streaming.retryInfo?.message && ( +
+
+ {streaming.retryInfo.stage === 'fallback_model' ? '自动切换模型' : '自动重试'} + {streaming.retryInfo.stage === 'fallback_model' ? ( + + {String(streaming.retryInfo.fromModel || '')} → {String(streaming.retryInfo.toModel || '')} + + ) : ( + + {String(streaming.retryInfo.model || '')} + {streaming.retryInfo.attempt ? ` · 第${streaming.retryInfo.attempt}次` : ''} + + )} +
+
{String(streaming.retryInfo.message)}
+ {!!streaming.retryInfo.reason && ( +
{String(streaming.retryInfo.reason)}
+ )} +
+ )} +
+
推理过程
+ {streaming.reasoningText ? {streaming.reasoningText + '▍'} : 等待推理…} +
+ +
+
正式回答
+ {streaming.answerText ? {streaming.answerText + '▍'} : 等待输出…} +
+
+
+ {(streaming.retrieved.length > 0 || streaming.toolCalls.length > 0) && ( +
+ {streaming.retrieved.length > 0 && } + {streaming.toolCalls.length > 0 && } +
+ )} +
+ )} + + )} +
+
+ ); +} + diff --git a/src/pages/chat/components/ChatDrawers.tsx b/src/pages/chat/components/ChatDrawers.tsx new file mode 100644 index 0000000..85a5f15 --- /dev/null +++ b/src/pages/chat/components/ChatDrawers.tsx @@ -0,0 +1,163 @@ +import { Button, Drawer, Empty, InputNumber, Slider } from 'antd'; +import type { Agent, ModelOverrides } from '../../../api'; +import McpResourcesDrawer from '../../../components/McpResourcesDrawer'; +import SessionSidebar from '../../../components/SessionSidebar'; +import PromptLibraryPage from '../../PromptLibraryPage'; + +export default function ChatDrawers(props: { + agentId?: string; + agent: Agent | null; + input: string; + setInput: (updater: (prev: string) => string) => void; + overrides: ModelOverrides; + setOverrides: (updater: (prev: ModelOverrides) => ModelOverrides) => void; + sessionId: string | null; + setSessionId: (v: string | null) => void; + setHighlightId: (v: string | null) => void; + sessionRefresh: number; + mcpDrawerOpen: boolean; + setMcpDrawerOpen: (v: boolean) => void; + tplDrawerOpen: boolean; + setTplDrawerOpen: (v: boolean) => void; + paramsDrawerOpen: boolean; + setParamsDrawerOpen: (v: boolean) => void; + historyDrawerOpen: boolean; + setHistoryDrawerOpen: (v: boolean) => void; + notify: { success: (t: string) => void }; +}) { + const { + agentId, + agent, + setInput, + overrides, + setOverrides, + sessionId, + setSessionId, + setHighlightId, + sessionRefresh, + mcpDrawerOpen, + setMcpDrawerOpen, + tplDrawerOpen, + setTplDrawerOpen, + paramsDrawerOpen, + setParamsDrawerOpen, + historyDrawerOpen, + setHistoryDrawerOpen, + notify + } = props; + + return ( + <> + {agentId && ( + setMcpDrawerOpen(false)} + onUse={(text) => setInput((cur) => cur + text)} + /> + )} + + setTplDrawerOpen(false)} width={520}> + { + setInput((cur) => (cur ? cur + '\n\n' : '') + t.body); + setTplDrawerOpen(false); + notify.success('已插入到输入框'); + }} + /> + + + setParamsDrawerOpen(false)} width={380}> +
+ 💡 这里设置的参数会临时覆盖智能体的默认配置,仅作用于当前浏览器会话,不会写回到智能体本身。 +
+ 关闭页面或清空字段即可恢复默认。 +
+ +
+
+ 🌡 Temperature(创造性) + 默认:{agent?.temperature ?? 0.7} +
+ setOverrides((o) => ({ ...o, temperature: v as number }))} + marks={{ 0: '严谨', 0.7: '默认', 1.4: '发散', 2: '混沌' }} + /> + {overrides.temperature !== undefined && ( + + )} +
+ +
+
+ 🎯 Top P(核采样) + 未设置时跟随模型默认 +
+ setOverrides((o) => ({ ...o, topP: v as number }))} disabled={overrides.topP === undefined} /> + {overrides.topP === undefined ? ( + + ) : ( + + )} +
+ +
+
+ 📏 Max Tokens(生成长度上限) + 未设置时由模型决定 +
+ setOverrides((o) => ({ ...o, maxTokens: v == null ? undefined : Number(v) }))} + min={1} + max={16384} + placeholder="例如 2048" + style={{ width: '100%' }} + /> +
+ + +
+ + setHistoryDrawerOpen(false)} open={historyDrawerOpen} bodyStyle={{ padding: 0 }}> + {agentId ? ( + { + setSessionId(sid); + setHighlightId(opts?.highlightMessageId || null); + setHistoryDrawerOpen(false); + }} + refreshTick={sessionRefresh} + /> + ) : ( + + )} + + + ); +} + diff --git a/src/pages/chat/components/ChatHeader.tsx b/src/pages/chat/components/ChatHeader.tsx new file mode 100644 index 0000000..3d4415a --- /dev/null +++ b/src/pages/chat/components/ChatHeader.tsx @@ -0,0 +1,58 @@ +import { ApiOutlined, DeleteOutlined, DownOutlined, EditOutlined, SettingOutlined } from '@ant-design/icons'; +import { Button, Dropdown, Modal, Space, Switch } from 'antd'; +import type { Agent } from '../../../api'; + +export default function ChatHeader(props: { + agent: Agent; + useStream: boolean; + setUseStream: (v: boolean) => void; + onOpenHistory: () => void; + onOpenParams: () => void; + onOpenMcp: () => void; + onManageAgent: () => void; + onClear: () => void; +}) { + const { agent, useStream, setUseStream, onOpenHistory, onOpenParams, onOpenMcp, onManageAgent, onClear } = props; + + return ( +
+
+
{agent.name}
+
{agent.model || '默认模型'} · T={agent.temperature}
+
+ +
+ 流式输出 + +
+ + , onClick: onOpenParams }, + { key: 'mcp', label: 'MCP 资源', icon: , onClick: onOpenMcp }, + { key: 'edit', label: '管理 Agent', icon: , onClick: onManageAgent }, + { type: 'divider' as const }, + { + key: 'clear', + label: '清空对话', + icon: , + danger: true, + onClick: () => { + Modal.confirm({ title: '清空当前会话所有消息?', onOk: onClear }); + } + } + ] + }} + > + + +
+
+ ); +} + diff --git a/src/pages/chat/components/ChatInput.tsx b/src/pages/chat/components/ChatInput.tsx new file mode 100644 index 0000000..59408d7 --- /dev/null +++ b/src/pages/chat/components/ChatInput.tsx @@ -0,0 +1,141 @@ +import { ArrowUpOutlined, BookOutlined, CloseOutlined, DownOutlined, PaperClipOutlined } from '@ant-design/icons'; +import { Button, Image as AntImage, Input, Select, Tag, Upload } from 'antd'; +import type { ChatAttachment } from '../../../api'; + +export default function ChatInput(props: { + input: string; + setInput: (v: string) => void; + sending: boolean; + attachments: ChatAttachment[]; + setAttachments: (updater: (prev: ChatAttachment[]) => ChatAttachment[]) => void; + imageUrls: string[]; + setImageUrls: (updater: (prev: string[]) => string[]) => void; + onSend: () => void; + onStop: () => void; + onAttach: (files: File[]) => void; + onOpenTpl: () => void; + modelOptions: Array<{ value: string; label: string }>; + activeModelValue: string; + onChangeModel: (modelId: string) => void; +}) { + const { + input, + setInput, + sending, + attachments, + setAttachments, + imageUrls, + setImageUrls, + onSend, + onStop, + onAttach, + onOpenTpl, + modelOptions, + activeModelValue, + onChangeModel + } = props; + + return ( +
+
+ {attachments.map((a, i) => ( + setAttachments((arr) => arr.filter((_, j) => j !== i))}> + 📎 {a.name} + + ))} + {imageUrls.map((u, i) => ( +
+ +
+ ))} +
+ +
+
+ setInput(e.target.value)} + placeholder="问我任何问题..." + autoSize={{ minRows: 3, maxRows: 10 }} + onKeyDown={(e) => { + if (e.key !== 'Enter') return; + if ((e as any).isComposing) return; + + if (e.metaKey || e.ctrlKey) { + e.preventDefault(); + const el = e.currentTarget; + const start = el.selectionStart ?? input.length; + const end = el.selectionEnd ?? input.length; + const next = input.slice(0, start) + '\n' + input.slice(end); + setInput(next); + requestAnimationFrame(() => { + el.selectionStart = el.selectionEnd = start + 1; + }); + return; + } + + if (!e.shiftKey && !e.altKey) { + e.preventDefault(); + onSend(); + } + }} + className="chat-input-textarea" + disabled={sending} + /> + +
+
+