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
This commit is contained in:
1 parent
8bfda096f7
commit
1a80ac2a00
7 files changed
+317
No files matched your search
@@ -32,6 +32,7 @@ import (
|
|||||||
websearch "git.yeij.top/AskaEth/Cyrene_v2/plugins/web-search"
|
websearch "git.yeij.top/AskaEth/Cyrene_v2/plugins/web-search"
|
||||||
|
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/llm"
|
"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"
|
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/orchestrator"
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/persona"
|
"git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/persona"
|
||||||
@@ -231,9 +232,12 @@ func main() {
|
|||||||
authMgr := api.NewRealAuthManager(cfg.JWTSecret, cfg.JWTExpiryHours, userStore)
|
authMgr := api.NewRealAuthManager(cfg.JWTSecret, cfg.JWTExpiryHours, userStore)
|
||||||
|
|
||||||
// Build dependencies for gateway handlers
|
// Build dependencies for gateway handlers
|
||||||
|
llmLogger := llmlog.NewLogger(200)
|
||||||
gwDeps := api.Dependencies{
|
gwDeps := api.Dependencies{
|
||||||
AuthMgr: authMgr,
|
AuthMgr: authMgr,
|
||||||
Orchestrator: orch,
|
Orchestrator: orch,
|
||||||
|
LLMLogger: &llmLoggerAdapter{Logger: llmLogger},
|
||||||
|
PluginList: pluginNamesToMap(reg.ListNames()),
|
||||||
}
|
}
|
||||||
if memSvc != nil {
|
if memSvc != nil {
|
||||||
gwDeps.MemorySvc = memory.NewAPIAdapter(memSvc)
|
gwDeps.MemorySvc = memory.NewAPIAdapter(memSvc)
|
||||||
@@ -277,3 +281,15 @@ func main() {
|
|||||||
_ = memSvc
|
_ = memSvc
|
||||||
_ = orch
|
_ = orch
|
||||||
}
|
}
|
||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
}
|
||||||
@@ -56,6 +56,13 @@ type OrchestratorPipeline interface {
|
|||||||
// WebSocketHub manages WS connections.
|
// WebSocketHub manages WS connections.
|
||||||
type WebSocketHub interface {
|
type WebSocketHub interface {
|
||||||
UpgradeHandler() http.HandlerFunc
|
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.
|
// Dependencies holds all injectable services.
|
||||||
@@ -65,6 +72,8 @@ type Dependencies struct {
|
|||||||
VoiceSvc VoiceService
|
VoiceSvc VoiceService
|
||||||
Orchestrator OrchestratorPipeline
|
Orchestrator OrchestratorPipeline
|
||||||
WSHub WebSocketHub
|
WSHub WebSocketHub
|
||||||
|
LLMLogger LLMCallLogger
|
||||||
|
PluginList []map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Router handles HTTP routing with middleware support.
|
// Router handles HTTP routing with middleware support.
|
||||||
@@ -101,6 +110,10 @@ func (r *Router) setupRoutes() {
|
|||||||
|
|
||||||
// Admin
|
// Admin
|
||||||
r.mux.HandleFunc("/api/v1/admin/dashboard", r.authMW(r.adminMW(r.adminDashboardHandler)))
|
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.
|
// 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 ---
|
// --- Helpers ---
|
||||||
|
|
||||||
type M = map[string]any
|
type M = map[string]any
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import ServicesPage from './pages/admin/ServicesPage'
|
|||||||
import IoTManagementPage from './pages/admin/IoTManagementPage'
|
import IoTManagementPage from './pages/admin/IoTManagementPage'
|
||||||
import PluginsPage from './pages/admin/PluginsPage'
|
import PluginsPage from './pages/admin/PluginsPage'
|
||||||
import MemoryPage from './pages/admin/MemoryPage'
|
import MemoryPage from './pages/admin/MemoryPage'
|
||||||
|
import LLMCallsPage from './pages/admin/LLMCallsPage'
|
||||||
|
import SystemPage from './pages/admin/SystemPage'
|
||||||
import LoginPage from './pages/LoginPage'
|
import LoginPage from './pages/LoginPage'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
@@ -35,6 +37,8 @@ export default function App() {
|
|||||||
<Route path="iot" element={<IoTManagementPage />} />
|
<Route path="iot" element={<IoTManagementPage />} />
|
||||||
<Route path="plugins" element={<PluginsPage />} />
|
<Route path="plugins" element={<PluginsPage />} />
|
||||||
<Route path="memory" element={<MemoryPage />} />
|
<Route path="memory" element={<MemoryPage />} />
|
||||||
|
<Route path="llm-calls" element={<LLMCallsPage />} />
|
||||||
|
<Route path="system" element={<SystemPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ const navItems = [
|
|||||||
{ to: '/admin/iot', label: 'IoT 设备', icon: '⌂' },
|
{ to: '/admin/iot', label: 'IoT 设备', icon: '⌂' },
|
||||||
{ to: '/admin/plugins', label: '插件管理', icon: '◇' },
|
{ to: '/admin/plugins', label: '插件管理', icon: '◇' },
|
||||||
{ to: '/admin/memory', label: '记忆管理', icon: '♡' },
|
{ to: '/admin/memory', label: '记忆管理', icon: '♡' },
|
||||||
|
{ to: '/admin/llm-calls', label: 'LLM 日志', icon: '🧠' },
|
||||||
|
{ to: '/admin/system', label: '系统信息', icon: '⊞' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function AdminLayout() {
|
export default function AdminLayout() {
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { getToken } from '../../api/client'
|
||||||
|
|
||||||
|
export default function LLMCallsPage() {
|
||||||
|
const [calls, setCalls] = useState<any[]>([])
|
||||||
|
const [stats, setStats] = useState<any>({})
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<h2 className="text-lg font-semibold">LLM 调用日志</h2>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
<MiniStat label="总调用" value={stats.total_calls || 0} />
|
||||||
|
<MiniStat label="成功率" value={stats.total_calls ? `${Math.round(stats.success_calls/stats.total_calls*100)}%` : '-'} />
|
||||||
|
<MiniStat label="平均耗时" value={stats.avg_duration_ms ? `${stats.avg_duration_ms}ms` : '-'} />
|
||||||
|
<MiniStat label="总 Token" value={stats.total_tokens || 0} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Calls table */}
|
||||||
|
<div className="bg-[#000] rounded-[18px] border border-[#404040] overflow-hidden">
|
||||||
|
{calls.length === 0 ? (
|
||||||
|
<div className="p-8 text-center text-white/30 text-sm">暂无 LLM 调用记录</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-[#393939] text-white/40 text-xs">
|
||||||
|
<th className="text-left px-4 py-2 font-normal">时间</th>
|
||||||
|
<th className="text-left px-4 py-2 font-normal">模型</th>
|
||||||
|
<th className="text-left px-4 py-2 font-normal">用途</th>
|
||||||
|
<th className="text-right px-4 py-2 font-normal">耗时</th>
|
||||||
|
<th className="text-right px-4 py-2 font-normal">Token</th>
|
||||||
|
<th className="text-center px-4 py-2 font-normal">状态</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{calls.map((c, i) => (
|
||||||
|
<tr key={c.id || i} className="border-b border-[#393939]/50">
|
||||||
|
<td className="px-4 py-2.5 text-white/50 text-xs font-mono">{c.time?.slice(11,19) || '-'}</td>
|
||||||
|
<td className="px-4 py-2.5 text-[#F2F2F2]">{c.model || '-'}</td>
|
||||||
|
<td className="px-4 py-2.5 text-white/50 text-xs">
|
||||||
|
<span className="px-1.5 py-0.5 rounded bg-[#2B3B54]/50 text-[#4788FF]">{c.purpose || '-'}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-white/50 text-xs text-right">{c.duration_ms}ms</td>
|
||||||
|
<td className="px-4 py-2.5 text-white/50 text-xs text-right">{(c.prompt_tokens||0)+(c.completion_tokens||0)}</td>
|
||||||
|
<td className="px-4 py-2.5 text-center">
|
||||||
|
<span className={`text-xs ${c.success ? 'text-[#4788FF]' : 'text-[#F12522]'}`}>
|
||||||
|
{c.success ? '✓' : '✗'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MiniStat({ label, value }: { label:string; value:string|number }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-[#000] border border-[#404040] rounded-[18px] px-4 py-3">
|
||||||
|
<p className="text-xs text-white/40 mb-1">{label}</p>
|
||||||
|
<p className="text-lg font-bold text-[#F2F2F2]">{value}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { getToken } from '../../api/client'
|
||||||
|
|
||||||
|
export default function SystemPage() {
|
||||||
|
const [info, setInfo] = useState<any>({})
|
||||||
|
const [sessions, setSessions] = useState<any[]>([])
|
||||||
|
const [plugins, setPlugins] = useState<any[]>([])
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<h2 className="text-lg font-semibold">系统信息</h2>
|
||||||
|
|
||||||
|
{/* System Info */}
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
<StatRow label="版本" value={info.version || '-'} />
|
||||||
|
<StatRow label="Go 版本" value={info.go_version || '-'} />
|
||||||
|
<StatRow label="运行时间" value={info.uptime_sec ? `${Math.floor(info.uptime_sec/60)}m` : '-'} />
|
||||||
|
<StatRow label="WS 连接" value={info.ws_clients ?? 0} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Plugins list */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-white/60 mb-3">已加载插件 ({plugins.length})</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{plugins.map((p, i) => (
|
||||||
|
<span key={i} className="px-3 py-1.5 bg-[#000] border border-[#404040] rounded-full text-xs text-[#F2F2F2]">
|
||||||
|
{p.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sessions */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-white/60 mb-3">活跃会话 ({sessions.length})</h3>
|
||||||
|
{sessions.length === 0 ? (
|
||||||
|
<p className="text-xs text-white/30">暂无活跃会话</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{sessions.map((s: any, i) => (
|
||||||
|
<div key={i} className="bg-[#000] border border-[#404040] rounded-[14px] px-4 py-2 text-xs text-white/50">
|
||||||
|
{s.session_id || s.id || '-'}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatRow({ label, value }: { label:string; value:string|number }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-[#000] border border-[#404040] rounded-[18px] px-4 py-3">
|
||||||
|
<p className="text-xs text-white/40 mb-1">{label}</p>
|
||||||
|
<p className="text-lg font-bold text-[#F2F2F2]">{value}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in new issue
Block a user