From ffc1f79de4e049c136f633d36b87db6cb1789d73 Mon Sep 17 00:00:00 2001 From: oudecheng <13802883547@139.com> Date: Wed, 8 Jul 2026 10:03:34 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E5=BB=BA=E7=AB=8B=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=20API=20=E5=AE=A2=E6=88=B7=E7=AB=AF=E5=B1=82=EF=BC=8C?= =?UTF-8?q?=E6=B6=88=E9=99=A4=E7=A1=AC=E7=BC=96=E7=A0=81=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E4=B8=8E=E9=87=8D=E5=A4=8D=20fetch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 web/src/api/ 目录(6 个文件):client.ts 基础设施 + 5 个领域模块 - 封装 18 处 fetch 调用为强类型函数,消除 17 处硬编码 /api/ 路径 - 统一 13 处重复的 fetch+json+错误处理模式 - 删除旧 web/src/components/Settings/api/expert.ts,合并到新 api/experts.ts - ConfigPage.tsx 替换 15 处 fetch 调用,ExpertSelector.tsx 改用新 API - 保持现有组件行为不变(静默失败仍静默,错误处理等价) --- web/src/api/client.ts | 56 ++++++++ web/src/api/config.ts | 27 ++++ web/src/api/experts.ts | 53 ++++++++ web/src/api/mcp.ts | 6 + web/src/api/skills.ts | 14 ++ web/src/api/subagents.ts | 14 ++ web/src/components/Chat/ExpertSelector.tsx | 23 ++-- web/src/components/Settings/ConfigPage.tsx | 151 ++++++++------------- web/src/components/Settings/api/expert.ts | 20 --- 9 files changed, 235 insertions(+), 129 deletions(-) create mode 100644 web/src/api/client.ts create mode 100644 web/src/api/config.ts create mode 100644 web/src/api/experts.ts create mode 100644 web/src/api/mcp.ts create mode 100644 web/src/api/skills.ts create mode 100644 web/src/api/subagents.ts delete mode 100644 web/src/components/Settings/api/expert.ts diff --git a/web/src/api/client.ts b/web/src/api/client.ts new file mode 100644 index 0000000..f32e320 --- /dev/null +++ b/web/src/api/client.ts @@ -0,0 +1,56 @@ +// 统一的 API 端点常量,消除硬编码字符串 +export const API = { + config: '/api/config', + restart: '/api/restart', + health: '/health', + mcpStatus: '/api/mcp/status', + skills: '/api/skills', + skillsToggle: '/api/skills/toggle', + subagents: '/api/subagents', + subagentsToggle: '/api/subagents/toggle', + experts: '/api/experts', + expertsToggle: '/api/experts/toggle', + expertsCreate: '/api/experts/create', + expertsUpdate: '/api/experts/update', + expertsDelete: '/api/experts/delete', + expertsSelected: '/api/experts/selected', + expertsSelect: '/api/experts/select', +} as const + +/** + * 基础 fetch 封装:自动添加 JSON headers,解析响应。 + * 返回 [data, error] 元组,不抛异常。 + */ +export async function apiFetch( + endpoint: string, + options?: { method?: string; body?: unknown; signal?: AbortSignal } +): Promise<[T | null, { status: number; message: string } | null]> { + try { + const resp = await fetch(endpoint, { + method: options?.method ?? 'GET', + headers: options?.body ? { 'Content-Type': 'application/json' } : undefined, + body: options?.body ? JSON.stringify(options.body) : undefined, + signal: options?.signal, + }) + const data = await resp.json().catch(() => null) + if (!resp.ok) { + return [null, { status: resp.status, message: data?.message || data?.error || `HTTP ${resp.status}` }] + } + return [data as T, null] + } catch (e) { + return [null, { status: 0, message: e instanceof Error ? e.message : 'Network error' }] + } +} + +/** + * GET 请求,静默失败返回 null(用于列表加载等场景)。 + */ +export async function apiGetSilent(endpoint: string): Promise { + try { + const resp = await fetch(endpoint) + if (!resp.ok) return null + return await resp.json() as T + } catch { + return null + } +} diff --git a/web/src/api/config.ts b/web/src/api/config.ts new file mode 100644 index 0000000..a7286b2 --- /dev/null +++ b/web/src/api/config.ts @@ -0,0 +1,27 @@ +import { API, apiFetch } from './client' +import type { AppConfig } from '../components/Settings/types' + +export async function getAppConfig(): Promise<[AppConfig | null, string | null]> { + const [data, err] = await apiFetch(API.config) + return [data, err?.message ?? null] +} + +export async function updateAppConfig(config: AppConfig): Promise<[true, null] | [false, string]> { + const [, err] = await apiFetch<{ success: boolean }>(API.config, { method: 'PUT', body: { config } }) + return err ? [false, err.message] : [true, null] +} + +export async function restartGateway(): Promise<{ status: number; data: any }> { + const resp = await fetch(API.restart, { method: 'POST' }) + const data = await resp.json().catch(() => ({})) + return { status: resp.status, data } +} + +export async function checkHealth(): Promise { + try { + const resp = await fetch(API.health) + return resp.ok + } catch { + return false + } +} diff --git a/web/src/api/experts.ts b/web/src/api/experts.ts new file mode 100644 index 0000000..330e8a1 --- /dev/null +++ b/web/src/api/experts.ts @@ -0,0 +1,53 @@ +import { API, apiGetSilent } from './client' +import type { ExpertListResponse, ExpertItem } from '../components/Settings/types' + +export function listExperts(): Promise { + return apiGetSilent(API.experts) +} + +export async function toggleExpert(name: string, scope: string, enabled: boolean): Promise { + return fetch(API.expertsToggle, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, scope, enabled }), + }) +} + +export async function createExpert(payload: { name: string; description: string; body: string; scope: string }): Promise { + return fetch(API.expertsCreate, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) +} + +export async function updateExpert(payload: { name: string; scope: string; description?: string; body?: string }): Promise { + return fetch(API.expertsUpdate, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) +} + +export async function deleteExpert(name: string, scope: string): Promise { + const params = new URLSearchParams({ name, scope }) + return fetch(`${API.expertsDelete}?${params}`, { method: 'DELETE' }) +} + +export async function getSelectedExpert(sessionId: string): Promise<{ expert_name: string | null; expert: ExpertItem | null }> { + const params = new URLSearchParams({ session_id: sessionId }) + const resp = await fetch(`${API.expertsSelected}?${params}`) + if (!resp.ok) return { expert_name: null, expert: null } + return resp.json() +} + +export async function selectExpert(sessionId: string, expertName: string | null): Promise<{ success: boolean; error?: string }> { + const resp = await fetch(API.expertsSelect, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ session_id: sessionId, expert_name: expertName }), + }) + const data = await resp.json().catch(() => ({})) + if (!resp.ok || !data.success) return { success: false, error: data.error || '切换专家失败' } + return { success: true } +} diff --git a/web/src/api/mcp.ts b/web/src/api/mcp.ts new file mode 100644 index 0000000..aaae0bc --- /dev/null +++ b/web/src/api/mcp.ts @@ -0,0 +1,6 @@ +import { API, apiGetSilent } from './client' +import type { McpStatusResponse } from '../components/Settings/types' + +export function getMcpStatus(): Promise { + return apiGetSilent(API.mcpStatus) +} diff --git a/web/src/api/skills.ts b/web/src/api/skills.ts new file mode 100644 index 0000000..bab6c80 --- /dev/null +++ b/web/src/api/skills.ts @@ -0,0 +1,14 @@ +import { API, apiGetSilent } from './client' +import type { SkillListResponse } from '../components/Settings/types' + +export function listSkills(): Promise { + return apiGetSilent(API.skills) +} + +export async function toggleSkill(name: string, scope: string, enabled: boolean): Promise { + return fetch(API.skillsToggle, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, scope, enabled }), + }) +} diff --git a/web/src/api/subagents.ts b/web/src/api/subagents.ts new file mode 100644 index 0000000..919036b --- /dev/null +++ b/web/src/api/subagents.ts @@ -0,0 +1,14 @@ +import { API, apiGetSilent } from './client' +import type { SubagentListResponse } from '../components/Settings/types' + +export function listSubagents(): Promise { + return apiGetSilent(API.subagents) +} + +export async function toggleSubagent(name: string, scope: string, enabled: boolean): Promise { + return fetch(API.subagentsToggle, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, scope, enabled }), + }) +} diff --git a/web/src/components/Chat/ExpertSelector.tsx b/web/src/components/Chat/ExpertSelector.tsx index 865f6f3..43d0b5f 100644 --- a/web/src/components/Chat/ExpertSelector.tsx +++ b/web/src/components/Chat/ExpertSelector.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef, useCallback } from 'react' import { UserCheck, ChevronDown, Loader2, Settings, Check } from 'lucide-react' -import { getSelectedExpert, selectExpert } from '../Settings/ConfigPage' +import { getSelectedExpert, selectExpert, listExperts } from '../../api/experts' interface ExpertItem { name: string @@ -81,18 +81,15 @@ export function ExpertSelector({ sessionId, onManageExperts, onSelectionChange } const fetchExpertList = useCallback(async () => { setListLoading(true) - try { - const resp = await fetch('/api/experts') - if (resp.ok) { - const data = await resp.json() - // Only show enabled experts (disabled_in_scopes.length === 0) - const enabled = (data.experts ?? []).filter( - (e: ExpertItem) => e.disabled_in_scopes.length === 0 - ) - setExpertList(enabled) - } - } catch { /* ignore */ } - finally { setListLoading(false) } + const data = await listExperts() + if (data) { + // Only show enabled experts (disabled_in_scopes.length === 0) + const enabled = (data.experts ?? []).filter( + (e: ExpertItem) => e.disabled_in_scopes.length === 0 + ) + setExpertList(enabled) + } + setListLoading(false) }, []) const handleToggleOpen = () => { diff --git a/web/src/components/Settings/ConfigPage.tsx b/web/src/components/Settings/ConfigPage.tsx index 6198826..fcd53a2 100644 --- a/web/src/components/Settings/ConfigPage.tsx +++ b/web/src/components/Settings/ConfigPage.tsx @@ -16,7 +16,12 @@ import type { } from './types' import { TABS, inputCls, selectCls, TIMEZONE_OPTIONS } from './constants' import { Field, Toggle, TagEditor, SectionCard, SourceEditor, MapEntryHeader } from './ui' -export { getSelectedExpert, selectExpert } from './api/expert' +import { getAppConfig, updateAppConfig, restartGateway, checkHealth } from '../../api/config' +import { listSkills, toggleSkill } from '../../api/skills' +import { listSubagents, toggleSubagent } from '../../api/subagents' +import { listExperts, toggleExpert, createExpert, updateExpert, deleteExpert } from '../../api/experts' +import { getMcpStatus } from '../../api/mcp' +export { getSelectedExpert, selectExpert } from '../../api/experts' // ── Main Component ───────────────────────────────────── export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPageProps) { @@ -57,89 +62,53 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage const [savingExpert, setSavingExpert] = useState(false) const fetchMcpStatus = useCallback(async () => { - try { - const resp = await fetch('/api/mcp/status') - if (resp.ok) setMcpStatus(await resp.json()) - } catch { /* ignore fetch errors */ } + const data = await getMcpStatus() + if (data) setMcpStatus(data) }, []) const fetchSkillList = useCallback(async () => { setSkillListLoading(true) - try { - const resp = await fetch('/api/skills') - if (resp.ok) setSkillList(await resp.json()) - } catch { /* ignore fetch errors */ } - finally { setSkillListLoading(false) } + const data = await listSkills() + if (data) setSkillList(data) + setSkillListLoading(false) }, []) - const toggleSkill = useCallback(async (name: string, scope: string, enabled: boolean) => { - const resp = await fetch('/api/skills/toggle', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name, scope, enabled }), - }) - return resp + const toggleSkillCb = useCallback(async (name: string, scope: string, enabled: boolean) => { + return toggleSkill(name, scope, enabled) }, []) const fetchSubagentList = useCallback(async () => { setSubagentListLoading(true) - try { - const resp = await fetch('/api/subagents') - if (resp.ok) setSubagentList(await resp.json()) - } catch { /* ignore fetch errors */ } - finally { setSubagentListLoading(false) } + const data = await listSubagents() + if (data) setSubagentList(data) + setSubagentListLoading(false) }, []) - const toggleSubagent = useCallback(async (name: string, scope: string, enabled: boolean) => { - const resp = await fetch('/api/subagents/toggle', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name, scope, enabled }), - }) - return resp + const toggleSubagentCb = useCallback(async (name: string, scope: string, enabled: boolean) => { + return toggleSubagent(name, scope, enabled) }, []) const fetchExpertList = useCallback(async () => { setExpertListLoading(true) - try { - const resp = await fetch('/api/experts') - if (resp.ok) setExpertList(await resp.json()) - } catch { /* ignore fetch errors */ } - finally { setExpertListLoading(false) } + const data = await listExperts() + if (data) setExpertList(data) + setExpertListLoading(false) }, []) - const toggleExpert = useCallback(async (name: string, scope: string, enabled: boolean) => { - const resp = await fetch('/api/experts/toggle', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name, scope, enabled }), - }) - return resp + const toggleExpertCb = useCallback(async (name: string, scope: string, enabled: boolean) => { + return toggleExpert(name, scope, enabled) }, []) - const createExpert = useCallback(async (payload: { name: string; description: string; body: string; scope: string }) => { - const resp = await fetch('/api/experts/create', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }) - return resp + const createExpertCb = useCallback(async (payload: { name: string; description: string; body: string; scope: string }) => { + return createExpert(payload) }, []) - const updateExpert = useCallback(async (payload: { name: string; scope: string; description?: string; body?: string }) => { - const resp = await fetch('/api/experts/update', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }) - return resp + const updateExpertCb = useCallback(async (payload: { name: string; scope: string; description?: string; body?: string }) => { + return updateExpert(payload) }, []) - const deleteExpert = useCallback(async (name: string, scope: string) => { - const resp = await fetch(`/api/experts/delete?name=${encodeURIComponent(name)}&scope=${encodeURIComponent(scope)}`, { - method: 'DELETE', - }) - return resp + const deleteExpertCb = useCallback(async (name: string, scope: string) => { + return deleteExpert(name, scope) }, []) const handleClose = useCallback(() => { @@ -149,10 +118,11 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage // Load config useEffect(() => { - fetch('/api/config').then(r => r.json()).then(data => { - setConfig(data) + getAppConfig().then(([data, err]) => { + if (data) setConfig(data) + if (err) setError('加载配置失败: ' + err) setLoading(false) - }).catch(e => { setError('加载配置失败: ' + e.message); setLoading(false) }) + }) }, []) // Fetch MCP status when MCP tab is selected @@ -190,55 +160,44 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage const handleSave = async () => { if (!config) return setSaving(true); setError('') - try { - const resp = await fetch('/api/config', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ config }), - }) - const data = await resp.json() - if (!resp.ok) throw new Error(data.message || data.error || '保存失败') + const [ok, err] = await updateAppConfig(config) + if (!ok) { + setError(err || '保存失败') + } else { // Config is now synced to both disk and in-memory state, // so the local state is already correct. No need to re-fetch. setDirty(false) // Show restart confirmation dialog setShowRestartDialog(true) - } catch (e: any) { - setError(e.message || '保存失败') - } finally { - setSaving(false) } + setSaving(false) } const handleRestart = async () => { setShowRestartDialog(false) setRestarting(true) try { - const resp = await fetch('/api/restart', { method: 'POST' }) - const data = await resp.json() - if (resp.status === 409) { + const { status, data } = await restartGateway() + if (status === 409) { setToast(data.message || '有任务运行中,请等待完成后再试') setRestarting(false) setTimeout(() => setToast(''), 5000) return } - if (!resp.ok) throw new Error(data.message || '重启失败') + if (status < 200 || status >= 300) throw new Error(data.message || '重启失败') setToast('服务正在重启,页面将自动重连...') // Poll /health until gateway is back const poll = async () => { for (let i = 0; i < 30; i++) { await new Promise(r => setTimeout(r, 1000)) - try { - const r = await fetch('/health') - if (r.ok) { - const refreshed = await fetch('/api/config').then(r => r.json()) - setConfig(refreshed) - setToast('服务已重启,配置已生效') - setRestarting(false) - setTimeout(() => setToast(''), 3000) - return - } - } catch { /* gateway not ready yet */ } + if (await checkHealth()) { + const [refreshed] = await getAppConfig() + if (refreshed) setConfig(refreshed) + setToast('服务已重启,配置已生效') + setRestarting(false) + setTimeout(() => setToast(''), 3000) + return + } } setToast('重启超时,请手动刷新页面') setRestarting(false) @@ -486,7 +445,7 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage }) try { - const resp = await toggleSkill(name, 'project', !currentlyEnabled) + const resp = await toggleSkillCb(name, 'project', !currentlyEnabled) const data = await resp.json() if (!resp.ok || !data.success) { // Rollback @@ -629,7 +588,7 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage }) try { - const resp = await toggleSubagent(name, 'project', !currentlyEnabled) + const resp = await toggleSubagentCb(name, 'project', !currentlyEnabled) const data = await resp.json() if (!resp.ok || !data.success) { setSubagentList(prevList) @@ -732,7 +691,7 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage }) try { - const resp = await toggleExpert(name, 'project', !currentlyEnabled) + const resp = await toggleExpertCb(name, 'project', !currentlyEnabled) const data = await resp.json() if (!resp.ok || !data.success) { setExpertList(prevList) @@ -770,7 +729,7 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage const handleDelete = async (name: string) => { if (!confirm(`确定删除专家 "${name}" 吗?此操作将删除对应文件。`)) return try { - const resp = await deleteExpert(name, 'project') + const resp = await deleteExpertCb(name, 'project') const data = await resp.json() if (!resp.ok || !data.success) { setToast(data.error || '删除专家失败') @@ -842,13 +801,13 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage setEditingExpertError('') try { const resp = isEdit - ? await updateExpert({ + ? await updateExpertCb({ name: editingExpert.nameField, scope: 'project', description: editingExpert.description, body: editingExpert.body, }) - : await createExpert({ + : await createExpertCb({ name: editingExpert.nameField, description: editingExpert.description, body: editingExpert.body, diff --git a/web/src/components/Settings/api/expert.ts b/web/src/components/Settings/api/expert.ts deleted file mode 100644 index 4b87997..0000000 --- a/web/src/components/Settings/api/expert.ts +++ /dev/null @@ -1,20 +0,0 @@ -// Expert API helpers extracted from ConfigPage.tsx -// Used by both ConfigPage and ExpertSelector -import type { ExpertItem } from '../types' - -export async function getSelectedExpert(sessionId: string): Promise<{ expert_name: string | null; expert: ExpertItem | null }> { - const resp = await fetch(`/api/experts/selected?session_id=${encodeURIComponent(sessionId)}`) - if (!resp.ok) return { expert_name: null, expert: null } - return resp.json() -} - -export async function selectExpert(sessionId: string, expertName: string | null): Promise<{ success: boolean; error?: string }> { - const resp = await fetch('/api/experts/select', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ session_id: sessionId, expert_name: expertName }), - }) - const data = await resp.json().catch(() => ({})) - if (!resp.ok || !data.success) return { success: false, error: data.error || '切换专家失败' } - return { success: true } -}