feat: LLM 调用日志 + ModelSelector 优化 + devtools.bat 编码修复
- 新增 call_log.go: 全局环形缓冲区记录每次 LLM 调用(模型/Token/耗时/错误) - OpenAIProvider.doChat/ChatStreamWithTools 自动记录调用日志 - ai-core 暴露 GET /api/v1/llm-calls 端点, DevTools 代理 + UI 面板 - ModelSelector.envProvider 改为单例缓存, 避免重复创建 HTTP Client - 新增 PurposeToolCalling 适配器, 后台思考工具调用走专用路由 - envFallback 超时 120s→180s, 显式设置 MaxRetries - devtools.bat 全英文, 解决 Windows CMD GBK 编码乱码问题 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -67,7 +67,8 @@ func main() {
|
|||||||
APIKey: cfg.LLMAPIKey,
|
APIKey: cfg.LLMAPIKey,
|
||||||
Model: cfg.LLMModel,
|
Model: cfg.LLMModel,
|
||||||
FallbackModel: cfg.LLMFallbackModel,
|
FallbackModel: cfg.LLMFallbackModel,
|
||||||
Timeout: 120 * time.Second,
|
MaxRetries: 3,
|
||||||
|
Timeout: 180 * time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建 ModelSelector (优先使用 models.json,回退到 .env)
|
// 创建 ModelSelector (优先使用 models.json,回退到 .env)
|
||||||
@@ -82,10 +83,12 @@ func main() {
|
|||||||
thinkerAdapter := llm.NewAdapter(provider)
|
thinkerAdapter := llm.NewAdapter(provider)
|
||||||
provider, _ = modelSelector.Select(context.Background(), llm.PurposeMemoryExtraction)
|
provider, _ = modelSelector.Select(context.Background(), llm.PurposeMemoryExtraction)
|
||||||
memoryAdapter := llm.NewAdapter(provider)
|
memoryAdapter := llm.NewAdapter(provider)
|
||||||
|
provider, _ = modelSelector.Select(context.Background(), llm.PurposeToolCalling)
|
||||||
|
toolAdapter := llm.NewAdapter(provider)
|
||||||
|
|
||||||
if configLoader != nil && configLoader.HasConfig() {
|
if configLoader != nil && configLoader.HasConfig() {
|
||||||
log.Printf("LLM适配器已就绪: models.json 驱动 (chat=%s, intent=%s, think=%s, memory=%s)",
|
log.Printf("LLM适配器已就绪: models.json 驱动 (chat=%s, intent=%s, think=%s, memory=%s, tool=%s)",
|
||||||
chatAdapter.ModelName(), intentAdapter.ModelName(), thinkerAdapter.ModelName(), memoryAdapter.ModelName())
|
chatAdapter.ModelName(), intentAdapter.ModelName(), thinkerAdapter.ModelName(), memoryAdapter.ModelName(), toolAdapter.ModelName())
|
||||||
} else {
|
} else {
|
||||||
log.Printf("LLM适配器已就绪: .env 驱动 (模型=%s)", chatAdapter.ModelName())
|
log.Printf("LLM适配器已就绪: .env 驱动 (模型=%s)", chatAdapter.ModelName())
|
||||||
}
|
}
|
||||||
@@ -193,6 +196,7 @@ func main() {
|
|||||||
personaLoader,
|
personaLoader,
|
||||||
memRetriever,
|
memRetriever,
|
||||||
thinkerAdapter,
|
thinkerAdapter,
|
||||||
|
toolAdapter,
|
||||||
iotClient,
|
iotClient,
|
||||||
memStore,
|
memStore,
|
||||||
toolRegistry,
|
toolRegistry,
|
||||||
@@ -311,6 +315,22 @@ func main() {
|
|||||||
w.Write([]byte(`{"status":"ok","service":"ai-core","model":"` + chatAdapter.ModelName() + `"}`))
|
w.Write([]byte(`{"status":"ok","service":"ai-core","model":"` + chatAdapter.ModelName() + `"}`))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// LLM 调用日志(调试用)
|
||||||
|
mux.HandleFunc("/api/v1/llm-calls", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
limit := 50
|
||||||
|
if n, err := fmt.Sscanf(r.URL.Query().Get("limit"), "%d", &limit); n != 1 || err != nil || limit <= 0 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
if limit > 500 {
|
||||||
|
limit = 500
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"calls": llm.GetCalls(limit),
|
||||||
|
"total": len(llm.GetCalls(0)),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
// 启动HTTP服务
|
// 启动HTTP服务
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: ":" + cfg.Port,
|
Addr: ":" + cfg.Port,
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ type Thinker struct {
|
|||||||
personaLoader *persona.Loader
|
personaLoader *persona.Loader
|
||||||
memRetriever *memory.Retriever
|
memRetriever *memory.Retriever
|
||||||
llmAdapter *llm.Adapter
|
llmAdapter *llm.Adapter
|
||||||
|
toolAdapter *llm.Adapter // 工具调用专用适配器
|
||||||
iotClient *tools.IoTClient
|
iotClient *tools.IoTClient
|
||||||
|
|
||||||
// 记忆管理
|
// 记忆管理
|
||||||
@@ -224,6 +225,7 @@ func NewThinker(
|
|||||||
personaLoader *persona.Loader,
|
personaLoader *persona.Loader,
|
||||||
memRetriever *memory.Retriever,
|
memRetriever *memory.Retriever,
|
||||||
llmAdapter *llm.Adapter,
|
llmAdapter *llm.Adapter,
|
||||||
|
toolAdapter *llm.Adapter,
|
||||||
iotClient *tools.IoTClient,
|
iotClient *tools.IoTClient,
|
||||||
memoryStore *memory.Store,
|
memoryStore *memory.Store,
|
||||||
toolRegistry *tools.Registry,
|
toolRegistry *tools.Registry,
|
||||||
@@ -237,6 +239,7 @@ func NewThinker(
|
|||||||
personaLoader: personaLoader,
|
personaLoader: personaLoader,
|
||||||
memRetriever: memRetriever,
|
memRetriever: memRetriever,
|
||||||
llmAdapter: llmAdapter,
|
llmAdapter: llmAdapter,
|
||||||
|
toolAdapter: toolAdapter,
|
||||||
iotClient: iotClient,
|
iotClient: iotClient,
|
||||||
thinkInterval: cfg.ThinkInterval,
|
thinkInterval: cfg.ThinkInterval,
|
||||||
silenceTimeout: cfg.SilenceTimeout,
|
silenceTimeout: cfg.SilenceTimeout,
|
||||||
@@ -598,7 +601,7 @@ func (t *Thinker) performThink(triggerReason string) {
|
|||||||
var toolCallRecords []map[string]interface{}
|
var toolCallRecords []map[string]interface{}
|
||||||
|
|
||||||
for round := 0; round <= maxToolRounds; round++ {
|
for round := 0; round <= maxToolRounds; round++ {
|
||||||
resp, err := t.llmAdapter.ChatWithTools(ctx, messages, openAITools)
|
resp, err := t.toolAdapter.ChatWithTools(ctx, messages, openAITools)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[后台思考] LLM调用失败 (round=%d): %v", round, err)
|
log.Printf("[后台思考] LLM调用失败 (round=%d): %v", round, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CallRecord records a single LLM API call.
|
||||||
|
type CallRecord struct {
|
||||||
|
Time time.Time `json:"time"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
Duration time.Duration `json:"duration_ms"`
|
||||||
|
PromptTokens int `json:"prompt_tokens"`
|
||||||
|
CompletionTokens int `json:"completion_tokens"`
|
||||||
|
TotalTokens int `json:"total_tokens"`
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CallLogger is a thread-safe ring buffer for LLM call records.
|
||||||
|
type CallLogger struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
records []CallRecord
|
||||||
|
capacity int
|
||||||
|
head int
|
||||||
|
size int
|
||||||
|
}
|
||||||
|
|
||||||
|
var globalCallLogger = &CallLogger{capacity: 500}
|
||||||
|
|
||||||
|
// LogCall records an LLM call. Safe for concurrent use.
|
||||||
|
func LogCall(r CallRecord) {
|
||||||
|
globalCallLogger.log(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCalls returns recent call records, newest first.
|
||||||
|
func GetCalls(limit int) []CallRecord {
|
||||||
|
return globalCallLogger.get(limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cl *CallLogger) log(r CallRecord) {
|
||||||
|
cl.mu.Lock()
|
||||||
|
defer cl.mu.Unlock()
|
||||||
|
|
||||||
|
if cl.records == nil {
|
||||||
|
cl.records = make([]CallRecord, cl.capacity)
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Time = time.Now()
|
||||||
|
cl.records[cl.head] = r
|
||||||
|
cl.head = (cl.head + 1) % cl.capacity
|
||||||
|
if cl.size < cl.capacity {
|
||||||
|
cl.size++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cl *CallLogger) get(limit int) []CallRecord {
|
||||||
|
cl.mu.RLock()
|
||||||
|
defer cl.mu.RUnlock()
|
||||||
|
|
||||||
|
if limit <= 0 || limit > cl.size {
|
||||||
|
limit = cl.size
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]CallRecord, limit)
|
||||||
|
for i := 0; i < limit; i++ {
|
||||||
|
idx := (cl.head - 1 - i) % cl.capacity
|
||||||
|
if idx < 0 {
|
||||||
|
idx += cl.capacity
|
||||||
|
}
|
||||||
|
result[i] = cl.records[idx]
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -139,14 +139,38 @@ func (p *OpenAIProvider) ChatStreamWithTools(ctx context.Context, messages []mod
|
|||||||
go func() {
|
go func() {
|
||||||
defer close(ch)
|
defer close(ch)
|
||||||
|
|
||||||
|
startTime := time.Now()
|
||||||
|
modelName := p.config.Model
|
||||||
|
var streamErr error
|
||||||
|
var finalUsage *model.Usage
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
r := CallRecord{
|
||||||
|
Model: modelName,
|
||||||
|
Duration: time.Since(startTime),
|
||||||
|
Success: streamErr == nil,
|
||||||
|
}
|
||||||
|
if streamErr != nil {
|
||||||
|
r.Error = streamErr.Error()
|
||||||
|
}
|
||||||
|
if finalUsage != nil {
|
||||||
|
r.PromptTokens = finalUsage.PromptTokens
|
||||||
|
r.CompletionTokens = finalUsage.CompletionTokens
|
||||||
|
r.TotalTokens = finalUsage.TotalTokens
|
||||||
|
}
|
||||||
|
LogCall(r)
|
||||||
|
}()
|
||||||
|
|
||||||
resp, err := p.doChatStream(ctx, messages, p.config.Model, tools)
|
resp, err := p.doChatStream(ctx, messages, p.config.Model, tools)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Fallback
|
// Fallback
|
||||||
if p.config.FallbackModel != "" {
|
if p.config.FallbackModel != "" {
|
||||||
logger.Printf("[LLM] 流式调用主模型失败,降级: %v", err)
|
logger.Printf("[LLM] 流式调用主模型失败,降级: %v", err)
|
||||||
|
modelName = p.config.FallbackModel
|
||||||
resp, err = p.doChatStream(ctx, messages, p.config.FallbackModel, tools)
|
resp, err = p.doChatStream(ctx, messages, p.config.FallbackModel, tools)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
streamErr = err
|
||||||
ch <- StreamChunk{Error: err, Done: true}
|
ch <- StreamChunk{Error: err, Done: true}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -184,20 +208,22 @@ func (p *OpenAIProvider) ChatStreamWithTools(ctx context.Context, messages []mod
|
|||||||
ch <- StreamChunk{Content: deltaStr}
|
ch <- StreamChunk{Content: deltaStr}
|
||||||
}
|
}
|
||||||
if streamResp.Choices[0].FinishReason != "" {
|
if streamResp.Choices[0].FinishReason != "" {
|
||||||
usage := &model.Usage{}
|
|
||||||
if streamResp.Usage != nil {
|
if streamResp.Usage != nil {
|
||||||
usage.PromptTokens = streamResp.Usage.PromptTokens
|
finalUsage = &model.Usage{
|
||||||
usage.CompletionTokens = streamResp.Usage.CompletionTokens
|
PromptTokens: streamResp.Usage.PromptTokens,
|
||||||
usage.TotalTokens = streamResp.Usage.TotalTokens
|
CompletionTokens: streamResp.Usage.CompletionTokens,
|
||||||
|
TotalTokens: streamResp.Usage.TotalTokens,
|
||||||
}
|
}
|
||||||
ch <- StreamChunk{Done: true, Usage: usage}
|
}
|
||||||
|
ch <- StreamChunk{Done: true, Usage: finalUsage}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := scanner.Err(); err != nil {
|
if err := scanner.Err(); err != nil {
|
||||||
ch <- StreamChunk{Error: fmt.Errorf("读取流式响应失败: %w", err), Done: true}
|
streamErr = fmt.Errorf("读取流式响应失败: %w", err)
|
||||||
|
ch <- StreamChunk{Error: streamErr, Done: true}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,7 +248,25 @@ type openAIStreamChoice struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// doChat 执行同步对话请求
|
// doChat 执行同步对话请求
|
||||||
func (p *OpenAIProvider) doChat(ctx context.Context, messages []model.LLMMessage, modelName string, stream bool, tools []OpenAITool) (*model.LLMResponse, error) {
|
func (p *OpenAIProvider) doChat(ctx context.Context, messages []model.LLMMessage, modelName string, stream bool, tools []OpenAITool) (llmResp *model.LLMResponse, err error) {
|
||||||
|
startTime := time.Now()
|
||||||
|
defer func() {
|
||||||
|
r := CallRecord{
|
||||||
|
Model: modelName,
|
||||||
|
Duration: time.Since(startTime),
|
||||||
|
Success: err == nil,
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
r.Error = err.Error()
|
||||||
|
}
|
||||||
|
if llmResp != nil {
|
||||||
|
r.PromptTokens = llmResp.Usage.PromptTokens
|
||||||
|
r.CompletionTokens = llmResp.Usage.CompletionTokens
|
||||||
|
r.TotalTokens = llmResp.Usage.TotalTokens
|
||||||
|
}
|
||||||
|
LogCall(r)
|
||||||
|
}()
|
||||||
|
|
||||||
// 转换消息格式
|
// 转换消息格式
|
||||||
oaiMessages := make([]openAIMessage, len(messages))
|
oaiMessages := make([]openAIMessage, len(messages))
|
||||||
for i, msg := range messages {
|
for i, msg := range messages {
|
||||||
@@ -304,7 +348,7 @@ func (p *OpenAIProvider) doChat(ctx context.Context, messages []model.LLMMessage
|
|||||||
|
|
||||||
// 检查是否有工具调用
|
// 检查是否有工具调用
|
||||||
choice := oaiResp.Choices[0]
|
choice := oaiResp.Choices[0]
|
||||||
llmResp := &model.LLMResponse{
|
llmResp = &model.LLMResponse{
|
||||||
Content: contentString(choice.Message.Content),
|
Content: contentString(choice.Message.Content),
|
||||||
FinishReason: choice.FinishReason,
|
FinishReason: choice.FinishReason,
|
||||||
ReasoningContent: choice.Message.ReasoningContent,
|
ReasoningContent: choice.Message.ReasoningContent,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ type ModelSelector struct {
|
|||||||
envCfg OpenAIConfig
|
envCfg OpenAIConfig
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
cache map[string]LLMProvider
|
cache map[string]LLMProvider
|
||||||
|
cachedEnv LLMProvider // cached env fallback, created once
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewModelSelector creates a ModelSelector. If loader is nil or has no config,
|
// NewModelSelector creates a ModelSelector. If loader is nil or has no config,
|
||||||
@@ -81,7 +82,12 @@ func (s *ModelSelector) DefaultAdapter() *Adapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *ModelSelector) envProvider() LLMProvider {
|
func (s *ModelSelector) envProvider() LLMProvider {
|
||||||
return NewOpenAIProvider(s.envCfg)
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if s.cachedEnv == nil {
|
||||||
|
s.cachedEnv = NewOpenAIProvider(s.envCfg)
|
||||||
|
}
|
||||||
|
return s.cachedEnv
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ModelSelector) getOrCreateProvider(modelID string, cfg *config.ModelsConfigData) (LLMProvider, error) {
|
func (s *ModelSelector) getOrCreateProvider(modelID string, cfg *config.ModelsConfigData) (LLMProvider, error) {
|
||||||
|
|||||||
+2
-2
@@ -2,7 +2,7 @@
|
|||||||
setlocal enabledelayedexpansion
|
setlocal enabledelayedexpansion
|
||||||
:: ========================================
|
:: ========================================
|
||||||
:: Cyrene DevTools CLI (Windows CMD)
|
:: Cyrene DevTools CLI (Windows CMD)
|
||||||
:: 用法: devtools.bat [命令] [选项]
|
:: Usage: devtools.bat [command] [options]
|
||||||
:: ========================================
|
:: ========================================
|
||||||
|
|
||||||
set "SCRIPT_DIR=%~dp0"
|
set "SCRIPT_DIR=%~dp0"
|
||||||
@@ -13,7 +13,7 @@ set "LOG_DIR=%DEVTOOLS_DIR%\logs"
|
|||||||
set "LOG_FILE=%LOG_DIR%\sh.log"
|
set "LOG_FILE=%LOG_DIR%\sh.log"
|
||||||
set "DB_COMPOSE_FILE=%ROOT%docker-compose.dev.db.yml"
|
set "DB_COMPOSE_FILE=%ROOT%docker-compose.dev.db.yml"
|
||||||
|
|
||||||
:: 解析第一个参数作为命令
|
:: Parse first argument as command
|
||||||
set CMD=%1
|
set CMD=%1
|
||||||
if "%CMD%"=="" set CMD=start
|
if "%CMD%"=="" set CMD=start
|
||||||
if "%CMD%"=="help" goto :help
|
if "%CMD%"=="help" goto :help
|
||||||
|
|||||||
@@ -696,6 +696,9 @@ input[type="range"] { accent-color: var(--accent); padding: 0; }
|
|||||||
<button class="nav-item" data-panel="modelConfig">
|
<button class="nav-item" data-panel="modelConfig">
|
||||||
<span class="nav-icon">🤖</span><span class="nav-label">模型配置</span>
|
<span class="nav-icon">🤖</span><span class="nav-label">模型配置</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="nav-item" data-panel="llmCalls">
|
||||||
|
<span class="nav-icon">📊</span><span class="nav-label">LLM 调用</span>
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
<span id="ws-dot" class="disconnected"></span>
|
<span id="ws-dot" class="disconnected"></span>
|
||||||
@@ -737,6 +740,7 @@ input[type="range"] { accent-color: var(--accent); padding: 0; }
|
|||||||
<!-- 客户端管理 -->
|
<!-- 客户端管理 -->
|
||||||
<div class="panel" id="panel-clients"></div>
|
<div class="panel" id="panel-clients"></div>
|
||||||
<div class="panel" id="panel-modelConfig"></div>
|
<div class="panel" id="panel-modelConfig"></div>
|
||||||
|
<div class="panel" id="panel-llmCalls"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -801,6 +805,7 @@ const STATE = {
|
|||||||
modelConfigModels: [],
|
modelConfigModels: [],
|
||||||
modelConfigRouting: [],
|
modelConfigRouting: [],
|
||||||
fetchedModels: [],
|
fetchedModels: [],
|
||||||
|
llmCallsData: [],
|
||||||
expandedThinkingLogs: {},
|
expandedThinkingLogs: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1015,6 +1020,7 @@ function switchPanel(name) {
|
|||||||
chatPlatforms: '💬 第三方聊天配置与消息日志',
|
chatPlatforms: '💬 第三方聊天配置与消息日志',
|
||||||
clients: '📱 客户端管理',
|
clients: '📱 客户端管理',
|
||||||
modelConfig: '🤖 模型配置管理',
|
modelConfig: '🤖 模型配置管理',
|
||||||
|
llmCalls: '📊 LLM 调用日志',
|
||||||
};
|
};
|
||||||
document.getElementById('panel-title').textContent = titles[name] || name;
|
document.getElementById('panel-title').textContent = titles[name] || name;
|
||||||
|
|
||||||
@@ -1041,6 +1047,7 @@ function switchPanel(name) {
|
|||||||
case 'chatPlatforms': renderChatPlatformsPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); startChatAutoRefresh(); break;
|
case 'chatPlatforms': renderChatPlatformsPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); startChatAutoRefresh(); break;
|
||||||
case 'clients': renderClientsPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
|
case 'clients': renderClientsPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
|
||||||
case 'modelConfig': renderModelConfigPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
|
case 'modelConfig': renderModelConfigPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
|
||||||
|
case 'llmCalls': renderLlmCallsPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4463,6 +4470,63 @@ async function editClientNote(clientID) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== LLM Calls Panel ==========
|
||||||
|
async function renderLlmCallsPanel() {
|
||||||
|
var panel = document.getElementById('panel-llmCalls');
|
||||||
|
panel.innerHTML = '<div class="loading">🔄 加载中...</div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
var resp = await api('/api/llm-calls?limit=100');
|
||||||
|
var calls = resp.calls || [];
|
||||||
|
STATE.llmCallsData = calls;
|
||||||
|
|
||||||
|
if (calls.length === 0) {
|
||||||
|
panel.innerHTML = '<div class="empty-state">暂无 LLM 调用记录<br><small>发送一条消息后刷新查看</small></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalTokens = calls.reduce(function(s, c) { return s + (c.total_tokens || 0); }, 0);
|
||||||
|
var successCount = calls.filter(function(c) { return c.success; }).length;
|
||||||
|
|
||||||
|
var html = '<div class="stats-row" style="margin-bottom:16px">' +
|
||||||
|
'<div class="stat-card"><div class="stat-value">' + calls.length + '</div><div class="stat-label">总调用</div></div>' +
|
||||||
|
'<div class="stat-card"><div class="stat-value">' + successCount + '/' + calls.length + '</div><div class="stat-label">成功</div></div>' +
|
||||||
|
'<div class="stat-card"><div class="stat-value">' + formatTokens(totalTokens) + '</div><div class="stat-label">总 Token</div></div>' +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
html += '<div class="table-wrap"><table class="data-table">' +
|
||||||
|
'<thead><tr>' +
|
||||||
|
'<th>时间</th><th>模型</th><th>耗时</th><th>Prompt</th><th>Completion</th><th>Total</th><th>状态</th>' +
|
||||||
|
'</tr></thead><tbody>';
|
||||||
|
|
||||||
|
calls.forEach(function(c) {
|
||||||
|
var statusClass = c.success ? 'status-ok' : 'status-err';
|
||||||
|
var statusText = c.success ? '✓' : '✗ ' + escHtml(c.error || '');
|
||||||
|
var durMs = (c.duration_ms || 0) / 1000000;
|
||||||
|
html += '<tr>' +
|
||||||
|
'<td style="font-size:11px;white-space:nowrap">' + formatTime(c.time) + '</td>' +
|
||||||
|
'<td style="font-size:12px;font-family:monospace">' + escHtml(c.model) + '</td>' +
|
||||||
|
'<td style="font-size:11px">' + (durMs > 0 ? (durMs / 1000).toFixed(2) + 's' : '-') + '</td>' +
|
||||||
|
'<td style="font-size:11px">' + (c.prompt_tokens || 0).toLocaleString() + '</td>' +
|
||||||
|
'<td style="font-size:11px">' + (c.completion_tokens || 0).toLocaleString() + '</td>' +
|
||||||
|
'<td style="font-size:11px;font-weight:600">' + (c.total_tokens || 0).toLocaleString() + '</td>' +
|
||||||
|
'<td><span class="' + statusClass + '">' + statusText + '</span></td>' +
|
||||||
|
'</tr>';
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '</tbody></table></div>';
|
||||||
|
panel.innerHTML = html;
|
||||||
|
} catch (err) {
|
||||||
|
panel.innerHTML = '<div class="error-state">加载失败: ' + escHtml(err.message) + '<br><small>确认 AI-Core 服务已启动</small></div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTokens(n) {
|
||||||
|
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
|
||||||
|
if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
|
||||||
|
return String(n);
|
||||||
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
<script src="iot-panel.js"></script>
|
<script src="iot-panel.js"></script>
|
||||||
<script>
|
<script>
|
||||||
@@ -4471,7 +4535,7 @@ async function editClientNote(clientID) {
|
|||||||
// Listen for browser back/forward navigation.
|
// Listen for browser back/forward navigation.
|
||||||
window.addEventListener('hashchange', function() {
|
window.addEventListener('hashchange', function() {
|
||||||
var hash = location.hash.replace('#', '');
|
var hash = location.hash.replace('#', '');
|
||||||
var validPanels = ['dashboard', 'memory', 'sessions', 'services', 'iot', 'performance', 'database', 'toolCalls', 'stt', 'thinking', 'timeline', 'chatPlatforms', 'clients', 'modelConfig'];
|
var validPanels = ['dashboard', 'memory', 'sessions', 'services', 'iot', 'performance', 'database', 'toolCalls', 'stt', 'thinking', 'timeline', 'chatPlatforms', 'clients', 'modelConfig', 'llmCalls'];
|
||||||
if (hash && validPanels.indexOf(hash) >= 0 && hash !== STATE.activePanel) {
|
if (hash && validPanels.indexOf(hash) >= 0 && hash !== STATE.activePanel) {
|
||||||
switchPanel(hash);
|
switchPanel(hash);
|
||||||
}
|
}
|
||||||
@@ -4482,7 +4546,7 @@ refreshStatus();
|
|||||||
|
|
||||||
// Restore last panel from URL hash, or default to dashboard.
|
// Restore last panel from URL hash, or default to dashboard.
|
||||||
var initHash = location.hash.replace('#', '');
|
var initHash = location.hash.replace('#', '');
|
||||||
var validPanels = ['dashboard', 'memory', 'sessions', 'services', 'iot', 'performance', 'database', 'toolCalls', 'stt', 'thinking', 'timeline', 'chatPlatforms', 'clients', 'modelConfig'];
|
var validPanels = ['dashboard', 'memory', 'sessions', 'services', 'iot', 'performance', 'database', 'toolCalls', 'stt', 'thinking', 'timeline', 'chatPlatforms', 'clients', 'modelConfig', 'llmCalls'];
|
||||||
if (initHash && validPanels.indexOf(initHash) >= 0) {
|
if (initHash && validPanels.indexOf(initHash) >= 0) {
|
||||||
switchPanel(initHash);
|
switchPanel(initHash);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { processManager } from './process-manager.js';
|
|||||||
import { performanceMonitor } from './performance.js';
|
import { performanceMonitor } from './performance.js';
|
||||||
import { SERVICES, DEVTOOLS_PORT, LOGS_DIR, logFile, GATEWAY_URL, TOOL_ENGINE_URL, ADMIN_USERNAME, ADMIN_PASSWORD } from './config.js';
|
import { SERVICES, DEVTOOLS_PORT, LOGS_DIR, logFile, GATEWAY_URL, TOOL_ENGINE_URL, ADMIN_USERNAME, ADMIN_PASSWORD } from './config.js';
|
||||||
|
|
||||||
|
const AI_CORE_URL = process.env.AI_CORE_URL || 'http://localhost:8081';
|
||||||
const MEMORY_SERVICE_URL = process.env.MEMORY_SERVICE_URL || 'http://localhost:8091';
|
const MEMORY_SERVICE_URL = process.env.MEMORY_SERVICE_URL || 'http://localhost:8091';
|
||||||
const VOICE_SERVICE_URL = process.env.VOICE_SERVICE_URL || 'http://localhost:8093';
|
const VOICE_SERVICE_URL = process.env.VOICE_SERVICE_URL || 'http://localhost:8093';
|
||||||
const PLATFORM_BRIDGE_URL = process.env.PLATFORM_BRIDGE_URL || 'http://localhost:8095';
|
const PLATFORM_BRIDGE_URL = process.env.PLATFORM_BRIDGE_URL || 'http://localhost:8095';
|
||||||
@@ -168,6 +169,31 @@ async function proxyToGateway(path, opts = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 代理请求到 AI-Core
|
||||||
|
*/
|
||||||
|
async function proxyToAICore(path, opts = {}) {
|
||||||
|
const url = `${AI_CORE_URL}${path}`;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(url, {
|
||||||
|
...opts,
|
||||||
|
headers: { 'Content-Type': 'application/json', ...opts.headers },
|
||||||
|
signal: AbortSignal.timeout(10000),
|
||||||
|
});
|
||||||
|
const body = await resp.json().catch(() => null);
|
||||||
|
return { status: resp.status, body };
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
status: 502,
|
||||||
|
body: {
|
||||||
|
error: `AI-Core 不可达: ${err.message}`,
|
||||||
|
errorType: 'ai_core_unreachable',
|
||||||
|
hint: 'AI-Core 服务未启动,请先在「服务管理」面板中启动 AI-Core',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ========== REST API 路由 ==========
|
// ========== REST API 路由 ==========
|
||||||
|
|
||||||
// ---- 健康检查 ----
|
// ---- 健康检查 ----
|
||||||
@@ -1003,6 +1029,13 @@ app.get('/api/voice/health', async (_req, res) => {
|
|||||||
res.status(result.status).json(result.body);
|
res.status(result.status).json(result.body);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /api/llm-calls — LLM 调用日志 (代理到 AI-Core)
|
||||||
|
app.get('/api/llm-calls', async (req, res) => {
|
||||||
|
const limit = parseInt(req.query.limit) || 50;
|
||||||
|
const result = await proxyToAICore(`/api/v1/llm-calls?limit=${Math.min(limit, 500)}`);
|
||||||
|
res.status(result.status).json(result.body);
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 代理请求到 Memory-Service
|
* 代理请求到 Memory-Service
|
||||||
* @param {string} path - Memory-Service API 路径
|
* @param {string} path - Memory-Service API 路径
|
||||||
|
|||||||
Reference in New Issue
Block a user