From e218316c55f1a2f8f6855c1865308f8a5c4d9506 Mon Sep 17 00:00:00 2001 From: temp Date: Mon, 24 Aug 2026 00:05:05 +0800 Subject: [PATCH] Add token usage tracking and UI refinements --- internal/app/app.go | 2 + internal/database/conversation.go | 2 + internal/database/database.go | 38 ++ internal/database/model_token_usage.go | 485 ++++++++++++++++++ internal/database/model_token_usage_test.go | 85 +++ internal/handler/token_usage.go | 92 ++++ .../multiagent/eino_run_runtime_session.go | 6 +- .../multiagent/eino_run_usage_accumulator.go | 3 + .../eino_run_usage_accumulator_test.go | 6 +- internal/security/rbac_middleware.go | 2 + internal/security/rbac_middleware_test.go | 6 + run.sh | 0 web/static/css/style.css | 184 +++++-- web/static/i18n/en-US.json | 5 + web/static/i18n/zh-CN.json | 5 + web/static/js/chat-message-copy.test.cjs | 35 ++ web/static/js/chat.js | 280 +++++++++- web/static/js/dashboard.js | 46 +- web/static/js/monitor.js | 6 +- web/static/js/project-folder-preview.test.cjs | 14 + web/static/js/projects.js | 15 +- web/static/js/turn-duration-refresh.test.cjs | 41 +- web/templates/index.html | 20 +- 23 files changed, 1285 insertions(+), 93 deletions(-) create mode 100644 internal/database/model_token_usage.go create mode 100644 internal/database/model_token_usage_test.go create mode 100644 internal/handler/token_usage.go mode change 100644 => 100755 run.sh create mode 100644 web/static/js/chat-message-copy.test.cjs diff --git a/internal/app/app.go b/internal/app/app.go index 7cf5b079..2fa1881e 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -1027,9 +1027,11 @@ func setupRoutes( protected.DELETE("/batch-tasks/:queueId/tasks/:taskId", agentHandler.DeleteBatchTask) // 对话历史 + protected.GET("/usage/tokens", conversationHandler.GetTokenUsageStats) protected.POST("/conversations", conversationHandler.CreateConversation) protected.GET("/conversations", conversationHandler.ListConversations) protected.GET("/conversations/:id", conversationHandler.GetConversation) + protected.GET("/conversations/:id/token-usage", conversationHandler.GetConversationTokenUsageStats) protected.GET("/conversations/:id/plan-tasks", conversationHandler.GetConversationPlanTasks) protected.GET("/messages/:id/process-details", conversationHandler.GetMessageProcessDetails) protected.GET("/process-details/:id", conversationHandler.GetProcessDetail) diff --git a/internal/database/conversation.go b/internal/database/conversation.go index fac4b894..a5eeca5f 100644 --- a/internal/database/conversation.go +++ b/internal/database/conversation.go @@ -1350,6 +1350,8 @@ func (db *DB) AddProcessDetailWithID(messageID, conversationID, eventType, messa return "", fmt.Errorf("添加过程详情失败: %w", err) } + db.maybeRecordModelTokenUsage(messageID, conversationID, id, eventType, data) + return id, nil } diff --git a/internal/database/database.go b/internal/database/database.go index 35884987..43fb2192 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -216,6 +216,32 @@ func (db *DB) initTables() error { FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE );` + // 创建模型 Token 用量表:process_details 负责时间线回放,本表负责结构化聚合统计。 + createModelTokenUsageTable := ` + CREATE TABLE IF NOT EXISTS model_token_usage ( + id TEXT PRIMARY KEY, + process_detail_id TEXT NOT NULL UNIQUE, + message_id TEXT NOT NULL, + conversation_id TEXT NOT NULL, + project_id TEXT, + source TEXT NOT NULL DEFAULT '', + orchestration TEXT NOT NULL DEFAULT '', + reason TEXT NOT NULL DEFAULT '', + model TEXT NOT NULL DEFAULT '', + model_calls INTEGER NOT NULL DEFAULT 0, + prompt_tokens INTEGER NOT NULL DEFAULT 0, + completion_tokens INTEGER NOT NULL DEFAULT 0, + total_tokens INTEGER NOT NULL DEFAULT 0, + cached_tokens INTEGER NOT NULL DEFAULT 0, + reasoning_tokens INTEGER NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + FOREIGN KEY (process_detail_id) REFERENCES process_details(id) ON DELETE CASCADE, + FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE, + FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE, + FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE SET NULL + );` + // 创建工具执行记录表 createToolExecutionsTable := ` CREATE TABLE IF NOT EXISTS tool_executions ( @@ -719,6 +745,10 @@ func (db *DB) initTables() error { CREATE INDEX IF NOT EXISTS idx_conversations_updated_at ON conversations(updated_at); CREATE INDEX IF NOT EXISTS idx_process_details_message_id ON process_details(message_id); CREATE INDEX IF NOT EXISTS idx_process_details_conversation_id ON process_details(conversation_id); + CREATE INDEX IF NOT EXISTS idx_model_token_usage_created_at ON model_token_usage(created_at); + CREATE INDEX IF NOT EXISTS idx_model_token_usage_conversation ON model_token_usage(conversation_id); + CREATE INDEX IF NOT EXISTS idx_model_token_usage_project ON model_token_usage(project_id); + CREATE INDEX IF NOT EXISTS idx_model_token_usage_model ON model_token_usage(model); CREATE INDEX IF NOT EXISTS idx_tool_executions_tool_name ON tool_executions(tool_name); CREATE INDEX IF NOT EXISTS idx_tool_executions_start_time ON tool_executions(start_time); CREATE INDEX IF NOT EXISTS idx_tool_executions_status ON tool_executions(status); @@ -806,6 +836,10 @@ func (db *DB) initTables() error { return fmt.Errorf("创建process_details表失败: %w", err) } + if _, err := db.Exec(createModelTokenUsageTable); err != nil { + return fmt.Errorf("创建model_token_usage表失败: %w", err) + } + if _, err := db.Exec(createToolExecutionsTable); err != nil { return fmt.Errorf("创建tool_executions表失败: %w", err) } @@ -981,6 +1015,10 @@ func (db *DB) initTables() error { if _, err := db.Exec(createIndexes); err != nil { return fmt.Errorf("创建索引失败: %w", err) } + + if err := db.BackfillModelTokenUsageFromProcessDetails(); err != nil { + return fmt.Errorf("回填模型Token用量失败: %w", err) + } db.logger.Debug("数据库表初始化完成") return nil } diff --git a/internal/database/model_token_usage.go b/internal/database/model_token_usage.go new file mode 100644 index 00000000..426bf5f5 --- /dev/null +++ b/internal/database/model_token_usage.go @@ -0,0 +1,485 @@ +package database + +import ( + "database/sql" + "encoding/json" + "fmt" + "math" + "strconv" + "strings" + "time" + + "github.com/google/uuid" + "go.uber.org/zap" +) + +const modelTokenUsageEventType = "eino_usage_summary" + +// ModelTokenUsage records one model-usage summary emitted by an Agent run. +type ModelTokenUsage struct { + ID string `json:"id"` + ProcessDetailID string `json:"processDetailId"` + MessageID string `json:"messageId"` + ConversationID string `json:"conversationId"` + ProjectID string `json:"projectId,omitempty"` + Source string `json:"source"` + Orchestration string `json:"orchestration"` + Reason string `json:"reason"` + Model string `json:"model,omitempty"` + ModelCalls int64 `json:"modelCalls"` + PromptTokens int64 `json:"promptTokens"` + CompletionTokens int64 `json:"completionTokens"` + TotalTokens int64 `json:"totalTokens"` + CachedTokens int64 `json:"cachedTokens"` + ReasoningTokens int64 `json:"reasoningTokens"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// ModelTokenUsageSummary is the aggregate shape used by dashboard and APIs. +type ModelTokenUsageSummary struct { + Events int64 `json:"events"` + ModelCalls int64 `json:"modelCalls"` + PromptTokens int64 `json:"promptTokens"` + CompletionTokens int64 `json:"completionTokens"` + TotalTokens int64 `json:"totalTokens"` + CachedTokens int64 `json:"cachedTokens"` + ReasoningTokens int64 `json:"reasoningTokens"` +} + +// ModelTokenUsageBreakdown is a grouped aggregate row. +type ModelTokenUsageBreakdown struct { + Key string `json:"key"` + Label string `json:"label,omitempty"` + Events int64 `json:"events"` + ModelCalls int64 `json:"modelCalls"` + PromptTokens int64 `json:"promptTokens"` + CompletionTokens int64 `json:"completionTokens"` + TotalTokens int64 `json:"totalTokens"` + CachedTokens int64 `json:"cachedTokens"` + ReasoningTokens int64 `json:"reasoningTokens"` +} + +// ModelTokenUsageStats is a compact API response for usage dashboards. +type ModelTokenUsageStats struct { + Summary ModelTokenUsageSummary `json:"summary"` + Today ModelTokenUsageSummary `json:"today"` + ByDay []ModelTokenUsageBreakdown `json:"byDay"` + ByModel []ModelTokenUsageBreakdown `json:"byModel"` + ByOrchestration []ModelTokenUsageBreakdown `json:"byOrchestration"` + Recent []ModelTokenUsage `json:"recent"` +} + +// ModelTokenUsageFilter scopes usage queries. +type ModelTokenUsageFilter struct { + ConversationID string + ProjectID string + Since time.Time + Until time.Time + Days int + Access RBACListAccess + Limit int +} + +func modelTokenUsageFromProcessDetail(messageID, conversationID, processDetailID string, data interface{}) (ModelTokenUsage, bool) { + m := mapFromUsageData(data) + if len(m) == 0 { + return ModelTokenUsage{}, false + } + usage := ModelTokenUsage{ + ID: uuid.New().String(), + ProcessDetailID: strings.TrimSpace(processDetailID), + MessageID: strings.TrimSpace(messageID), + ConversationID: strings.TrimSpace(conversationID), + Source: strings.TrimSpace(fmt.Sprint(m["source"])), + Orchestration: strings.TrimSpace(fmt.Sprint(m["orchestration"])), + Reason: strings.TrimSpace(fmt.Sprint(m["reason"])), + Model: strings.TrimSpace(fmt.Sprint(m["model"])), + ModelCalls: usageInt64(m["modelCalls"]), + PromptTokens: usageInt64(m["promptTokens"]), + CompletionTokens: usageInt64(m["completionTokens"]), + TotalTokens: usageInt64(m["totalTokens"]), + CachedTokens: usageInt64(m["cachedTokens"]), + ReasoningTokens: usageInt64(m["reasoningTokens"]), + } + if usage.TotalTokens == 0 && (usage.PromptTokens > 0 || usage.CompletionTokens > 0) { + usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens + } + if usage.ProcessDetailID == "" || usage.MessageID == "" || usage.ConversationID == "" { + return ModelTokenUsage{}, false + } + if usage.ModelCalls == 0 && usage.TotalTokens == 0 && usage.PromptTokens == 0 && usage.CompletionTokens == 0 && usage.CachedTokens == 0 && usage.ReasoningTokens == 0 { + return ModelTokenUsage{}, false + } + return usage, true +} + +func mapFromUsageData(data interface{}) map[string]interface{} { + switch v := data.(type) { + case nil: + return nil + case map[string]interface{}: + return v + case string: + var m map[string]interface{} + if err := json.Unmarshal([]byte(v), &m); err == nil { + return m + } + case []byte: + var m map[string]interface{} + if err := json.Unmarshal(v, &m); err == nil { + return m + } + default: + raw, err := json.Marshal(v) + if err == nil { + var m map[string]interface{} + if err := json.Unmarshal(raw, &m); err == nil { + return m + } + } + } + return nil +} + +func usageInt64(v interface{}) int64 { + switch n := v.(type) { + case int: + return int64(n) + case int8: + return int64(n) + case int16: + return int64(n) + case int32: + return int64(n) + case int64: + return n + case uint: + return int64(n) + case uint8: + return int64(n) + case uint16: + return int64(n) + case uint32: + return int64(n) + case uint64: + if n > math.MaxInt64 { + return math.MaxInt64 + } + return int64(n) + case float32: + return int64(n) + case float64: + return int64(n) + case json.Number: + i, _ := n.Int64() + return i + case string: + i, _ := strconv.ParseInt(strings.TrimSpace(n), 10, 64) + return i + default: + i, _ := strconv.ParseInt(strings.TrimSpace(fmt.Sprint(v)), 10, 64) + return i + } +} + +func (db *DB) maybeRecordModelTokenUsage(messageID, conversationID, processDetailID, eventType string, data interface{}) { + if db == nil || eventType != modelTokenUsageEventType { + return + } + usage, ok := modelTokenUsageFromProcessDetail(messageID, conversationID, processDetailID, data) + if !ok { + return + } + if err := db.UpsertModelTokenUsage(usage); err != nil && db.logger != nil { + db.logger.Warn("保存模型Token用量失败", + zap.String("processDetailId", processDetailID), + zap.String("conversationId", conversationID), + zap.Error(err)) + } +} + +// UpsertModelTokenUsage persists usage with process_detail_id idempotency. +func (db *DB) UpsertModelTokenUsage(usage ModelTokenUsage) error { + if db == nil { + return fmt.Errorf("database is nil") + } + now := time.Now() + createdAt := usage.CreatedAt + if createdAt.IsZero() { + createdAt = now + } + if usage.ID == "" { + usage.ID = uuid.New().String() + } + var projectID sql.NullString + if err := db.QueryRow(`SELECT project_id FROM conversations WHERE id = ?`, usage.ConversationID).Scan(&projectID); err != nil && err != sql.ErrNoRows { + return fmt.Errorf("查询对话项目失败: %w", err) + } + projectValue := interface{}(nil) + if projectID.Valid && strings.TrimSpace(projectID.String) != "" { + projectValue = strings.TrimSpace(projectID.String) + } + _, err := db.Exec(` +INSERT INTO model_token_usage ( + id, process_detail_id, message_id, conversation_id, project_id, + source, orchestration, reason, model, model_calls, + prompt_tokens, completion_tokens, total_tokens, cached_tokens, reasoning_tokens, + created_at, updated_at +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +ON CONFLICT(process_detail_id) DO UPDATE SET + message_id = excluded.message_id, + conversation_id = excluded.conversation_id, + project_id = excluded.project_id, + source = excluded.source, + orchestration = excluded.orchestration, + reason = excluded.reason, + model = excluded.model, + model_calls = excluded.model_calls, + prompt_tokens = excluded.prompt_tokens, + completion_tokens = excluded.completion_tokens, + total_tokens = excluded.total_tokens, + cached_tokens = excluded.cached_tokens, + reasoning_tokens = excluded.reasoning_tokens, + created_at = excluded.created_at, + updated_at = excluded.updated_at`, + usage.ID, usage.ProcessDetailID, usage.MessageID, usage.ConversationID, projectValue, + usage.Source, usage.Orchestration, usage.Reason, usage.Model, usage.ModelCalls, + usage.PromptTokens, usage.CompletionTokens, usage.TotalTokens, usage.CachedTokens, usage.ReasoningTokens, + createdAt, now, + ) + if err != nil { + return fmt.Errorf("写入模型Token用量失败: %w", err) + } + return nil +} + +// BackfillModelTokenUsageFromProcessDetails makes existing timeline usage events queryable. +func (db *DB) BackfillModelTokenUsageFromProcessDetails() error { + if db == nil { + return nil + } + rows, err := db.Query(` +SELECT pd.id, pd.message_id, pd.conversation_id, pd.data, pd.created_at +FROM process_details pd +LEFT JOIN model_token_usage mtu ON mtu.process_detail_id = pd.id +WHERE pd.event_type = ? + AND (mtu.id IS NULL OR mtu.created_at != pd.created_at)`, modelTokenUsageEventType) + if err != nil { + return fmt.Errorf("查询历史模型Token用量失败: %w", err) + } + defer rows.Close() + for rows.Next() { + var processDetailID, messageID, conversationID string + var data sql.NullString + var createdAt string + if err := rows.Scan(&processDetailID, &messageID, &conversationID, &data, &createdAt); err != nil { + return fmt.Errorf("扫描历史模型Token用量失败: %w", err) + } + if !data.Valid { + continue + } + usage, ok := modelTokenUsageFromProcessDetail(messageID, conversationID, processDetailID, data.String) + if !ok { + continue + } + usage.CreatedAt = parseModelTokenUsageTime(createdAt) + if err := db.UpsertModelTokenUsage(usage); err != nil { + return err + } + } + if err := rows.Err(); err != nil { + return fmt.Errorf("遍历历史模型Token用量失败: %w", err) + } + return nil +} + +func (db *DB) GetModelTokenUsageStats(filter ModelTokenUsageFilter) (*ModelTokenUsageStats, error) { + if db == nil { + return nil, fmt.Errorf("database is nil") + } + if filter.Days <= 0 { + filter.Days = 7 + } + if filter.Limit <= 0 { + filter.Limit = 10 + } + where, args := buildModelTokenUsageWhere(filter, "mtu", "c") + summary, err := db.queryModelTokenUsageSummary("SELECT "+modelTokenUsageSummarySelect("mtu")+" FROM model_token_usage mtu JOIN conversations c ON c.id = mtu.conversation_id"+where, args...) + if err != nil { + return nil, err + } + todayFilter := filter + now := time.Now() + todayFilter.Since = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + todayWhere, todayArgs := buildModelTokenUsageWhere(todayFilter, "mtu", "c") + today, err := db.queryModelTokenUsageSummary("SELECT "+modelTokenUsageSummarySelect("mtu")+" FROM model_token_usage mtu JOIN conversations c ON c.id = mtu.conversation_id"+todayWhere, todayArgs...) + if err != nil { + return nil, err + } + byDay, err := db.queryModelTokenUsageBreakdown( + "SELECT date(mtu.created_at) AS k, date(mtu.created_at) AS label, "+modelTokenUsageSummarySelect("mtu")+" FROM model_token_usage mtu JOIN conversations c ON c.id = mtu.conversation_id"+where+" GROUP BY date(mtu.created_at) ORDER BY k DESC LIMIT ?", + append(args, filter.Days)..., + ) + if err != nil { + return nil, err + } + byModel, err := db.queryModelTokenUsageBreakdown( + "SELECT COALESCE(NULLIF(TRIM(mtu.model), ''), 'unknown') AS k, COALESCE(NULLIF(TRIM(mtu.model), ''), 'Unknown') AS label, "+modelTokenUsageSummarySelect("mtu")+" FROM model_token_usage mtu JOIN conversations c ON c.id = mtu.conversation_id"+where+" GROUP BY k ORDER BY SUM(mtu.total_tokens) DESC LIMIT ?", + append(args, filter.Limit)..., + ) + if err != nil { + return nil, err + } + byOrch, err := db.queryModelTokenUsageBreakdown( + "SELECT COALESCE(NULLIF(TRIM(mtu.orchestration), ''), 'unknown') AS k, COALESCE(NULLIF(TRIM(mtu.orchestration), ''), 'Unknown') AS label, "+modelTokenUsageSummarySelect("mtu")+" FROM model_token_usage mtu JOIN conversations c ON c.id = mtu.conversation_id"+where+" GROUP BY k ORDER BY SUM(mtu.total_tokens) DESC LIMIT ?", + append(args, filter.Limit)..., + ) + if err != nil { + return nil, err + } + recent, err := db.ListModelTokenUsage(filter) + if err != nil { + return nil, err + } + return &ModelTokenUsageStats{ + Summary: summary, + Today: today, + ByDay: byDay, + ByModel: byModel, + ByOrchestration: byOrch, + Recent: recent, + }, nil +} + +func modelTokenUsageSummarySelect(alias string) string { + p := "" + if alias != "" { + p = alias + "." + } + return fmt.Sprintf(`COUNT(%sid), +COALESCE(SUM(%smodel_calls), 0), +COALESCE(SUM(%sprompt_tokens), 0), +COALESCE(SUM(%scompletion_tokens), 0), +COALESCE(SUM(%stotal_tokens), 0), +COALESCE(SUM(%scached_tokens), 0), +COALESCE(SUM(%sreasoning_tokens), 0)`, p, p, p, p, p, p, p) +} + +func buildModelTokenUsageWhere(filter ModelTokenUsageFilter, usageAlias, convAlias string) (string, []interface{}) { + where := " WHERE 1=1" + args := []interface{}{} + uPrefix := "" + if usageAlias != "" { + uPrefix = usageAlias + "." + } + if cid := strings.TrimSpace(filter.ConversationID); cid != "" { + where += " AND " + uPrefix + "conversation_id = ?" + args = append(args, cid) + } + where, args = appendConversationProjectFilter(where, args, filter.ProjectID, usageAlias) + if !filter.Since.IsZero() { + where += " AND " + uPrefix + "created_at >= ?" + args = append(args, filter.Since) + } + if !filter.Until.IsZero() { + where += " AND " + uPrefix + "created_at <= ?" + args = append(args, filter.Until) + } + where, args = appendConversationAccessFilter(where, args, filter.Access.UserID, filter.Access.Scope, convAlias) + return where, args +} + +func (db *DB) queryModelTokenUsageSummary(query string, args ...interface{}) (ModelTokenUsageSummary, error) { + var s ModelTokenUsageSummary + err := db.QueryRow(query, args...).Scan( + &s.Events, &s.ModelCalls, &s.PromptTokens, &s.CompletionTokens, + &s.TotalTokens, &s.CachedTokens, &s.ReasoningTokens, + ) + if err != nil { + return s, fmt.Errorf("查询模型Token用量汇总失败: %w", err) + } + return s, nil +} + +func (db *DB) queryModelTokenUsageBreakdown(query string, args ...interface{}) ([]ModelTokenUsageBreakdown, error) { + rows, err := db.Query(query, args...) + if err != nil { + return nil, fmt.Errorf("查询模型Token用量分组失败: %w", err) + } + defer rows.Close() + out := []ModelTokenUsageBreakdown{} + for rows.Next() { + var row ModelTokenUsageBreakdown + if err := rows.Scan( + &row.Key, &row.Label, &row.Events, &row.ModelCalls, &row.PromptTokens, + &row.CompletionTokens, &row.TotalTokens, &row.CachedTokens, &row.ReasoningTokens, + ); err != nil { + return nil, fmt.Errorf("扫描模型Token用量分组失败: %w", err) + } + out = append(out, row) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("遍历模型Token用量分组失败: %w", err) + } + return out, nil +} + +func (db *DB) ListModelTokenUsage(filter ModelTokenUsageFilter) ([]ModelTokenUsage, error) { + if filter.Limit <= 0 { + filter.Limit = 20 + } + if filter.Limit > 500 { + filter.Limit = 500 + } + where, args := buildModelTokenUsageWhere(filter, "mtu", "c") + args = append(args, filter.Limit) + rows, err := db.Query(` +SELECT mtu.id, mtu.process_detail_id, mtu.message_id, mtu.conversation_id, + COALESCE(mtu.project_id, ''), mtu.source, mtu.orchestration, mtu.reason, mtu.model, + mtu.model_calls, mtu.prompt_tokens, mtu.completion_tokens, mtu.total_tokens, + mtu.cached_tokens, mtu.reasoning_tokens, mtu.created_at, mtu.updated_at +FROM model_token_usage mtu +JOIN conversations c ON c.id = mtu.conversation_id`+where+` +ORDER BY mtu.created_at DESC, mtu.rowid DESC +LIMIT ?`, args...) + if err != nil { + return nil, fmt.Errorf("查询模型Token用量明细失败: %w", err) + } + defer rows.Close() + out := []ModelTokenUsage{} + for rows.Next() { + var u ModelTokenUsage + var createdAt, updatedAt string + if err := rows.Scan( + &u.ID, &u.ProcessDetailID, &u.MessageID, &u.ConversationID, &u.ProjectID, + &u.Source, &u.Orchestration, &u.Reason, &u.Model, &u.ModelCalls, + &u.PromptTokens, &u.CompletionTokens, &u.TotalTokens, &u.CachedTokens, + &u.ReasoningTokens, &createdAt, &updatedAt, + ); err != nil { + return nil, fmt.Errorf("扫描模型Token用量明细失败: %w", err) + } + u.CreatedAt = parseModelTokenUsageTime(createdAt) + u.UpdatedAt = parseModelTokenUsageTime(updatedAt) + out = append(out, u) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("遍历模型Token用量明细失败: %w", err) + } + return out, nil +} + +func parseModelTokenUsageTime(s string) time.Time { + for _, layout := range []string{ + "2006-01-02 15:04:05.999999999-07:00", + "2006-01-02 15:04:05.999999-07:00", + "2006-01-02 15:04:05", + time.RFC3339Nano, + time.RFC3339, + } { + if t, err := time.Parse(layout, strings.TrimSpace(s)); err == nil { + return t + } + } + return time.Time{} +} diff --git a/internal/database/model_token_usage_test.go b/internal/database/model_token_usage_test.go new file mode 100644 index 00000000..5fa1a09f --- /dev/null +++ b/internal/database/model_token_usage_test.go @@ -0,0 +1,85 @@ +package database + +import ( + "path/filepath" + "testing" + + "go.uber.org/zap" +) + +func TestModelTokenUsagePersistsFromUsageProcessDetail(t *testing.T) { + db := newModelTokenUsageTestDB(t) + conv, err := db.CreateConversation("usage", ConversationCreateMeta{}) + if err != nil { + t.Fatalf("CreateConversation: %v", err) + } + msg, err := db.AddMessage(conv.ID, "assistant", "done", nil) + if err != nil { + t.Fatalf("AddMessage: %v", err) + } + if err := db.AddProcessDetail(msg.ID, conv.ID, modelTokenUsageEventType, "usage", map[string]interface{}{ + "source": "eino", + "orchestration": "deep", + "reason": "final", + "model": "gpt-test", + "modelCalls": 2, + "promptTokens": 10, + "completionTokens": 3, + "totalTokens": 13, + "cachedTokens": 4, + "reasoningTokens": 1, + }); err != nil { + t.Fatalf("AddProcessDetail: %v", err) + } + + stats, err := db.GetModelTokenUsageStats(ModelTokenUsageFilter{}) + if err != nil { + t.Fatalf("GetModelTokenUsageStats: %v", err) + } + if stats.Summary.Events != 1 || stats.Summary.ModelCalls != 2 || stats.Summary.TotalTokens != 13 || stats.Summary.CachedTokens != 4 || stats.Summary.ReasoningTokens != 1 { + t.Fatalf("summary = %#v", stats.Summary) + } + if len(stats.ByModel) != 1 || stats.ByModel[0].Key != "gpt-test" || stats.ByModel[0].TotalTokens != 13 { + t.Fatalf("by model = %#v", stats.ByModel) + } +} + +func TestModelTokenUsageBackfillIsIdempotent(t *testing.T) { + db := newModelTokenUsageTestDB(t) + conv, err := db.CreateConversation("usage", ConversationCreateMeta{}) + if err != nil { + t.Fatalf("CreateConversation: %v", err) + } + msg, err := db.AddMessage(conv.ID, "assistant", "done", nil) + if err != nil { + t.Fatalf("AddMessage: %v", err) + } + if err := db.AddProcessDetail(msg.ID, conv.ID, modelTokenUsageEventType, "usage", map[string]interface{}{ + "source": "eino", "modelCalls": 1, "promptTokens": 7, "completionTokens": 5, "totalTokens": 12, + }); err != nil { + t.Fatalf("AddProcessDetail: %v", err) + } + if err := db.BackfillModelTokenUsageFromProcessDetails(); err != nil { + t.Fatalf("Backfill 1: %v", err) + } + if err := db.BackfillModelTokenUsageFromProcessDetails(); err != nil { + t.Fatalf("Backfill 2: %v", err) + } + stats, err := db.GetModelTokenUsageStats(ModelTokenUsageFilter{}) + if err != nil { + t.Fatalf("GetModelTokenUsageStats: %v", err) + } + if stats.Summary.Events != 1 || stats.Summary.TotalTokens != 12 { + t.Fatalf("summary after backfill = %#v", stats.Summary) + } +} + +func newModelTokenUsageTestDB(t *testing.T) *DB { + t.Helper() + db, err := NewDB(filepath.Join(t.TempDir(), "usage.db"), zap.NewNop()) + if err != nil { + t.Fatalf("NewDB: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + return db +} diff --git a/internal/handler/token_usage.go b/internal/handler/token_usage.go new file mode 100644 index 00000000..a8d9b0a5 --- /dev/null +++ b/internal/handler/token_usage.go @@ -0,0 +1,92 @@ +package handler + +import ( + "net/http" + "strconv" + "strings" + "time" + + "cyberstrike-ai/internal/database" + "cyberstrike-ai/internal/security" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +// GetTokenUsageStats returns model token usage aggregates for dashboard views. +func (h *ConversationHandler) GetTokenUsageStats(c *gin.Context) { + filter := tokenUsageFilterFromQuery(c) + if session, ok := security.CurrentSession(c); ok { + filter.Access = database.RBACListAccess{UserID: session.UserID, Scope: session.Scope} + } + stats, err := h.db.GetModelTokenUsageStats(filter) + if err != nil { + h.logger.Error("获取Token用量统计失败", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, stats) +} + +// GetConversationTokenUsageStats returns token usage scoped to one conversation. +func (h *ConversationHandler) GetConversationTokenUsageStats(c *gin.Context) { + filter := tokenUsageFilterFromQuery(c) + filter.ConversationID = strings.TrimSpace(c.Param("id")) + if session, ok := security.CurrentSession(c); ok { + filter.Access = database.RBACListAccess{UserID: session.UserID, Scope: session.Scope} + } + stats, err := h.db.GetModelTokenUsageStats(filter) + if err != nil { + h.logger.Error("获取对话Token用量统计失败", zap.Error(err), zap.String("conversationId", filter.ConversationID)) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, stats) +} + +func tokenUsageFilterFromQuery(c *gin.Context) database.ModelTokenUsageFilter { + days, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("days", "7"))) + if days <= 0 { + days = 7 + } + if days > 365 { + days = 365 + } + limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "10"))) + if limit <= 0 { + limit = 10 + } + if limit > 500 { + limit = 500 + } + filter := database.ModelTokenUsageFilter{ + ConversationID: strings.TrimSpace(c.Query("conversation_id")), + ProjectID: strings.TrimSpace(c.Query("project_id")), + Days: days, + Limit: limit, + } + if since := parseTokenUsageQueryTime(c.Query("since")); !since.IsZero() { + filter.Since = since + } else if days > 0 { + now := time.Now() + start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).AddDate(0, 0, -(days - 1)) + filter.Since = start + } + if until := parseTokenUsageQueryTime(c.Query("until")); !until.IsZero() { + filter.Until = until + } + return filter +} + +func parseTokenUsageQueryTime(raw string) time.Time { + raw = strings.TrimSpace(raw) + if raw == "" { + return time.Time{} + } + for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02"} { + if t, err := time.Parse(layout, raw); err == nil { + return t + } + } + return time.Time{} +} diff --git a/internal/multiagent/eino_run_runtime_session.go b/internal/multiagent/eino_run_runtime_session.go index c8b5765e..6297a6bb 100644 --- a/internal/multiagent/eino_run_runtime_session.go +++ b/internal/multiagent/eino_run_runtime_session.go @@ -371,5 +371,9 @@ func (s *einoRunRuntimeSession) emitUsageSummary(reason string) bool { if s == nil || s.usage == nil { return false } - return s.usage.EmitOnce(s.conversationID, s.orchMode, reason, s.progress, s.logger) + modelName := "" + if s.args != nil { + modelName = s.args.ModelName + } + return s.usage.EmitOnce(s.conversationID, s.orchMode, reason, modelName, s.progress, s.logger) } diff --git a/internal/multiagent/eino_run_usage_accumulator.go b/internal/multiagent/eino_run_usage_accumulator.go index 8daa5b53..1cc06cc7 100644 --- a/internal/multiagent/eino_run_usage_accumulator.go +++ b/internal/multiagent/eino_run_usage_accumulator.go @@ -61,6 +61,7 @@ func (a *einoRunUsageAccumulator) EmitOnce( conversationID string, orchestration string, reason string, + modelName string, progress func(eventType, message string, data interface{}), logger *zap.Logger, ) bool { @@ -81,6 +82,7 @@ func (a *einoRunUsageAccumulator) EmitOnce( "source": "eino", "orchestration": orchestration, "reason": reason, + "model": modelName, "modelCalls": s.ModelCalls, "promptTokens": s.PromptTokens, "completionTokens": s.CompletionTokens, @@ -96,6 +98,7 @@ func (a *einoRunUsageAccumulator) EmitOnce( zap.String("conversationId", conversationID), zap.String("orchestration", orchestration), zap.String("reason", reason), + zap.String("model", modelName), zap.Int("modelCalls", s.ModelCalls), zap.Int("promptTokens", s.PromptTokens), zap.Int("completionTokens", s.CompletionTokens), diff --git a/internal/multiagent/eino_run_usage_accumulator_test.go b/internal/multiagent/eino_run_usage_accumulator_test.go index 57c23779..a26e3c3b 100644 --- a/internal/multiagent/eino_run_usage_accumulator_test.go +++ b/internal/multiagent/eino_run_usage_accumulator_test.go @@ -49,16 +49,16 @@ func TestEinoRunUsageAccumulatorEmitOnce(t *testing.T) { } } - if !acc.EmitOnce("conv-1", "deep", "final", progress, nil) { + if !acc.EmitOnce("conv-1", "deep", "final", "gpt-test", progress, nil) { t.Fatal("first emit should return true") } - if acc.EmitOnce("conv-1", "deep", "partial", progress, nil) { + if acc.EmitOnce("conv-1", "deep", "partial", "gpt-test", progress, nil) { t.Fatal("second emit should return false") } if len(events) != 1 { t.Fatalf("events = %#v, want one usage summary", events) } - if events[0]["conversationId"] != "conv-1" || events[0]["orchestration"] != "deep" || events[0]["reason"] != "final" || events[0]["totalTokens"] != 3 { + if events[0]["conversationId"] != "conv-1" || events[0]["orchestration"] != "deep" || events[0]["reason"] != "final" || events[0]["model"] != "gpt-test" || events[0]["totalTokens"] != 3 { t.Fatalf("event = %#v", events[0]) } } diff --git a/internal/security/rbac_middleware.go b/internal/security/rbac_middleware.go index 6a719612..77e7c055 100644 --- a/internal/security/rbac_middleware.go +++ b/internal/security/rbac_middleware.go @@ -118,6 +118,8 @@ func permissionForRequest(method, fullPath string) string { return "hitl:write" case strings.HasPrefix(path, "/agent-loop"), strings.HasPrefix(path, "/batch-tasks"): return crudPermission(method, "tasks") + case path == "/usage/tokens": + return "dashboard:read" case strings.HasPrefix(path, "/conversations"), strings.HasPrefix(path, "/messages"), strings.HasPrefix(path, "/process-details"): return crudPermission(method, "chat") case strings.HasPrefix(path, "/groups"): diff --git a/internal/security/rbac_middleware_test.go b/internal/security/rbac_middleware_test.go index 6d1c54a5..ab478400 100644 --- a/internal/security/rbac_middleware_test.go +++ b/internal/security/rbac_middleware_test.go @@ -119,6 +119,12 @@ func TestRBACResourcePickerRequiresWritePermission(t *testing.T) { } } +func TestRBACMiddlewareMapsTokenUsageStatsToDashboardRead(t *testing.T) { + if got := permissionForRequest(http.MethodGet, "/api/usage/tokens"); got != "dashboard:read" { + t.Fatalf("token usage permission = %q, want dashboard:read", got) + } +} + func TestMCPInvocationPermissionIsSeparateFromMCPAdministration(t *testing.T) { if got := permissionForRequest(http.MethodPost, "/api/mcp"); got != "mcp:execute" { t.Fatalf("MCP invocation permission = %q, want mcp:execute", got) diff --git a/run.sh b/run.sh old mode 100644 new mode 100755 diff --git a/web/static/css/style.css b/web/static/css/style.css index e68a8c3a..1cfc7bb6 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -4107,62 +4107,50 @@ html[data-theme="dark"] .sidebar-content:hover::-webkit-scrollbar-thumb:hover { position: relative; } -/* 消息复制按钮 - 位于消息气泡右下角 */ +/* 消息复制按钮 - 与时间戳同一行 */ .message-copy-btn { - position: absolute; - bottom: 12px; - right: 12px; - display: flex; + position: static; + display: inline-flex; align-items: center; justify-content: center; - gap: 6px; - padding: 8px 14px; - background: #ffffff; - border: 1px solid rgba(0, 0, 0, 0.08); - border-radius: 20px; - color: #666; - font-size: 0.8125rem; + width: 28px; + height: 28px; + padding: 0; + background: transparent; + border: 1px solid transparent; + border-radius: 6px; + color: var(--text-secondary, #888); cursor: pointer; - transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.04); - z-index: 10; - opacity: 0; - transform: translateY(4px); - font-weight: 500; -} - -.message-bubble:hover .message-copy-btn { - opacity: 1; - transform: translateY(0); + opacity: 0.72; + flex-shrink: 0; + transition: opacity 0.2s ease, color 0.2s ease, background 0.2s ease, border-color 0.2s ease; } .message-copy-btn:hover { - background: rgba(255, 255, 255, 1); - border-color: rgba(0, 102, 255, 0.2); - color: #0066ff; - box-shadow: 0 4px 12px rgba(0, 102, 255, 0.15), 0 2px 4px rgba(0, 0, 0, 0.08); - transform: translateY(-1px); + color: var(--accent-color, #0066ff); + background: rgba(0, 102, 255, 0.07); + border-color: rgba(0, 102, 255, 0.14); + opacity: 1; } .message-copy-btn:active { - transform: translateY(0) scale(0.98); - box-shadow: 0 2px 6px rgba(0, 102, 255, 0.12), 0 1px 2px rgba(0, 0, 0, 0.06); + background: rgba(0, 102, 255, 0.11); } .message-copy-btn svg { width: 16px; height: 16px; flex-shrink: 0; - transition: transform 0.2s ease; } -.message-copy-btn:hover svg { - transform: scale(1.1); +.message-copy-btn:focus-visible { + opacity: 1; + outline: 2px solid var(--accent-color, #0066ff); + outline-offset: 1px; } .message-copy-btn span { - font-weight: 500; - letter-spacing: 0.01em; + display: none; } /* 时间戳 + 删除本轮(与气泡分离,和「展开详情」同一视觉层级) */ @@ -24302,11 +24290,15 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible { /* 第一行:核心 KPI(视觉层次 + 色块强调) */ .dashboard-kpi-row { display: grid; - grid-template-columns: repeat(4, 1fr); + grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 16px; margin-bottom: 24px; } +@media (max-width: 1200px) { + .dashboard-kpi-row { grid-template-columns: repeat(3, 1fr); } +} + @media (max-width: 900px) { .dashboard-kpi-row { grid-template-columns: repeat(2, 1fr); } } @@ -24466,6 +24458,7 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible { .dashboard-kpi-card:nth-child(2) { background: linear-gradient(145deg, #fff 0%, #fef2f2 100%); } .dashboard-kpi-card:nth-child(3) { background: linear-gradient(145deg, #fff 0%, #f0fdf4 100%); } .dashboard-kpi-card:nth-child(4) { background: linear-gradient(145deg, #fff 0%, #f0fdfa 100%); } +.dashboard-kpi-card:nth-child(5) { background: linear-gradient(145deg, #fff 0%, #f8fafc 100%); } .dashboard-kpi-card:hover { transform: translateY(-3px); @@ -24494,6 +24487,7 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible { .dashboard-kpi-icon-vuln { background: rgba(239, 68, 68, 0.1); color: #ef4444; } .dashboard-kpi-icon-calls { background: rgba(34, 197, 94, 0.1); color: #22c55e; } .dashboard-kpi-icon-rate { background: rgba(20, 184, 166, 0.1); color: #14b8a6; } +.dashboard-kpi-icon-tokens { background: rgba(99, 102, 241, 0.1); color: #6366f1; } .dashboard-kpi-value { font-size: 1.875rem; @@ -28734,12 +28728,54 @@ html[data-theme="dark"] .vulnerability-details code.vuln-detail-field-value { } .role-selector-icon { - font-size: 1rem; - line-height: 1; flex-shrink: 0; - display: flex; +} + +.agent-mode-logo { + --agent-logo-a: #858d98; + --agent-logo-b: #858d98; + display: inline-flex; align-items: center; justify-content: center; + width: 18px; + height: 18px; + border: 0; + border-radius: 0; + background: transparent; + color: var(--agent-logo-a); + box-shadow: none; + overflow: visible; + flex: 0 0 auto; +} + +.agent-mode-logo--deep { + --agent-logo-a: #858d98; + --agent-logo-b: #858d98; +} + +.agent-mode-logo--plan { + --agent-logo-a: #858d98; + --agent-logo-b: #858d98; +} + +.agent-mode-logo--supervisor { + --agent-logo-a: #858d98; + --agent-logo-b: #858d98; +} + +.agent-mode-logo--default { + --agent-logo-a: #858d98; + --agent-logo-b: #858d98; +} + +.agent-mode-logo__svg { + width: 100%; + height: 100%; + display: block; + stroke: currentColor; + stroke-width: 1.9; + stroke-linecap: round; + stroke-linejoin: round; } .role-selector-text { @@ -29049,6 +29085,16 @@ html[data-theme="dark"] .vulnerability-details code.vuln-detail-field-value { border-color: rgba(138, 43, 226, 0.3); box-shadow: 0 2px 6px rgba(138, 43, 226, 0.2); } + +.role-selection-item-main .role-selection-item-icon-main.agent-mode-logo, +.role-selection-item-main:hover .role-selection-item-icon-main.agent-mode-logo, +.role-selection-item-main.selected .role-selection-item-icon-main.agent-mode-logo { + width: 28px; + height: 28px; + border-color: transparent; + background: transparent; + box-shadow: none; +} .role-selection-item-content-main { display: flex; flex-direction: column; @@ -36309,7 +36355,8 @@ html[data-theme="dark"] .conversation-reasoning-card .hitl-reviewer-toggle-btn.i html[data-theme="dark"] .dashboard-kpi-card:nth-child(1), html[data-theme="dark"] .dashboard-kpi-card:nth-child(2), html[data-theme="dark"] .dashboard-kpi-card:nth-child(3), -html[data-theme="dark"] .dashboard-kpi-card:nth-child(4) { +html[data-theme="dark"] .dashboard-kpi-card:nth-child(4), +html[data-theme="dark"] .dashboard-kpi-card:nth-child(5) { background: linear-gradient(145deg, #111827 0%, #172033 100%); } @@ -36797,21 +36844,17 @@ html[data-theme="dark"] .webshell-ai-msg.assistant.webshell-ai-candidate-output } html[data-theme="dark"] .message-copy-btn { - background: #1f2937; - border-color: #334155; color: var(--text-secondary); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.28); } html[data-theme="dark"] .message-copy-btn:hover { - background: #263244; + background: rgba(96, 165, 250, 0.12); border-color: rgba(96, 165, 250, 0.45); color: var(--accent-hover); - box-shadow: 0 4px 12px rgba(96, 165, 250, 0.18); } html[data-theme="dark"] .message-copy-btn:active { - box-shadow: 0 2px 6px rgba(96, 165, 250, 0.14); + background: rgba(96, 165, 250, 0.18); } html[data-theme="dark"] .message.user .message-bubble { @@ -37364,6 +37407,16 @@ html[data-theme="dark"] .role-selection-item-icon-main { border-color: rgba(148, 163, 184, 0.12) !important; } +html[data-theme="dark"] .role-selection-item-icon-main.agent-mode-logo, +html[data-theme="dark"] .project-conversation-preview-mode-icon.agent-mode-logo, +html[data-theme="dark"] .role-selector-icon.agent-mode-logo { + --agent-logo-a: #94a3b8; + --agent-logo-b: #94a3b8; + border-color: transparent !important; + background: transparent !important; + box-shadow: none !important; +} + html[data-theme="dark"] .sidebar-list-pagination, html[data-theme="dark"] .sidebar-list-pagination-inner, html[data-theme="dark"] .conversation-sidebar-pagination, @@ -45062,6 +45115,13 @@ html[data-theme="dark"] .project-folder-preview-edit:focus-visible { color: #858d98; } +.project-conversation-preview-mode-icon.agent-mode-logo { + width: 16px; + height: 16px; + margin-right: 0; + color: #858d98; +} + .project-conversation-preview-project, .project-conversation-preview-mode { min-width: 0; @@ -46350,15 +46410,6 @@ html[data-theme="dark"] .chat-composer-surface > .chat-session-settings-popover margin-bottom: 0; } -.message.assistant-turn-with-process .message-copy-btn { - right: 0; - bottom: -34px; - padding: 5px 9px; - border: 0; - background: transparent; - box-shadow: none; -} - .message.assistant-turn-with-process .mcp-call-section { width: 100%; margin: 0; @@ -46408,8 +46459,9 @@ html[data-theme="dark"] .chat-composer-surface > .chat-session-settings-popover .turn-process-leading { display: inline-flex; align-items: center; - gap: 9px; + gap: 8px; min-width: 0; + flex-wrap: wrap; } .turn-process-status-dot { @@ -46426,6 +46478,26 @@ html[data-theme="dark"] .chat-composer-surface > .chat-session-settings-popover animation: codex-turn-pulse 1.55s ease-in-out infinite; } +.turn-process-token-chip { + display: inline; + padding: 0; + border: 0; + border-radius: 0; + background: transparent; + color: inherit; + font-size: inherit; + font-weight: inherit; + line-height: inherit; + opacity: 0.72; + white-space: nowrap; +} + +.turn-process-token-chip::before { + content: "· "; + color: inherit; + font-weight: inherit; +} + @keyframes codex-turn-pulse { 0%, 100% { opacity: 0.55; transform: scale(0.88); } 50% { opacity: 1; transform: scale(1); } diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index 41fbf538..56508646 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -127,6 +127,9 @@ "vulnTotal": "Total vulnerabilities", "toolCalls": "Tool invocations", "successRate": "Tool success rate", + "tokenUsage": "Token usage", + "tokenUsageSub": "Last 7 days {{calls}} calls · Today {{today}}", + "noTokenUsageYet": "No usage yet", "clickToViewTasks": "Click to view tasks", "clickToViewChat": "Click to view conversations", "clickToViewVuln": "Click to view vulnerabilities", @@ -633,6 +636,8 @@ "turnDurationMinutes": "{{minutes}} min {{seconds}} sec", "turnDurationHours": "{{hours}} hr {{minutes}} min", "turnProcessAria": "{{state}}; expand or collapse execution details", + "turnTokenUsageLabel": "{{tokens}} tokens", + "turnTokenUsageTitle": "Token usage: {{total}} (input {{prompt}}, output {{completion}}, cached {{cached}}, reasoning {{reasoning}}, {{calls}} calls)", "turnNumber": "Turn {{number}}", "turnPending": "Processing…", "expandDetailLazyHint": "Expand details (loads iteration details on click)", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index 42d99610..1a1fff99 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -127,6 +127,9 @@ "vulnTotal": "漏洞总数", "toolCalls": "工具调用次数", "successRate": "工具执行成功率", + "tokenUsage": "Token 用量", + "tokenUsageSub": "近 7 天 {{calls}} 次调用 · 今日 {{today}}", + "noTokenUsageYet": "暂无用量", "clickToViewTasks": "点击查看任务管理", "clickToViewChat": "点击查看对话", "clickToViewVuln": "点击查看漏洞管理", @@ -621,6 +624,8 @@ "turnDurationMinutes": "{{minutes}} 分钟 {{seconds}} 秒", "turnDurationHours": "{{hours}} 小时 {{minutes}} 分钟", "turnProcessAria": "{{state}},展开或收起执行过程", + "turnTokenUsageLabel": "{{tokens}} tokens", + "turnTokenUsageTitle": "Token 用量:{{total}}(输入 {{prompt}},输出 {{completion}},缓存 {{cached}},推理 {{reasoning}},调用 {{calls}} 次)", "turnNumber": "第 {{number}} 轮", "turnPending": "正在处理…", "expandDetailLazyHint": "展开详情(点击后加载迭代详情)", diff --git a/web/static/js/chat-message-copy.test.cjs b/web/static/js/chat-message-copy.test.cjs new file mode 100644 index 00000000..208d57d6 --- /dev/null +++ b/web/static/js/chat-message-copy.test.cjs @@ -0,0 +1,35 @@ +const fs = require('node:fs'); +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const chat = fs.readFileSync('web/static/js/chat.js', 'utf8'); +const monitor = fs.readFileSync('web/static/js/monitor.js', 'utf8'); + +function functionSource(source, name, nextName) { + const start = source.indexOf(`function ${name}(`); + const end = source.indexOf(`function ${nextName}(`, start); + assert.notEqual(start, -1, `${name} should exist`); + assert.notEqual(end, -1, `${nextName} should follow ${name}`); + return source.slice(start, end); +} + +test('用户和助手消息使用同一复制按钮入口', () => { + const helperSource = functionSource(chat, 'appendMessageCopyButton', 'addMessage'); + const addMessageSource = functionSource(chat, 'addMessage', 'copyMessageToClipboard'); + + assert.match(helperSource, /classList\.contains\('assistant'\)[\s\S]*classList\.contains\('user'\)/); + assert.match(helperSource, /const footer = ensureMessageMetaFooter\(content\)/); + assert.match(helperSource, /message-bubble \.message-copy-btn/); + assert.match(helperSource, /copyMessageToClipboard\(messageDiv, this\)/); + assert.match(addMessageSource, /role === 'assistant' \|\| role === 'user'[\s\S]*messageDiv\.dataset\.originalContent = content/); + assert.match(addMessageSource, /metaFooter\.appendChild\(timeDiv\)/); + assert.match(addMessageSource, /role === 'assistant' \|\| role === 'user'[\s\S]*appendMessageCopyButton\(messageDiv\)/); +}); + +test('刷新消息内容时会保留或补回复制按钮', () => { + const refreshSource = functionSource(chat, 'refreshSystemReadyMessageBubbles', 'appendMessageCopyButton'); + const updateSource = functionSource(monitor, 'updateAssistantBubbleContent', 'isConversationTaskRunning'); + + assert.match(refreshSource, /appendMessageCopyButton\(messageDiv\)/); + assert.match(updateSource, /window\.appendMessageCopyButton\(assistantElement\)/); +}); diff --git a/web/static/js/chat.js b/web/static/js/chat.js index 0735c74c..53c97835 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -1001,23 +1001,30 @@ function getAgentModeLabelForValue(mode) { } } -function getAgentModeIconForValue(mode) { +function getAgentModeIconClassForValue(mode) { switch (mode) { - case CHAT_AGENT_MODE_EINO_SINGLE: return '⚡'; - case 'deep': return '🧩'; - case 'plan_execute': return '📋'; - case 'supervisor': return '🎯'; - default: return '🤖'; + case CHAT_AGENT_MODE_EINO_SINGLE: return 'eino'; + case 'deep': return 'deep'; + case 'plan_execute': return 'plan'; + case 'supervisor': return 'supervisor'; + default: return 'default'; } } +function renderAgentModeLogoMarkup() { + return ''; +} + function syncAgentModeFromValue(value) { const hid = document.getElementById('agent-mode-select'); const label = document.getElementById('agent-mode-text'); const icon = document.getElementById('agent-mode-icon'); if (hid) hid.value = value; if (label) label.textContent = getAgentModeLabelForValue(value); - if (icon) icon.textContent = getAgentModeIconForValue(value); + if (icon) { + icon.className = 'role-selector-icon agent-mode-logo agent-mode-logo--' + getAgentModeIconClassForValue(value); + icon.innerHTML = renderAgentModeLogoMarkup(); + } document.querySelectorAll('.agent-mode-option').forEach(function (el) { const v = el.getAttribute('data-value'); el.classList.toggle('selected', v === value); @@ -3507,9 +3514,60 @@ function refreshSystemReadyMessageBubbles() { bubble.innerHTML = formattedContent; if (typeof wrapTablesInBubble === 'function') wrapTablesInBubble(bubble); messageDiv.dataset.originalContent = text; + appendMessageCopyButton(messageDiv); }); } +function ensureMessageMetaFooter(content) { + if (!content) return null; + let footer = content.querySelector('.message-meta-footer'); + if (footer) return footer; + const timeDiv = content.querySelector('.message-time'); + footer = document.createElement('div'); + footer.className = 'message-meta-footer'; + if (timeDiv && timeDiv.parentNode === content) { + timeDiv.parentNode.insertBefore(footer, timeDiv); + footer.appendChild(timeDiv); + } else { + content.appendChild(footer); + } + return footer; +} + +function appendMessageCopyButton(messageDiv) { + if (!messageDiv) return null; + if (!messageDiv.classList || (!messageDiv.classList.contains('assistant') && !messageDiv.classList.contains('user'))) { + return null; + } + const content = messageDiv.querySelector('.message-content'); + const footer = ensureMessageMetaFooter(content); + if (!footer) return null; + + messageDiv.querySelectorAll('.message-bubble .message-copy-btn').forEach((btn) => btn.remove()); + let copyBtn = footer.querySelector('.message-copy-btn'); + if (copyBtn) return copyBtn; + + copyBtn = document.createElement('button'); + copyBtn.type = 'button'; + copyBtn.className = 'message-copy-btn'; + copyBtn.innerHTML = '' + (typeof window.t === 'function' ? window.t('common.copy') : '复制') + ''; + copyBtn.title = typeof window.t === 'function' ? window.t('chat.copyMessageTitle') : '复制消息内容'; + copyBtn.setAttribute('aria-label', copyBtn.title); + copyBtn.onclick = function(e) { + e.stopPropagation(); + copyMessageToClipboard(messageDiv, this); + }; + const deleteBtn = footer.querySelector('.message-delete-turn-btn'); + if (deleteBtn) { + footer.insertBefore(copyBtn, deleteBtn); + } else { + footer.appendChild(copyBtn); + } + return copyBtn; +} +window.appendMessageCopyButton = appendMessageCopyButton; +window.ensureMessageMetaFooter = ensureMessageMetaFooter; + // 添加消息(options.systemReadyMessage 为 true 时,语言切换会刷新该条文案) function addMessage(role, content, mcpExecutionIds = null, progressId = null, createdAt = null, options = null) { const messagesDiv = document.getElementById('chat-messages'); @@ -3581,23 +3639,10 @@ function addMessage(role, content, mcpExecutionIds = null, progressId = null, cr contentWrapper.appendChild(bubble); // 保存原始内容到消息元素,用于复制功能 - if (role === 'assistant') { + if (role === 'assistant' || role === 'user') { messageDiv.dataset.originalContent = content; } - // 为助手消息添加复制按钮(复制整个回复内容)- 放在消息气泡右下角 - if (role === 'assistant') { - const copyBtn = document.createElement('button'); - copyBtn.className = 'message-copy-btn'; - copyBtn.innerHTML = '' + (typeof window.t === 'function' ? window.t('common.copy') : '复制') + ''; - copyBtn.title = typeof window.t === 'function' ? window.t('chat.copyMessageTitle') : '复制消息内容'; - copyBtn.onclick = function(e) { - e.stopPropagation(); - copyMessageToClipboard(messageDiv, this); - }; - bubble.appendChild(copyBtn); - } - // 添加时间戳 const timeDiv = document.createElement('div'); timeDiv.className = 'message-time'; @@ -3626,8 +3671,16 @@ function addMessage(role, content, mcpExecutionIds = null, progressId = null, cr try { timeDiv.dataset.messageTime = messageTime.toISOString(); } catch (e) { /* ignore */ } - contentWrapper.appendChild(timeDiv); + const metaFooter = document.createElement('div'); + metaFooter.className = 'message-meta-footer'; + metaFooter.appendChild(timeDiv); + contentWrapper.appendChild(metaFooter); messageDiv.appendChild(contentWrapper); + + // 为用户和助手消息添加复制按钮(复制整条消息内容) + if (role === 'assistant' || role === 'user') { + appendMessageCopyButton(messageDiv); + } // 有 MCP 执行记录且非流式占位消息时展示调用按钮;带 progressId 的流式占位不挂此条(与进度卡片一致,结束时 integrate 再创建) if (role === 'assistant' && (mcpExecutionIds && Array.isArray(mcpExecutionIds) && mcpExecutionIds.length > 0) && !progressId) { @@ -4132,6 +4185,10 @@ function renderProcessDetails(messageId, processDetails, options) { detailsContainer.dataset.lazyNotLoaded = '0'; detailsContainer.dataset.loaded = '1'; } + const turnUsageFromDetails = extractAssistantTurnTokenUsage(processDetails); + if (turnUsageFromDetails) { + setAssistantTurnTokenUsage(messageElement, turnUsageFromDetails); + } processDetails = mergeMessageReasoningContentIntoProcessDetails(processDetails, reasoningFromMessage); processDetails = filterNoiseProcessDetails(processDetails); processDetails = dedupeConsecutiveProcessDetailRows(processDetails); @@ -4887,6 +4944,134 @@ function formatAssistantTurnDuration(durationMs) { : seconds + ' 秒'; } +function assistantTurnUsageNumber(value) { + const n = Number(value); + return Number.isFinite(n) && n > 0 ? Math.round(n) : 0; +} + +function normalizeAssistantTurnTokenUsage(data) { + const source = data && typeof data === 'object' ? data : {}; + const usage = { + modelCalls: assistantTurnUsageNumber(source.modelCalls), + promptTokens: assistantTurnUsageNumber(source.promptTokens), + completionTokens: assistantTurnUsageNumber(source.completionTokens), + totalTokens: assistantTurnUsageNumber(source.totalTokens), + cachedTokens: assistantTurnUsageNumber(source.cachedTokens), + reasoningTokens: assistantTurnUsageNumber(source.reasoningTokens), + model: source.model != null ? String(source.model).trim() : '' + }; + if (usage.totalTokens <= 0 && (usage.promptTokens > 0 || usage.completionTokens > 0)) { + usage.totalTokens = usage.promptTokens + usage.completionTokens; + } + return usage.totalTokens > 0 ? usage : null; +} + +function mergeAssistantTurnTokenUsage(target, usage) { + if (!usage) return target || null; + const out = target || { + modelCalls: 0, + promptTokens: 0, + completionTokens: 0, + totalTokens: 0, + cachedTokens: 0, + reasoningTokens: 0, + model: '' + }; + out.modelCalls += usage.modelCalls || 0; + out.promptTokens += usage.promptTokens || 0; + out.completionTokens += usage.completionTokens || 0; + out.totalTokens += usage.totalTokens || 0; + out.cachedTokens += usage.cachedTokens || 0; + out.reasoningTokens += usage.reasoningTokens || 0; + if (!out.model && usage.model) out.model = usage.model; + return out.totalTokens > 0 ? out : null; +} + +function extractAssistantTurnTokenUsage(processDetails) { + if (!Array.isArray(processDetails)) return null; + let total = null; + processDetails.forEach((detail) => { + if (!detail || String(detail.eventType || '').trim() !== 'eino_usage_summary') return; + const usage = normalizeAssistantTurnTokenUsage(detail.data); + total = mergeAssistantTurnTokenUsage(total, usage); + }); + return total; +} + +function setAssistantTurnTokenUsage(messageElementOrId, usage) { + const messageElement = typeof messageElementOrId === 'string' + ? document.getElementById(messageElementOrId) + : messageElementOrId; + if (!messageElement || !messageElement.dataset) return; + const normalized = normalizeAssistantTurnTokenUsage(usage); + if (!normalized) { + delete messageElement.dataset.turnModelCalls; + delete messageElement.dataset.turnPromptTokens; + delete messageElement.dataset.turnCompletionTokens; + delete messageElement.dataset.turnTotalTokens; + delete messageElement.dataset.turnCachedTokens; + delete messageElement.dataset.turnReasoningTokens; + delete messageElement.dataset.turnModel; + syncAssistantTurnSummary(messageElement); + return; + } + messageElement.dataset.turnModelCalls = String(normalized.modelCalls || 0); + messageElement.dataset.turnPromptTokens = String(normalized.promptTokens || 0); + messageElement.dataset.turnCompletionTokens = String(normalized.completionTokens || 0); + messageElement.dataset.turnTotalTokens = String(normalized.totalTokens || 0); + messageElement.dataset.turnCachedTokens = String(normalized.cachedTokens || 0); + messageElement.dataset.turnReasoningTokens = String(normalized.reasoningTokens || 0); + if (normalized.model) { + messageElement.dataset.turnModel = normalized.model; + } else { + delete messageElement.dataset.turnModel; + } + syncAssistantTurnSummary(messageElement); +} + +function getAssistantTurnTokenUsage(messageElement) { + if (!messageElement || !messageElement.dataset) return null; + return normalizeAssistantTurnTokenUsage({ + modelCalls: messageElement.dataset.turnModelCalls, + promptTokens: messageElement.dataset.turnPromptTokens, + completionTokens: messageElement.dataset.turnCompletionTokens, + totalTokens: messageElement.dataset.turnTotalTokens, + cachedTokens: messageElement.dataset.turnCachedTokens, + reasoningTokens: messageElement.dataset.turnReasoningTokens, + model: messageElement.dataset.turnModel + }); +} + +function formatAssistantTurnTokenCount(value) { + const n = assistantTurnUsageNumber(value); + if (n >= 1000000) return (n / 1000000).toFixed(n >= 10000000 ? 0 : 1).replace(/\.0$/, '') + 'M'; + if (n >= 1000) return (n / 1000).toFixed(n >= 100000 ? 0 : 1).replace(/\.0$/, '') + 'K'; + return String(n); +} + +function formatAssistantTurnTokenUsageLabel(usage) { + const tokens = formatAssistantTurnTokenCount(usage && usage.totalTokens); + return typeof window.t === 'function' + ? window.t('chat.turnTokenUsageLabel', { tokens: tokens }) + : tokens + ' tokens'; +} + +function formatAssistantTurnTokenUsageTitle(usage) { + const safeUsage = usage || {}; + const values = { + total: formatAssistantTurnTokenCount(safeUsage.totalTokens), + prompt: formatAssistantTurnTokenCount(safeUsage.promptTokens), + completion: formatAssistantTurnTokenCount(safeUsage.completionTokens), + cached: formatAssistantTurnTokenCount(safeUsage.cachedTokens), + reasoning: formatAssistantTurnTokenCount(safeUsage.reasoningTokens), + calls: formatAssistantTurnTokenCount(safeUsage.modelCalls), + model: safeUsage.model || '' + }; + return typeof window.t === 'function' + ? window.t('chat.turnTokenUsageTitle', values) + : 'Token usage: ' + values.total + ' (input ' + values.prompt + ', output ' + values.completion + ')'; +} + function assistantTurnTimestamp(value) { if (value == null || value === '') return NaN; const n = new Date(value).getTime(); @@ -4985,6 +5170,12 @@ function syncAssistantTurnSummary(messageElementOrId) { : 0; } const duration = formatAssistantTurnDuration(durationMs); + const tokenUsage = getAssistantTurnTokenUsage(messageElement); + const tokenUsageHtml = tokenUsage + ? '' + + escapeHtml(formatAssistantTurnTokenUsageLabel(tokenUsage)) + + '' + : ''; let text; if (status === 'running') { text = typeof window.t === 'function' ? window.t('chat.turnElapsedRunning', { duration: duration }) : '已处理 ' + duration; @@ -5001,6 +5192,7 @@ function syncAssistantTurnSummary(messageElementOrId) { ${escapeHtml(text)} + ${tokenUsageHtml} `; @@ -5012,8 +5204,10 @@ function syncAssistantTurnSummary(messageElementOrId) { } window.setAssistantTurnTiming = setAssistantTurnTiming; +window.setAssistantTurnTokenUsage = setAssistantTurnTokenUsage; window.syncAssistantTurnSummary = syncAssistantTurnSummary; window.formatAssistantTurnDuration = formatAssistantTurnDuration; +window.extractAssistantTurnTokenUsage = extractAssistantTurnTokenUsage; /** 渗透测试区:工具栏(展开详情 | N次工具执行)+ 独立工具列表 + 迭代时间线 */ function ensureMcpCallSectionChrome(messageElement, messageId) { @@ -6156,6 +6350,43 @@ async function prefetchLastAssistantProcessDetails() { } } +async function hydrateConversationTokenUsage(conversationId, expectedSeq, signal) { + const id = String(conversationId || '').trim(); + if (!id || typeof apiFetch !== 'function' || typeof window.setAssistantTurnTokenUsage !== 'function') return; + if (signal && signal.aborted) return; + const params = new URLSearchParams(); + params.set('since', '1970-01-01'); + params.set('limit', '500'); + const res = await apiFetch( + '/api/conversations/' + encodeURIComponent(id) + '/token-usage?' + params.toString(), + signal ? { signal: signal } : undefined + ); + const payload = await res.json().catch(() => ({})); + if (!res.ok || (signal && signal.aborted)) return; + if (expectedSeq != null && expectedSeq !== loadConversationRequestSeq) return; + if (currentConversationId !== id) return; + const rows = Array.isArray(payload && payload.recent) ? payload.recent : []; + if (rows.length === 0) return; + const byMessage = new Map(); + rows.forEach((row) => { + const messageId = row && row.messageId != null ? String(row.messageId).trim() : ''; + if (!messageId) return; + const usage = normalizeAssistantTurnTokenUsage(row); + if (!usage) return; + byMessage.set(messageId, mergeAssistantTurnTokenUsage(byMessage.get(messageId) || null, usage)); + }); + if (byMessage.size === 0) return; + document.querySelectorAll('#chat-messages .message.assistant[data-backend-message-id]').forEach((messageElement) => { + const backendMessageId = messageElement && messageElement.dataset + ? String(messageElement.dataset.backendMessageId || '').trim() + : ''; + const usage = backendMessageId ? byMessage.get(backendMessageId) : null; + if (usage) { + window.setAssistantTurnTokenUsage(messageElement, usage); + } + }); +} + async function loadConversation(conversationId) { conversationId = String(conversationId || '').trim(); if (!conversationId) return; @@ -6458,6 +6689,11 @@ async function loadConversation(conversationId) { if (seq !== loadConversationRequestSeq) { return; } + hydrateConversationTokenUsage(conversationId, seq, conversationLoadController.signal).catch((e) => { + if (!e || e.name !== 'AbortError') { + console.warn('hydrateConversationTokenUsage failed', e); + } + }); if (currentConversationId === conversationId && typeof window.restoreHitlInlineForConversation === 'function') { await window.restoreHitlInlineForConversation(conversationId); } diff --git a/web/static/js/dashboard.js b/web/static/js/dashboard.js index 43081317..23243bd5 100644 --- a/web/static/js/dashboard.js +++ b/web/static/js/dashboard.js @@ -66,10 +66,12 @@ async function refreshDashboard() { setDashboardOverviewPlaceholder('…'); setEl('dashboard-kpi-tools-calls', '…'); setEl('dashboard-kpi-success-rate', '…'); + setEl('dashboard-kpi-token-usage', '…'); setKpiSubText('dashboard-kpi-tasks-sub-text', '…'); setKpiSubText('dashboard-kpi-vuln-sub-text', '…'); setKpiSubText('dashboard-kpi-tools-sub-text', '…'); setKpiSubText('dashboard-kpi-rate-sub-text', '…'); + setKpiSubText('dashboard-kpi-token-sub-text', '…'); hideEl('dashboard-kpi-vuln-critical-badge'); hideEl('dashboard-alert-banner'); setRecentVulnsLoading(); @@ -127,7 +129,7 @@ async function refreshDashboard() { hitlPendingRes, notificationsRes, externalMcpStatsRes, webshellRes, c2ListenersRes, c2SessionsRes, c2TasksRes, - projectSummaryRes, severityFilteredStatsRes + projectSummaryRes, severityFilteredStatsRes, tokenUsageRes ] = await Promise.all([ fetchJson('/api/agent-loop/tasks'), fetchJson('/api/vulnerabilities/stats'), @@ -159,7 +161,8 @@ async function refreshDashboard() { fetchJson(dashboardProjectScopedUrl('/api/c2/sessions?limit=500')), fetchJson(dashboardProjectScopedUrl('/api/c2/tasks?page=1&page_size=1')), fetchJson('/api/projects/dashboard-summary?fact_limit=10'), - selectedSeverityStatus ? fetchJson('/api/vulnerabilities/stats?status=' + encodeURIComponent(selectedSeverityStatus)) : Promise.resolve(null) + selectedSeverityStatus ? fetchJson('/api/vulnerabilities/stats?status=' + encodeURIComponent(selectedSeverityStatus)) : Promise.resolve(null), + fetchJson(dashboardProjectScopedUrl('/api/usage/tokens?days=7&limit=5')) ]); // 如果在 await 期间 controller 已被 abort,说明又有新刷新启动了,丢弃本次结果 @@ -330,6 +333,8 @@ async function refreshDashboard() { renderDashboardToolsBar(null); } + renderDashboardTokenUsage(tokenUsageRes); + // 「能力总览 → MCP 工具」用配置总数(包含未被调用过的工具);专项接口失败时回落到 monitor 的 names.length if (toolsConfigRes && typeof toolsConfigRes.total === 'number') { setEl('dashboard-resource-tools', formatNumber(toolsConfigRes.total)); @@ -435,10 +440,12 @@ async function refreshDashboard() { setDashboardOverviewPlaceholder('-'); setEl('dashboard-kpi-success-rate', '-'); setEl('dashboard-kpi-tools-calls', '-'); + setEl('dashboard-kpi-token-usage', '-'); setKpiSubText('dashboard-kpi-tasks-sub-text', '-'); setKpiSubText('dashboard-kpi-vuln-sub-text', '-'); setKpiSubText('dashboard-kpi-tools-sub-text', '-'); setKpiSubText('dashboard-kpi-rate-sub-text', '-'); + setKpiSubText('dashboard-kpi-token-sub-text', '-'); ['tools', 'skills', 'knowledge', 'roles', 'agents'].forEach(function (k) { setEl('dashboard-resource-' + k, '-'); }); @@ -700,6 +707,41 @@ function setKpiRateBadge(id, rate, failedCount) { } } +function renderDashboardTokenUsage(res) { + const summary = res && res.summary ? res.summary : null; + if (!summary) { + setEl('dashboard-kpi-token-usage', '-'); + setKpiSubText('dashboard-kpi-token-sub-text', '-'); + return; + } + const total = Number(summary.totalTokens || 0); + const calls = Number(summary.modelCalls || 0); + const today = res && res.today ? Number(res.today.totalTokens || 0) : 0; + if (!Number.isFinite(total) || total <= 0) { + setEl('dashboard-kpi-token-usage', '0'); + setKpiSubText('dashboard-kpi-token-sub-text', dt('dashboard.noTokenUsageYet', null, '暂无用量')); + return; + } + setEl('dashboard-kpi-token-usage', formatTokenUsageCompact(total)); + setKpiSubText('dashboard-kpi-token-sub-text', + dt('dashboard.tokenUsageSub', { + today: formatTokenUsageCompact(today), + calls: Number.isFinite(calls) ? calls : 0 + }, '近 7 天 ' + (Number.isFinite(calls) ? calls : 0) + ' 次调用 · 今日 ' + formatTokenUsageCompact(today))); +} + +function formatTokenUsageCompact(num) { + const n = Number(num || 0); + if (!Number.isFinite(n) || n <= 0) return '0'; + if (n >= 1000000) { + return (n / 1000000).toFixed(n >= 10000000 ? 0 : 1).replace(/\.0$/, '') + 'M'; + } + if (n >= 1000) { + return (n / 1000).toFixed(n >= 10000 ? 0 : 1).replace(/\.0$/, '') + 'K'; + } + return String(Math.trunc(n)); +} + // sessionStorage:告警条「×」忽略记录 + 最近一次**实际展示过**的 reason 片段(不含 level), // 用于在「问题从多变少」(如审完 HITL 后只剩严重漏洞)时,避免误用更早对「仅子集」的忽略。 var DASH_SESSION_ALERT_DISMISSED = 'dashboard.dismissedAlert'; diff --git a/web/static/js/monitor.js b/web/static/js/monitor.js index f684287d..f9bcbecc 100644 --- a/web/static/js/monitor.js +++ b/web/static/js/monitor.js @@ -1044,7 +1044,7 @@ function updateAssistantBubbleContent(assistantMessageId, content, renderMarkdow const bubble = assistantElement.querySelector('.message-bubble'); if (!bubble) return; - // 保留复制按钮:addMessage 会把按钮 append 在 message-bubble 里 + // 清理旧版本可能残留在气泡内的复制按钮;新版按钮统一在时间行。 const copyBtn = bubble.querySelector('.message-copy-btn'); if (copyBtn) copyBtn.remove(); @@ -1066,7 +1066,9 @@ function updateAssistantBubbleContent(assistantMessageId, content, renderMarkdow if (typeof wrapTablesInBubble === 'function') { wrapTablesInBubble(bubble); } - if (copyBtn) bubble.appendChild(copyBtn); + if (typeof window.appendMessageCopyButton === 'function') { + window.appendMessageCopyButton(assistantElement); + } if (typeof window.csMarkdownSanitize !== 'undefined') { window.csMarkdownSanitize.stripSuspiciousImages(bubble); diff --git a/web/static/js/project-folder-preview.test.cjs b/web/static/js/project-folder-preview.test.cjs index 2e3c44f9..4478ca4f 100644 --- a/web/static/js/project-folder-preview.test.cjs +++ b/web/static/js/project-folder-preview.test.cjs @@ -138,3 +138,17 @@ test('对话悬浮预览标题与时间分行显示并保留更多标题内容', assert.match(titleStyles, /-webkit-line-clamp: 2;/); assert.doesNotMatch(titleStyles, /white-space: nowrap;/); }); + +test('对话悬浮预览使用美化后的代理模式徽标', () => { + const projects = fs.readFileSync('web/static/js/projects.js', 'utf8'); + + assert.match(projects, /function getProjectConversationModeIconClass\(conversation\)/); + assert.match(projects, /project-conversation-preview-mode-icon agent-mode-logo agent-mode-logo--default/); + assert.match(projects, /agent-mode-logo__svg/); + assert.match(projects, /
- + @@ -3165,6 +3174,10 @@ function showProjectConversationPreview(conversation, project, row) { ageEl.hidden = !ageEl.textContent; preview.querySelector('.project-conversation-preview-project').textContent = project?.name || pickerMessage(tp, 'chat.conversationPreviewNoProject', '未绑定项目'); + const modeIcon = preview.querySelector('.project-conversation-preview-mode-icon'); + if (modeIcon) { + modeIcon.className = 'project-conversation-preview-mode-icon agent-mode-logo agent-mode-logo--' + getProjectConversationModeIconClass(conversation); + } preview.querySelector('.project-conversation-preview-mode').textContent = getProjectConversationModeLabel(conversation); statusEl.textContent = status; statusEl.className = 'project-conversation-preview-status' diff --git a/web/static/js/turn-duration-refresh.test.cjs b/web/static/js/turn-duration-refresh.test.cjs index 262548c9..2f0d94b6 100644 --- a/web/static/js/turn-duration-refresh.test.cjs +++ b/web/static/js/turn-duration-refresh.test.cjs @@ -66,7 +66,7 @@ function createHarness(nowMs) { clearInterval() {}, }; vm.runInNewContext( - `${timingSource(chat)}; this.setAssistantTurnTiming = setAssistantTurnTiming;`, + `${timingSource(chat)}; this.setAssistantTurnTiming = setAssistantTurnTiming; this.setAssistantTurnTokenUsage = setAssistantTurnTokenUsage; this.extractAssistantTurnTokenUsage = extractAssistantTurnTokenUsage;`, context ); return context; @@ -103,6 +103,45 @@ test('已完成任务仍优先使用持久化耗时', () => { assert.match(message.label.innerHTML, /耗时 1 分钟 5 秒/); }); +test('助手轮次摘要会显示持久化 token 用量', () => { + const context = createHarness(Date.parse('2026-08-12T02:05:00.000Z')); + const message = createMessage(); + + context.setAssistantTurnTiming(message, { + startedAt: '2026-08-12T02:00:00.000Z', + completedAt: '2026-08-12T02:00:03.000Z', + durationMs: 3000, + status: 'completed', + }); + context.setAssistantTurnTokenUsage(message, { + promptTokens: 1200, + completionTokens: 34, + cachedTokens: 200, + reasoningTokens: 12, + modelCalls: 1, + model: 'deepseek-v3', + }); + + assert.equal(message.dataset.turnTotalTokens, '1234'); + assert.match(message.label.innerHTML, /1\.2K tokens/); + assert.match(message.label.innerHTML, /turn-process-token-chip/); +}); + +test('助手轮次可从 Eino usage summary 过程详情提取 token 用量', () => { + const context = createHarness(Date.parse('2026-08-12T02:05:00.000Z')); + + const usage = context.extractAssistantTurnTokenUsage([ + { eventType: 'progress', data: { totalTokens: 9999 } }, + { eventType: 'eino_usage_summary', data: { promptTokens: 400, completionTokens: 100, modelCalls: 1 } }, + { eventType: 'eino_usage_summary', data: { totalTokens: 25, modelCalls: 1 } }, + ]); + + assert.equal(usage.totalTokens, 525); + assert.equal(usage.promptTokens, 400); + assert.equal(usage.completionTokens, 100); + assert.equal(usage.modelCalls, 2); +}); + test('已中断任务使用固定终态耗时且不再按当前时间增长', () => { const context = createHarness(Date.parse('2026-08-13T12:00:00.000Z')); const message = createMessage(); diff --git a/web/templates/index.html b/web/templates/index.html index c094de23..d02d213a 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -529,6 +529,16 @@ 运行平稳
+
+
+
Token 用量
+ +
+
-
+
+ - +
+
@@ -1213,7 +1223,7 @@