From 1a80ac2a009308a9c41fa271772bbf348adf3708 Mon Sep 17 00:00:00 2001 From: Aska Date: Wed, 12 Aug 2026 16:56:52 +0800 Subject: [PATCH] feat(admin): add LLM call logs, system info, plugin list pages - ai-core/llm: ring-buffer call logger with stats - Gateway: /api/v1/admin/llm-calls, /system, /plugins, /sessions endpoints - Frontend: LLMCallsPage (table + stats), SystemPage (version/plugins/sessions) - Admin nav expanded to 7 items --- backend/cmd/cyrene/main.go | 18 +++- backend/services/ai-core/llm/logger.go | 84 +++++++++++++++++++ backend/services/gateway/api/router.go | 47 +++++++++++ frontend/web/src/App.tsx | 4 + .../web/src/components/admin/AdminLayout.tsx | 2 + frontend/web/src/pages/admin/LLMCallsPage.tsx | 84 +++++++++++++++++++ frontend/web/src/pages/admin/SystemPage.tsx | 80 ++++++++++++++++++ 7 files changed, 318 insertions(+), 1 deletion(-) create mode 100644 backend/services/ai-core/llm/logger.go create mode 100644 frontend/web/src/pages/admin/LLMCallsPage.tsx create mode 100644 frontend/web/src/pages/admin/SystemPage.tsx diff --git a/backend/cmd/cyrene/main.go b/backend/cmd/cyrene/main.go index b76df09..233a4c4 100644 --- a/backend/cmd/cyrene/main.go +++ b/backend/cmd/cyrene/main.go @@ -32,6 +32,7 @@ import ( websearch "git.yeij.top/AskaEth/Cyrene_v2/plugins/web-search" "git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/llm" + llmlog "git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/llm" aiCoreCtx "git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/context" "git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/orchestrator" "git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/persona" @@ -231,9 +232,12 @@ func main() { authMgr := api.NewRealAuthManager(cfg.JWTSecret, cfg.JWTExpiryHours, userStore) // Build dependencies for gateway handlers + llmLogger := llmlog.NewLogger(200) gwDeps := api.Dependencies{ AuthMgr: authMgr, Orchestrator: orch, + LLMLogger: &llmLoggerAdapter{Logger: llmLogger}, + PluginList: pluginNamesToMap(reg.ListNames()), } if memSvc != nil { gwDeps.MemorySvc = memory.NewAPIAdapter(memSvc) @@ -276,4 +280,16 @@ func main() { _ = voiceSvc _ = memSvc _ = orch -} \ No newline at end of file +} +// llmLoggerAdapter adapts llm.Logger to api.LLMCallLogger interface. +type llmLoggerAdapter struct{ *llmlog.Logger } + +func (a *llmLoggerAdapter) Recent(n int) []map[string]any { return a.Logger.RecentMaps(n) } + +func pluginNamesToMap(names []string) []map[string]string { + result := make([]map[string]string, len(names)) + for i, n := range names { + result[i] = map[string]string{"name": n, "enabled": "true"} + } + return result +} diff --git a/backend/services/ai-core/llm/logger.go b/backend/services/ai-core/llm/logger.go new file mode 100644 index 0000000..2db6b4d --- /dev/null +++ b/backend/services/ai-core/llm/logger.go @@ -0,0 +1,84 @@ +package llm + +import ( + "sync" + "time" +) + +type CallRecord struct { + ID string `json:"id"` + Time time.Time `json:"time"` + Model string `json:"model"` + Purpose string `json:"purpose"` + PromptTokens int `json:"prompt_tokens"` + CompTokens int `json:"completion_tokens"` + DurationMs int64 `json:"duration_ms"` + Success bool `json:"success"` + Error string `json:"error,omitempty"` +} + +type Logger struct { + mu sync.RWMutex + records []CallRecord + max int + index int + total int64 +} + +func NewLogger(max int) *Logger { return &Logger{records: make([]CallRecord, max), max: max} } + +func (l *Logger) Record(r CallRecord) { + l.mu.Lock() + defer l.mu.Unlock() + r.ID = time.Now().Format("150405") + "-" + r.Purpose + l.records[l.index%l.max] = r + l.index++ + l.total++ +} + +func (l *Logger) Recent(n int) []CallRecord { + l.mu.RLock() + defer l.mu.RUnlock() + if n > l.max { n = l.max } + count := l.index + if count > l.max { count = l.max } + if n > count { n = count } + result := make([]CallRecord, n) + for i := 0; i < n; i++ { + idx := (l.index - 1 - i + l.max) % l.max + if idx < l.max { result[n-1-i] = l.records[idx] } + } + return result +} + +func (l *Logger) RecentMaps(n int) []map[string]any { + records := l.Recent(n) + result := make([]map[string]any, len(records)) + for i, r := range records { + result[i] = map[string]any{ + "id": r.ID, "time": r.Time.Format(time.RFC3339), "model": r.Model, + "purpose": r.Purpose, "prompt_tokens": r.PromptTokens, "completion_tokens": r.CompTokens, + "duration_ms": r.DurationMs, "success": r.Success, "error": r.Error, + } + } + return result +} + +func (l *Logger) Stats() map[string]any { + l.mu.RLock() + defer l.mu.RUnlock() + var calls, succ int64 + var dur, tok int64 + count := l.index + if count > l.max { count = l.max } + for i := 0; i < count; i++ { + r := l.records[(l.index-1-i+l.max)%l.max] + calls++ + if r.Success { succ++ } + dur += r.DurationMs + tok += int64(r.PromptTokens + r.CompTokens) + } + avg := 0 + if calls > 0 { avg = int(dur / calls) } + return map[string]any{"total_calls": calls, "success_calls": succ, "avg_duration_ms": avg, "total_tokens": tok} +} diff --git a/backend/services/gateway/api/router.go b/backend/services/gateway/api/router.go index 811b0ce..7a64dfd 100644 --- a/backend/services/gateway/api/router.go +++ b/backend/services/gateway/api/router.go @@ -56,6 +56,13 @@ type OrchestratorPipeline interface { // WebSocketHub manages WS connections. type WebSocketHub interface { UpgradeHandler() http.HandlerFunc + Count() int +} + +// LLMCallLogger provides LLM call history. +type LLMCallLogger interface { + Recent(n int) []map[string]any + Stats() map[string]any } // Dependencies holds all injectable services. @@ -65,6 +72,8 @@ type Dependencies struct { VoiceSvc VoiceService Orchestrator OrchestratorPipeline WSHub WebSocketHub + LLMLogger LLMCallLogger + PluginList []map[string]string } // Router handles HTTP routing with middleware support. @@ -101,6 +110,10 @@ func (r *Router) setupRoutes() { // Admin r.mux.HandleFunc("/api/v1/admin/dashboard", r.authMW(r.adminMW(r.adminDashboardHandler))) + r.mux.HandleFunc("/api/v1/admin/llm-calls", r.authMW(r.adminMW(r.llmCallsHandler))) + r.mux.HandleFunc("/api/v1/admin/system", r.authMW(r.adminMW(r.systemInfoHandler))) + r.mux.HandleFunc("/api/v1/admin/plugins", r.authMW(r.adminMW(r.pluginListHandler))) + r.mux.HandleFunc("/api/v1/admin/sessions", r.authMW(r.adminMW(r.adminSessionsHandler))) } // ServeHTTP implements http.Handler. @@ -348,6 +361,40 @@ func (r *Router) adminDashboardHandler(w http.ResponseWriter, _ *http.Request) { }) } +// --- Admin extra handlers --- + +func (r *Router) llmCallsHandler(w http.ResponseWriter, req *http.Request) { + if r.deps.LLMLogger == nil { + writeJSON(w, 200, M{"calls": []any{}, "stats": M{}}) + return + } + writeJSON(w, 200, M{ + "calls": r.deps.LLMLogger.Recent(50), + "stats": r.deps.LLMLogger.Stats(), + }) +} + +func (r *Router) systemInfoHandler(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, 200, M{ + "system": M{ + "version": "2.0.0-dev", + "go_version": "1.26", + "uptime_sec": int(time.Since(startTime).Seconds()), + "ws_clients": r.deps.WSHub.Count(), + }, + }) +} + +func (r *Router) pluginListHandler(w http.ResponseWriter, _ *http.Request) { + plugins := r.deps.PluginList + if plugins == nil { plugins = []map[string]string{} } + writeJSON(w, 200, M{"plugins": plugins}) +} + +func (r *Router) adminSessionsHandler(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, 200, M{"sessions": []any{}, "total": r.deps.WSHub.Count()}) +} + // --- Helpers --- type M = map[string]any diff --git a/frontend/web/src/App.tsx b/frontend/web/src/App.tsx index 086ece4..abff5d5 100644 --- a/frontend/web/src/App.tsx +++ b/frontend/web/src/App.tsx @@ -8,6 +8,8 @@ import ServicesPage from './pages/admin/ServicesPage' import IoTManagementPage from './pages/admin/IoTManagementPage' import PluginsPage from './pages/admin/PluginsPage' import MemoryPage from './pages/admin/MemoryPage' +import LLMCallsPage from './pages/admin/LLMCallsPage' +import SystemPage from './pages/admin/SystemPage' import LoginPage from './pages/LoginPage' export default function App() { @@ -35,6 +37,8 @@ export default function App() { } /> } /> } /> + } /> + } /> diff --git a/frontend/web/src/components/admin/AdminLayout.tsx b/frontend/web/src/components/admin/AdminLayout.tsx index 3177a37..1a79bcc 100644 --- a/frontend/web/src/components/admin/AdminLayout.tsx +++ b/frontend/web/src/components/admin/AdminLayout.tsx @@ -6,6 +6,8 @@ const navItems = [ { to: '/admin/iot', label: 'IoT 设备', icon: '⌂' }, { to: '/admin/plugins', label: '插件管理', icon: '◇' }, { to: '/admin/memory', label: '记忆管理', icon: '♡' }, + { to: '/admin/llm-calls', label: 'LLM 日志', icon: '🧠' }, + { to: '/admin/system', label: '系统信息', icon: '⊞' }, ] export default function AdminLayout() { diff --git a/frontend/web/src/pages/admin/LLMCallsPage.tsx b/frontend/web/src/pages/admin/LLMCallsPage.tsx new file mode 100644 index 0000000..011aae6 --- /dev/null +++ b/frontend/web/src/pages/admin/LLMCallsPage.tsx @@ -0,0 +1,84 @@ +import { useEffect, useState } from 'react' +import { getToken } from '../../api/client' + +export default function LLMCallsPage() { + const [calls, setCalls] = useState([]) + const [stats, setStats] = useState({}) + + useEffect(() => { + fetchLLM() + const interval = setInterval(fetchLLM, 5000) + return () => clearInterval(interval) + }, []) + + const fetchLLM = async () => { + try { + const token = getToken() + const res = await fetch('/api/v1/admin/llm-calls', { headers: { Authorization: `Bearer ${token}` } }) + const data = await res.json() + setCalls(data.calls || []) + setStats(data.stats || {}) + } catch {} + } + + return ( +
+

LLM 调用日志

+ + {/* Stats */} +
+ + + + +
+ + {/* Calls table */} +
+ {calls.length === 0 ? ( +
暂无 LLM 调用记录
+ ) : ( + + + + + + + + + + + + + {calls.map((c, i) => ( + + + + + + + + + ))} + +
时间模型用途耗时Token状态
{c.time?.slice(11,19) || '-'}{c.model || '-'} + {c.purpose || '-'} + {c.duration_ms}ms{(c.prompt_tokens||0)+(c.completion_tokens||0)} + + {c.success ? '✓' : '✗'} + +
+ )} +
+
+ ) +} + +function MiniStat({ label, value }: { label:string; value:string|number }) { + return ( +
+

{label}

+

{value}

+
+ ) +} diff --git a/frontend/web/src/pages/admin/SystemPage.tsx b/frontend/web/src/pages/admin/SystemPage.tsx new file mode 100644 index 0000000..3707418 --- /dev/null +++ b/frontend/web/src/pages/admin/SystemPage.tsx @@ -0,0 +1,80 @@ +import { useEffect, useState } from 'react' +import { getToken } from '../../api/client' + +export default function SystemPage() { + const [info, setInfo] = useState({}) + const [sessions, setSessions] = useState([]) + const [plugins, setPlugins] = useState([]) + + useEffect(() => { + fetchAll() + const interval = setInterval(fetchAll, 10000) + return () => clearInterval(interval) + }, []) + + const fetchAll = async () => { + const token = getToken() + const headers = { Authorization: `Bearer ${token}` } + try { + const [sysRes, sessionRes, pluginRes] = await Promise.all([ + fetch('/api/v1/admin/system', { headers }), + fetch('/api/v1/admin/sessions', { headers }), + fetch('/api/v1/admin/plugins', { headers }), + ]) + if (sysRes.ok) setInfo((await sysRes.json()).system) + if (sessionRes.ok) setSessions((await sessionRes.json()).sessions || []) + if (pluginRes.ok) setPlugins((await pluginRes.json()).plugins || []) + } catch {} + } + + return ( +
+

系统信息

+ + {/* System Info */} +
+ + + + +
+ + {/* Plugins list */} +
+

已加载插件 ({plugins.length})

+
+ {plugins.map((p, i) => ( + + {p.name} + + ))} +
+
+ + {/* Sessions */} +
+

活跃会话 ({sessions.length})

+ {sessions.length === 0 ? ( +

暂无活跃会话

+ ) : ( +
+ {sessions.map((s: any, i) => ( +
+ {s.session_id || s.id || '-'} +
+ ))} +
+ )} +
+
+ ) +} + +function StatRow({ label, value }: { label:string; value:string|number }) { + return ( +
+

{label}

+

{value}

+
+ ) +}