mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-08-29 06:00:52 +02:00
Add token usage tracking and UI refinements
This commit is contained in:
@@ -1027,9 +1027,11 @@ func setupRoutes(
|
|||||||
protected.DELETE("/batch-tasks/:queueId/tasks/:taskId", agentHandler.DeleteBatchTask)
|
protected.DELETE("/batch-tasks/:queueId/tasks/:taskId", agentHandler.DeleteBatchTask)
|
||||||
|
|
||||||
// 对话历史
|
// 对话历史
|
||||||
|
protected.GET("/usage/tokens", conversationHandler.GetTokenUsageStats)
|
||||||
protected.POST("/conversations", conversationHandler.CreateConversation)
|
protected.POST("/conversations", conversationHandler.CreateConversation)
|
||||||
protected.GET("/conversations", conversationHandler.ListConversations)
|
protected.GET("/conversations", conversationHandler.ListConversations)
|
||||||
protected.GET("/conversations/:id", conversationHandler.GetConversation)
|
protected.GET("/conversations/:id", conversationHandler.GetConversation)
|
||||||
|
protected.GET("/conversations/:id/token-usage", conversationHandler.GetConversationTokenUsageStats)
|
||||||
protected.GET("/conversations/:id/plan-tasks", conversationHandler.GetConversationPlanTasks)
|
protected.GET("/conversations/:id/plan-tasks", conversationHandler.GetConversationPlanTasks)
|
||||||
protected.GET("/messages/:id/process-details", conversationHandler.GetMessageProcessDetails)
|
protected.GET("/messages/:id/process-details", conversationHandler.GetMessageProcessDetails)
|
||||||
protected.GET("/process-details/:id", conversationHandler.GetProcessDetail)
|
protected.GET("/process-details/:id", conversationHandler.GetProcessDetail)
|
||||||
|
|||||||
@@ -1350,6 +1350,8 @@ func (db *DB) AddProcessDetailWithID(messageID, conversationID, eventType, messa
|
|||||||
return "", fmt.Errorf("添加过程详情失败: %w", err)
|
return "", fmt.Errorf("添加过程详情失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
db.maybeRecordModelTokenUsage(messageID, conversationID, id, eventType, data)
|
||||||
|
|
||||||
return id, nil
|
return id, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -216,6 +216,32 @@ func (db *DB) initTables() error {
|
|||||||
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE
|
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 := `
|
createToolExecutionsTable := `
|
||||||
CREATE TABLE IF NOT EXISTS tool_executions (
|
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_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_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_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_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_start_time ON tool_executions(start_time);
|
||||||
CREATE INDEX IF NOT EXISTS idx_tool_executions_status ON tool_executions(status);
|
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)
|
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 {
|
if _, err := db.Exec(createToolExecutionsTable); err != nil {
|
||||||
return fmt.Errorf("创建tool_executions表失败: %w", err)
|
return fmt.Errorf("创建tool_executions表失败: %w", err)
|
||||||
}
|
}
|
||||||
@@ -981,6 +1015,10 @@ func (db *DB) initTables() error {
|
|||||||
if _, err := db.Exec(createIndexes); err != nil {
|
if _, err := db.Exec(createIndexes); err != nil {
|
||||||
return fmt.Errorf("创建索引失败: %w", err)
|
return fmt.Errorf("创建索引失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := db.BackfillModelTokenUsageFromProcessDetails(); err != nil {
|
||||||
|
return fmt.Errorf("回填模型Token用量失败: %w", err)
|
||||||
|
}
|
||||||
db.logger.Debug("数据库表初始化完成")
|
db.logger.Debug("数据库表初始化完成")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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{}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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{}
|
||||||
|
}
|
||||||
@@ -371,5 +371,9 @@ func (s *einoRunRuntimeSession) emitUsageSummary(reason string) bool {
|
|||||||
if s == nil || s.usage == nil {
|
if s == nil || s.usage == nil {
|
||||||
return false
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ func (a *einoRunUsageAccumulator) EmitOnce(
|
|||||||
conversationID string,
|
conversationID string,
|
||||||
orchestration string,
|
orchestration string,
|
||||||
reason string,
|
reason string,
|
||||||
|
modelName string,
|
||||||
progress func(eventType, message string, data interface{}),
|
progress func(eventType, message string, data interface{}),
|
||||||
logger *zap.Logger,
|
logger *zap.Logger,
|
||||||
) bool {
|
) bool {
|
||||||
@@ -81,6 +82,7 @@ func (a *einoRunUsageAccumulator) EmitOnce(
|
|||||||
"source": "eino",
|
"source": "eino",
|
||||||
"orchestration": orchestration,
|
"orchestration": orchestration,
|
||||||
"reason": reason,
|
"reason": reason,
|
||||||
|
"model": modelName,
|
||||||
"modelCalls": s.ModelCalls,
|
"modelCalls": s.ModelCalls,
|
||||||
"promptTokens": s.PromptTokens,
|
"promptTokens": s.PromptTokens,
|
||||||
"completionTokens": s.CompletionTokens,
|
"completionTokens": s.CompletionTokens,
|
||||||
@@ -96,6 +98,7 @@ func (a *einoRunUsageAccumulator) EmitOnce(
|
|||||||
zap.String("conversationId", conversationID),
|
zap.String("conversationId", conversationID),
|
||||||
zap.String("orchestration", orchestration),
|
zap.String("orchestration", orchestration),
|
||||||
zap.String("reason", reason),
|
zap.String("reason", reason),
|
||||||
|
zap.String("model", modelName),
|
||||||
zap.Int("modelCalls", s.ModelCalls),
|
zap.Int("modelCalls", s.ModelCalls),
|
||||||
zap.Int("promptTokens", s.PromptTokens),
|
zap.Int("promptTokens", s.PromptTokens),
|
||||||
zap.Int("completionTokens", s.CompletionTokens),
|
zap.Int("completionTokens", s.CompletionTokens),
|
||||||
|
|||||||
@@ -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")
|
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")
|
t.Fatal("second emit should return false")
|
||||||
}
|
}
|
||||||
if len(events) != 1 {
|
if len(events) != 1 {
|
||||||
t.Fatalf("events = %#v, want one usage summary", events)
|
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])
|
t.Fatalf("event = %#v", events[0])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,6 +118,8 @@ func permissionForRequest(method, fullPath string) string {
|
|||||||
return "hitl:write"
|
return "hitl:write"
|
||||||
case strings.HasPrefix(path, "/agent-loop"), strings.HasPrefix(path, "/batch-tasks"):
|
case strings.HasPrefix(path, "/agent-loop"), strings.HasPrefix(path, "/batch-tasks"):
|
||||||
return crudPermission(method, "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"):
|
case strings.HasPrefix(path, "/conversations"), strings.HasPrefix(path, "/messages"), strings.HasPrefix(path, "/process-details"):
|
||||||
return crudPermission(method, "chat")
|
return crudPermission(method, "chat")
|
||||||
case strings.HasPrefix(path, "/groups"):
|
case strings.HasPrefix(path, "/groups"):
|
||||||
|
|||||||
@@ -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) {
|
func TestMCPInvocationPermissionIsSeparateFromMCPAdministration(t *testing.T) {
|
||||||
if got := permissionForRequest(http.MethodPost, "/api/mcp"); got != "mcp:execute" {
|
if got := permissionForRequest(http.MethodPost, "/api/mcp"); got != "mcp:execute" {
|
||||||
t.Fatalf("MCP invocation permission = %q, want mcp:execute", got)
|
t.Fatalf("MCP invocation permission = %q, want mcp:execute", got)
|
||||||
|
|||||||
+128
-56
@@ -4107,62 +4107,50 @@ html[data-theme="dark"] .sidebar-content:hover::-webkit-scrollbar-thumb:hover {
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 消息复制按钮 - 位于消息气泡右下角 */
|
/* 消息复制按钮 - 与时间戳同一行 */
|
||||||
.message-copy-btn {
|
.message-copy-btn {
|
||||||
position: absolute;
|
position: static;
|
||||||
bottom: 12px;
|
display: inline-flex;
|
||||||
right: 12px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 6px;
|
width: 28px;
|
||||||
padding: 8px 14px;
|
height: 28px;
|
||||||
background: #ffffff;
|
padding: 0;
|
||||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
background: transparent;
|
||||||
border-radius: 20px;
|
border: 1px solid transparent;
|
||||||
color: #666;
|
border-radius: 6px;
|
||||||
font-size: 0.8125rem;
|
color: var(--text-secondary, #888);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
opacity: 0.72;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.04);
|
flex-shrink: 0;
|
||||||
z-index: 10;
|
transition: opacity 0.2s ease, color 0.2s ease, background 0.2s ease, border-color 0.2s ease;
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(4px);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-bubble:hover .message-copy-btn {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-copy-btn:hover {
|
.message-copy-btn:hover {
|
||||||
background: rgba(255, 255, 255, 1);
|
color: var(--accent-color, #0066ff);
|
||||||
border-color: rgba(0, 102, 255, 0.2);
|
background: rgba(0, 102, 255, 0.07);
|
||||||
color: #0066ff;
|
border-color: rgba(0, 102, 255, 0.14);
|
||||||
box-shadow: 0 4px 12px rgba(0, 102, 255, 0.15), 0 2px 4px rgba(0, 0, 0, 0.08);
|
opacity: 1;
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-copy-btn:active {
|
.message-copy-btn:active {
|
||||||
transform: translateY(0) scale(0.98);
|
background: rgba(0, 102, 255, 0.11);
|
||||||
box-shadow: 0 2px 6px rgba(0, 102, 255, 0.12), 0 1px 2px rgba(0, 0, 0, 0.06);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-copy-btn svg {
|
.message-copy-btn svg {
|
||||||
width: 16px;
|
width: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
transition: transform 0.2s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-copy-btn:hover svg {
|
.message-copy-btn:focus-visible {
|
||||||
transform: scale(1.1);
|
opacity: 1;
|
||||||
|
outline: 2px solid var(--accent-color, #0066ff);
|
||||||
|
outline-offset: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-copy-btn span {
|
.message-copy-btn span {
|
||||||
font-weight: 500;
|
display: none;
|
||||||
letter-spacing: 0.01em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 时间戳 + 删除本轮(与气泡分离,和「展开详情」同一视觉层级) */
|
/* 时间戳 + 删除本轮(与气泡分离,和「展开详情」同一视觉层级) */
|
||||||
@@ -24302,11 +24290,15 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible {
|
|||||||
/* 第一行:核心 KPI(视觉层次 + 色块强调) */
|
/* 第一行:核心 KPI(视觉层次 + 色块强调) */
|
||||||
.dashboard-kpi-row {
|
.dashboard-kpi-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.dashboard-kpi-row { grid-template-columns: repeat(3, 1fr); }
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.dashboard-kpi-row { grid-template-columns: repeat(2, 1fr); }
|
.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(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(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(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 {
|
.dashboard-kpi-card:hover {
|
||||||
transform: translateY(-3px);
|
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-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-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-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 {
|
.dashboard-kpi-value {
|
||||||
font-size: 1.875rem;
|
font-size: 1.875rem;
|
||||||
@@ -28734,12 +28728,54 @@ html[data-theme="dark"] .vulnerability-details code.vuln-detail-field-value {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.role-selector-icon {
|
.role-selector-icon {
|
||||||
font-size: 1rem;
|
|
||||||
line-height: 1;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
display: flex;
|
}
|
||||||
|
|
||||||
|
.agent-mode-logo {
|
||||||
|
--agent-logo-a: #858d98;
|
||||||
|
--agent-logo-b: #858d98;
|
||||||
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: 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 {
|
.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);
|
border-color: rgba(138, 43, 226, 0.3);
|
||||||
box-shadow: 0 2px 6px rgba(138, 43, 226, 0.2);
|
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 {
|
.role-selection-item-content-main {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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(1),
|
||||||
html[data-theme="dark"] .dashboard-kpi-card:nth-child(2),
|
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(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%);
|
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 {
|
html[data-theme="dark"] .message-copy-btn {
|
||||||
background: #1f2937;
|
|
||||||
border-color: #334155;
|
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.28);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-theme="dark"] .message-copy-btn:hover {
|
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);
|
border-color: rgba(96, 165, 250, 0.45);
|
||||||
color: var(--accent-hover);
|
color: var(--accent-hover);
|
||||||
box-shadow: 0 4px 12px rgba(96, 165, 250, 0.18);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-theme="dark"] .message-copy-btn:active {
|
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 {
|
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;
|
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,
|
||||||
html[data-theme="dark"] .sidebar-list-pagination-inner,
|
html[data-theme="dark"] .sidebar-list-pagination-inner,
|
||||||
html[data-theme="dark"] .conversation-sidebar-pagination,
|
html[data-theme="dark"] .conversation-sidebar-pagination,
|
||||||
@@ -45062,6 +45115,13 @@ html[data-theme="dark"] .project-folder-preview-edit:focus-visible {
|
|||||||
color: #858d98;
|
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-project,
|
||||||
.project-conversation-preview-mode {
|
.project-conversation-preview-mode {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -46350,15 +46410,6 @@ html[data-theme="dark"] .chat-composer-surface > .chat-session-settings-popover
|
|||||||
margin-bottom: 0;
|
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 {
|
.message.assistant-turn-with-process .mcp-call-section {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -46408,8 +46459,9 @@ html[data-theme="dark"] .chat-composer-surface > .chat-session-settings-popover
|
|||||||
.turn-process-leading {
|
.turn-process-leading {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 9px;
|
gap: 8px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.turn-process-status-dot {
|
.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;
|
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 {
|
@keyframes codex-turn-pulse {
|
||||||
0%, 100% { opacity: 0.55; transform: scale(0.88); }
|
0%, 100% { opacity: 0.55; transform: scale(0.88); }
|
||||||
50% { opacity: 1; transform: scale(1); }
|
50% { opacity: 1; transform: scale(1); }
|
||||||
|
|||||||
@@ -127,6 +127,9 @@
|
|||||||
"vulnTotal": "Total vulnerabilities",
|
"vulnTotal": "Total vulnerabilities",
|
||||||
"toolCalls": "Tool invocations",
|
"toolCalls": "Tool invocations",
|
||||||
"successRate": "Tool success rate",
|
"successRate": "Tool success rate",
|
||||||
|
"tokenUsage": "Token usage",
|
||||||
|
"tokenUsageSub": "Last 7 days {{calls}} calls · Today {{today}}",
|
||||||
|
"noTokenUsageYet": "No usage yet",
|
||||||
"clickToViewTasks": "Click to view tasks",
|
"clickToViewTasks": "Click to view tasks",
|
||||||
"clickToViewChat": "Click to view conversations",
|
"clickToViewChat": "Click to view conversations",
|
||||||
"clickToViewVuln": "Click to view vulnerabilities",
|
"clickToViewVuln": "Click to view vulnerabilities",
|
||||||
@@ -633,6 +636,8 @@
|
|||||||
"turnDurationMinutes": "{{minutes}} min {{seconds}} sec",
|
"turnDurationMinutes": "{{minutes}} min {{seconds}} sec",
|
||||||
"turnDurationHours": "{{hours}} hr {{minutes}} min",
|
"turnDurationHours": "{{hours}} hr {{minutes}} min",
|
||||||
"turnProcessAria": "{{state}}; expand or collapse execution details",
|
"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}}",
|
"turnNumber": "Turn {{number}}",
|
||||||
"turnPending": "Processing…",
|
"turnPending": "Processing…",
|
||||||
"expandDetailLazyHint": "Expand details (loads iteration details on click)",
|
"expandDetailLazyHint": "Expand details (loads iteration details on click)",
|
||||||
|
|||||||
@@ -127,6 +127,9 @@
|
|||||||
"vulnTotal": "漏洞总数",
|
"vulnTotal": "漏洞总数",
|
||||||
"toolCalls": "工具调用次数",
|
"toolCalls": "工具调用次数",
|
||||||
"successRate": "工具执行成功率",
|
"successRate": "工具执行成功率",
|
||||||
|
"tokenUsage": "Token 用量",
|
||||||
|
"tokenUsageSub": "近 7 天 {{calls}} 次调用 · 今日 {{today}}",
|
||||||
|
"noTokenUsageYet": "暂无用量",
|
||||||
"clickToViewTasks": "点击查看任务管理",
|
"clickToViewTasks": "点击查看任务管理",
|
||||||
"clickToViewChat": "点击查看对话",
|
"clickToViewChat": "点击查看对话",
|
||||||
"clickToViewVuln": "点击查看漏洞管理",
|
"clickToViewVuln": "点击查看漏洞管理",
|
||||||
@@ -621,6 +624,8 @@
|
|||||||
"turnDurationMinutes": "{{minutes}} 分钟 {{seconds}} 秒",
|
"turnDurationMinutes": "{{minutes}} 分钟 {{seconds}} 秒",
|
||||||
"turnDurationHours": "{{hours}} 小时 {{minutes}} 分钟",
|
"turnDurationHours": "{{hours}} 小时 {{minutes}} 分钟",
|
||||||
"turnProcessAria": "{{state}},展开或收起执行过程",
|
"turnProcessAria": "{{state}},展开或收起执行过程",
|
||||||
|
"turnTokenUsageLabel": "{{tokens}} tokens",
|
||||||
|
"turnTokenUsageTitle": "Token 用量:{{total}}(输入 {{prompt}},输出 {{completion}},缓存 {{cached}},推理 {{reasoning}},调用 {{calls}} 次)",
|
||||||
"turnNumber": "第 {{number}} 轮",
|
"turnNumber": "第 {{number}} 轮",
|
||||||
"turnPending": "正在处理…",
|
"turnPending": "正在处理…",
|
||||||
"expandDetailLazyHint": "展开详情(点击后加载迭代详情)",
|
"expandDetailLazyHint": "展开详情(点击后加载迭代详情)",
|
||||||
|
|||||||
@@ -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\)/);
|
||||||
|
});
|
||||||
+258
-22
@@ -1001,23 +1001,30 @@ function getAgentModeLabelForValue(mode) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAgentModeIconForValue(mode) {
|
function getAgentModeIconClassForValue(mode) {
|
||||||
switch (mode) {
|
switch (mode) {
|
||||||
case CHAT_AGENT_MODE_EINO_SINGLE: return '⚡';
|
case CHAT_AGENT_MODE_EINO_SINGLE: return 'eino';
|
||||||
case 'deep': return '🧩';
|
case 'deep': return 'deep';
|
||||||
case 'plan_execute': return '📋';
|
case 'plan_execute': return 'plan';
|
||||||
case 'supervisor': return '🎯';
|
case 'supervisor': return 'supervisor';
|
||||||
default: return '🤖';
|
default: return 'default';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderAgentModeLogoMarkup() {
|
||||||
|
return '<svg class="agent-mode-logo__svg" viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="5" r="2"/><path d="M12 7v4"/><path d="M8 16h.01"/><path d="M16 16h.01"/></svg>';
|
||||||
|
}
|
||||||
|
|
||||||
function syncAgentModeFromValue(value) {
|
function syncAgentModeFromValue(value) {
|
||||||
const hid = document.getElementById('agent-mode-select');
|
const hid = document.getElementById('agent-mode-select');
|
||||||
const label = document.getElementById('agent-mode-text');
|
const label = document.getElementById('agent-mode-text');
|
||||||
const icon = document.getElementById('agent-mode-icon');
|
const icon = document.getElementById('agent-mode-icon');
|
||||||
if (hid) hid.value = value;
|
if (hid) hid.value = value;
|
||||||
if (label) label.textContent = getAgentModeLabelForValue(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) {
|
document.querySelectorAll('.agent-mode-option').forEach(function (el) {
|
||||||
const v = el.getAttribute('data-value');
|
const v = el.getAttribute('data-value');
|
||||||
el.classList.toggle('selected', v === value);
|
el.classList.toggle('selected', v === value);
|
||||||
@@ -3507,9 +3514,60 @@ function refreshSystemReadyMessageBubbles() {
|
|||||||
bubble.innerHTML = formattedContent;
|
bubble.innerHTML = formattedContent;
|
||||||
if (typeof wrapTablesInBubble === 'function') wrapTablesInBubble(bubble);
|
if (typeof wrapTablesInBubble === 'function') wrapTablesInBubble(bubble);
|
||||||
messageDiv.dataset.originalContent = text;
|
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 = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="9" y="9" width="13" height="13" rx="2" ry="2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg><span>' + (typeof window.t === 'function' ? window.t('common.copy') : '复制') + '</span>';
|
||||||
|
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 时,语言切换会刷新该条文案)
|
// 添加消息(options.systemReadyMessage 为 true 时,语言切换会刷新该条文案)
|
||||||
function addMessage(role, content, mcpExecutionIds = null, progressId = null, createdAt = null, options = null) {
|
function addMessage(role, content, mcpExecutionIds = null, progressId = null, createdAt = null, options = null) {
|
||||||
const messagesDiv = document.getElementById('chat-messages');
|
const messagesDiv = document.getElementById('chat-messages');
|
||||||
@@ -3581,23 +3639,10 @@ function addMessage(role, content, mcpExecutionIds = null, progressId = null, cr
|
|||||||
contentWrapper.appendChild(bubble);
|
contentWrapper.appendChild(bubble);
|
||||||
|
|
||||||
// 保存原始内容到消息元素,用于复制功能
|
// 保存原始内容到消息元素,用于复制功能
|
||||||
if (role === 'assistant') {
|
if (role === 'assistant' || role === 'user') {
|
||||||
messageDiv.dataset.originalContent = content;
|
messageDiv.dataset.originalContent = content;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 为助手消息添加复制按钮(复制整个回复内容)- 放在消息气泡右下角
|
|
||||||
if (role === 'assistant') {
|
|
||||||
const copyBtn = document.createElement('button');
|
|
||||||
copyBtn.className = 'message-copy-btn';
|
|
||||||
copyBtn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="9" y="9" width="13" height="13" rx="2" ry="2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg><span>' + (typeof window.t === 'function' ? window.t('common.copy') : '复制') + '</span>';
|
|
||||||
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');
|
const timeDiv = document.createElement('div');
|
||||||
timeDiv.className = 'message-time';
|
timeDiv.className = 'message-time';
|
||||||
@@ -3626,8 +3671,16 @@ function addMessage(role, content, mcpExecutionIds = null, progressId = null, cr
|
|||||||
try {
|
try {
|
||||||
timeDiv.dataset.messageTime = messageTime.toISOString();
|
timeDiv.dataset.messageTime = messageTime.toISOString();
|
||||||
} catch (e) { /* ignore */ }
|
} 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);
|
messageDiv.appendChild(contentWrapper);
|
||||||
|
|
||||||
|
// 为用户和助手消息添加复制按钮(复制整条消息内容)
|
||||||
|
if (role === 'assistant' || role === 'user') {
|
||||||
|
appendMessageCopyButton(messageDiv);
|
||||||
|
}
|
||||||
|
|
||||||
// 有 MCP 执行记录且非流式占位消息时展示调用按钮;带 progressId 的流式占位不挂此条(与进度卡片一致,结束时 integrate 再创建)
|
// 有 MCP 执行记录且非流式占位消息时展示调用按钮;带 progressId 的流式占位不挂此条(与进度卡片一致,结束时 integrate 再创建)
|
||||||
if (role === 'assistant' && (mcpExecutionIds && Array.isArray(mcpExecutionIds) && mcpExecutionIds.length > 0) && !progressId) {
|
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.lazyNotLoaded = '0';
|
||||||
detailsContainer.dataset.loaded = '1';
|
detailsContainer.dataset.loaded = '1';
|
||||||
}
|
}
|
||||||
|
const turnUsageFromDetails = extractAssistantTurnTokenUsage(processDetails);
|
||||||
|
if (turnUsageFromDetails) {
|
||||||
|
setAssistantTurnTokenUsage(messageElement, turnUsageFromDetails);
|
||||||
|
}
|
||||||
processDetails = mergeMessageReasoningContentIntoProcessDetails(processDetails, reasoningFromMessage);
|
processDetails = mergeMessageReasoningContentIntoProcessDetails(processDetails, reasoningFromMessage);
|
||||||
processDetails = filterNoiseProcessDetails(processDetails);
|
processDetails = filterNoiseProcessDetails(processDetails);
|
||||||
processDetails = dedupeConsecutiveProcessDetailRows(processDetails);
|
processDetails = dedupeConsecutiveProcessDetailRows(processDetails);
|
||||||
@@ -4887,6 +4944,134 @@ function formatAssistantTurnDuration(durationMs) {
|
|||||||
: seconds + ' 秒';
|
: 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) {
|
function assistantTurnTimestamp(value) {
|
||||||
if (value == null || value === '') return NaN;
|
if (value == null || value === '') return NaN;
|
||||||
const n = new Date(value).getTime();
|
const n = new Date(value).getTime();
|
||||||
@@ -4985,6 +5170,12 @@ function syncAssistantTurnSummary(messageElementOrId) {
|
|||||||
: 0;
|
: 0;
|
||||||
}
|
}
|
||||||
const duration = formatAssistantTurnDuration(durationMs);
|
const duration = formatAssistantTurnDuration(durationMs);
|
||||||
|
const tokenUsage = getAssistantTurnTokenUsage(messageElement);
|
||||||
|
const tokenUsageHtml = tokenUsage
|
||||||
|
? '<span class="turn-process-token-chip" title="' + escapeHtml(formatAssistantTurnTokenUsageTitle(tokenUsage)) + '">' +
|
||||||
|
escapeHtml(formatAssistantTurnTokenUsageLabel(tokenUsage)) +
|
||||||
|
'</span>'
|
||||||
|
: '';
|
||||||
let text;
|
let text;
|
||||||
if (status === 'running') {
|
if (status === 'running') {
|
||||||
text = typeof window.t === 'function' ? window.t('chat.turnElapsedRunning', { duration: duration }) : '已处理 ' + duration;
|
text = typeof window.t === 'function' ? window.t('chat.turnElapsedRunning', { duration: duration }) : '已处理 ' + duration;
|
||||||
@@ -5001,6 +5192,7 @@ function syncAssistantTurnSummary(messageElementOrId) {
|
|||||||
<span class="turn-process-leading">
|
<span class="turn-process-leading">
|
||||||
<span class="turn-process-status-dot${status === 'running' ? ' is-running' : ''}" aria-hidden="true"></span>
|
<span class="turn-process-status-dot${status === 'running' ? ' is-running' : ''}" aria-hidden="true"></span>
|
||||||
<span class="turn-process-summary-text">${escapeHtml(text)}</span>
|
<span class="turn-process-summary-text">${escapeHtml(text)}</span>
|
||||||
|
${tokenUsageHtml}
|
||||||
</span>
|
</span>
|
||||||
<svg class="turn-process-chevron" viewBox="0 0 20 20" fill="none" aria-hidden="true"><path d="M7.5 5.5L12 10l-4.5 4.5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
<svg class="turn-process-chevron" viewBox="0 0 20 20" fill="none" aria-hidden="true"><path d="M7.5 5.5L12 10l-4.5 4.5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
`;
|
`;
|
||||||
@@ -5012,8 +5204,10 @@ function syncAssistantTurnSummary(messageElementOrId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
window.setAssistantTurnTiming = setAssistantTurnTiming;
|
window.setAssistantTurnTiming = setAssistantTurnTiming;
|
||||||
|
window.setAssistantTurnTokenUsage = setAssistantTurnTokenUsage;
|
||||||
window.syncAssistantTurnSummary = syncAssistantTurnSummary;
|
window.syncAssistantTurnSummary = syncAssistantTurnSummary;
|
||||||
window.formatAssistantTurnDuration = formatAssistantTurnDuration;
|
window.formatAssistantTurnDuration = formatAssistantTurnDuration;
|
||||||
|
window.extractAssistantTurnTokenUsage = extractAssistantTurnTokenUsage;
|
||||||
|
|
||||||
/** 渗透测试区:工具栏(展开详情 | N次工具执行)+ 独立工具列表 + 迭代时间线 */
|
/** 渗透测试区:工具栏(展开详情 | N次工具执行)+ 独立工具列表 + 迭代时间线 */
|
||||||
function ensureMcpCallSectionChrome(messageElement, messageId) {
|
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) {
|
async function loadConversation(conversationId) {
|
||||||
conversationId = String(conversationId || '').trim();
|
conversationId = String(conversationId || '').trim();
|
||||||
if (!conversationId) return;
|
if (!conversationId) return;
|
||||||
@@ -6458,6 +6689,11 @@ async function loadConversation(conversationId) {
|
|||||||
if (seq !== loadConversationRequestSeq) {
|
if (seq !== loadConversationRequestSeq) {
|
||||||
return;
|
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') {
|
if (currentConversationId === conversationId && typeof window.restoreHitlInlineForConversation === 'function') {
|
||||||
await window.restoreHitlInlineForConversation(conversationId);
|
await window.restoreHitlInlineForConversation(conversationId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,10 +66,12 @@ async function refreshDashboard() {
|
|||||||
setDashboardOverviewPlaceholder('…');
|
setDashboardOverviewPlaceholder('…');
|
||||||
setEl('dashboard-kpi-tools-calls', '…');
|
setEl('dashboard-kpi-tools-calls', '…');
|
||||||
setEl('dashboard-kpi-success-rate', '…');
|
setEl('dashboard-kpi-success-rate', '…');
|
||||||
|
setEl('dashboard-kpi-token-usage', '…');
|
||||||
setKpiSubText('dashboard-kpi-tasks-sub-text', '…');
|
setKpiSubText('dashboard-kpi-tasks-sub-text', '…');
|
||||||
setKpiSubText('dashboard-kpi-vuln-sub-text', '…');
|
setKpiSubText('dashboard-kpi-vuln-sub-text', '…');
|
||||||
setKpiSubText('dashboard-kpi-tools-sub-text', '…');
|
setKpiSubText('dashboard-kpi-tools-sub-text', '…');
|
||||||
setKpiSubText('dashboard-kpi-rate-sub-text', '…');
|
setKpiSubText('dashboard-kpi-rate-sub-text', '…');
|
||||||
|
setKpiSubText('dashboard-kpi-token-sub-text', '…');
|
||||||
hideEl('dashboard-kpi-vuln-critical-badge');
|
hideEl('dashboard-kpi-vuln-critical-badge');
|
||||||
hideEl('dashboard-alert-banner');
|
hideEl('dashboard-alert-banner');
|
||||||
setRecentVulnsLoading();
|
setRecentVulnsLoading();
|
||||||
@@ -127,7 +129,7 @@ async function refreshDashboard() {
|
|||||||
hitlPendingRes, notificationsRes, externalMcpStatsRes,
|
hitlPendingRes, notificationsRes, externalMcpStatsRes,
|
||||||
webshellRes,
|
webshellRes,
|
||||||
c2ListenersRes, c2SessionsRes, c2TasksRes,
|
c2ListenersRes, c2SessionsRes, c2TasksRes,
|
||||||
projectSummaryRes, severityFilteredStatsRes
|
projectSummaryRes, severityFilteredStatsRes, tokenUsageRes
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
fetchJson('/api/agent-loop/tasks'),
|
fetchJson('/api/agent-loop/tasks'),
|
||||||
fetchJson('/api/vulnerabilities/stats'),
|
fetchJson('/api/vulnerabilities/stats'),
|
||||||
@@ -159,7 +161,8 @@ async function refreshDashboard() {
|
|||||||
fetchJson(dashboardProjectScopedUrl('/api/c2/sessions?limit=500')),
|
fetchJson(dashboardProjectScopedUrl('/api/c2/sessions?limit=500')),
|
||||||
fetchJson(dashboardProjectScopedUrl('/api/c2/tasks?page=1&page_size=1')),
|
fetchJson(dashboardProjectScopedUrl('/api/c2/tasks?page=1&page_size=1')),
|
||||||
fetchJson('/api/projects/dashboard-summary?fact_limit=10'),
|
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,说明又有新刷新启动了,丢弃本次结果
|
// 如果在 await 期间 controller 已被 abort,说明又有新刷新启动了,丢弃本次结果
|
||||||
@@ -330,6 +333,8 @@ async function refreshDashboard() {
|
|||||||
renderDashboardToolsBar(null);
|
renderDashboardToolsBar(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
renderDashboardTokenUsage(tokenUsageRes);
|
||||||
|
|
||||||
// 「能力总览 → MCP 工具」用配置总数(包含未被调用过的工具);专项接口失败时回落到 monitor 的 names.length
|
// 「能力总览 → MCP 工具」用配置总数(包含未被调用过的工具);专项接口失败时回落到 monitor 的 names.length
|
||||||
if (toolsConfigRes && typeof toolsConfigRes.total === 'number') {
|
if (toolsConfigRes && typeof toolsConfigRes.total === 'number') {
|
||||||
setEl('dashboard-resource-tools', formatNumber(toolsConfigRes.total));
|
setEl('dashboard-resource-tools', formatNumber(toolsConfigRes.total));
|
||||||
@@ -435,10 +440,12 @@ async function refreshDashboard() {
|
|||||||
setDashboardOverviewPlaceholder('-');
|
setDashboardOverviewPlaceholder('-');
|
||||||
setEl('dashboard-kpi-success-rate', '-');
|
setEl('dashboard-kpi-success-rate', '-');
|
||||||
setEl('dashboard-kpi-tools-calls', '-');
|
setEl('dashboard-kpi-tools-calls', '-');
|
||||||
|
setEl('dashboard-kpi-token-usage', '-');
|
||||||
setKpiSubText('dashboard-kpi-tasks-sub-text', '-');
|
setKpiSubText('dashboard-kpi-tasks-sub-text', '-');
|
||||||
setKpiSubText('dashboard-kpi-vuln-sub-text', '-');
|
setKpiSubText('dashboard-kpi-vuln-sub-text', '-');
|
||||||
setKpiSubText('dashboard-kpi-tools-sub-text', '-');
|
setKpiSubText('dashboard-kpi-tools-sub-text', '-');
|
||||||
setKpiSubText('dashboard-kpi-rate-sub-text', '-');
|
setKpiSubText('dashboard-kpi-rate-sub-text', '-');
|
||||||
|
setKpiSubText('dashboard-kpi-token-sub-text', '-');
|
||||||
['tools', 'skills', 'knowledge', 'roles', 'agents'].forEach(function (k) {
|
['tools', 'skills', 'knowledge', 'roles', 'agents'].forEach(function (k) {
|
||||||
setEl('dashboard-resource-' + 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),
|
// sessionStorage:告警条「×」忽略记录 + 最近一次**实际展示过**的 reason 片段(不含 level),
|
||||||
// 用于在「问题从多变少」(如审完 HITL 后只剩严重漏洞)时,避免误用更早对「仅子集」的忽略。
|
// 用于在「问题从多变少」(如审完 HITL 后只剩严重漏洞)时,避免误用更早对「仅子集」的忽略。
|
||||||
var DASH_SESSION_ALERT_DISMISSED = 'dashboard.dismissedAlert';
|
var DASH_SESSION_ALERT_DISMISSED = 'dashboard.dismissedAlert';
|
||||||
|
|||||||
@@ -1044,7 +1044,7 @@ function updateAssistantBubbleContent(assistantMessageId, content, renderMarkdow
|
|||||||
const bubble = assistantElement.querySelector('.message-bubble');
|
const bubble = assistantElement.querySelector('.message-bubble');
|
||||||
if (!bubble) return;
|
if (!bubble) return;
|
||||||
|
|
||||||
// 保留复制按钮:addMessage 会把按钮 append 在 message-bubble 里
|
// 清理旧版本可能残留在气泡内的复制按钮;新版按钮统一在时间行。
|
||||||
const copyBtn = bubble.querySelector('.message-copy-btn');
|
const copyBtn = bubble.querySelector('.message-copy-btn');
|
||||||
if (copyBtn) copyBtn.remove();
|
if (copyBtn) copyBtn.remove();
|
||||||
|
|
||||||
@@ -1066,7 +1066,9 @@ function updateAssistantBubbleContent(assistantMessageId, content, renderMarkdow
|
|||||||
if (typeof wrapTablesInBubble === 'function') {
|
if (typeof wrapTablesInBubble === 'function') {
|
||||||
wrapTablesInBubble(bubble);
|
wrapTablesInBubble(bubble);
|
||||||
}
|
}
|
||||||
if (copyBtn) bubble.appendChild(copyBtn);
|
if (typeof window.appendMessageCopyButton === 'function') {
|
||||||
|
window.appendMessageCopyButton(assistantElement);
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof window.csMarkdownSanitize !== 'undefined') {
|
if (typeof window.csMarkdownSanitize !== 'undefined') {
|
||||||
window.csMarkdownSanitize.stripSuspiciousImages(bubble);
|
window.csMarkdownSanitize.stripSuspiciousImages(bubble);
|
||||||
|
|||||||
@@ -138,3 +138,17 @@ test('对话悬浮预览标题与时间分行显示并保留更多标题内容',
|
|||||||
assert.match(titleStyles, /-webkit-line-clamp: 2;/);
|
assert.match(titleStyles, /-webkit-line-clamp: 2;/);
|
||||||
assert.doesNotMatch(titleStyles, /white-space: nowrap;/);
|
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, /<rect x="3" y="11" width="18" height="10" rx="2"/);
|
||||||
|
assert.match(projects, /agent-mode-logo--' \+ getProjectConversationModeIconClass\(conversation\)/);
|
||||||
|
assert.match(styles, /\.agent-mode-logo\s*\{[\s\S]*?background: transparent;/);
|
||||||
|
assert.match(styles, /\.agent-mode-logo__svg\s*\{[\s\S]*?stroke: currentColor;[\s\S]*?stroke-width: 1\.9;/);
|
||||||
|
assert.match(styles, /\.project-conversation-preview-mode-icon\.agent-mode-logo\s*\{[\s\S]*?width: 16px;/);
|
||||||
|
assert.doesNotMatch(cssBlock(styles, '.agent-mode-logo'), /linear-gradient|box-shadow: 0 5px/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -3084,6 +3084,15 @@ function getProjectConversationModeLabel(conversation) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getProjectConversationModeIconClass(conversation) {
|
||||||
|
const mode = String(conversation?.agentMode || conversation?.agent_mode || '').trim().toLowerCase();
|
||||||
|
if (mode === 'eino_single') return 'eino';
|
||||||
|
if (mode === 'deep') return 'deep';
|
||||||
|
if (mode === 'plan_execute') return 'plan';
|
||||||
|
if (mode === 'supervisor') return 'supervisor';
|
||||||
|
return 'default';
|
||||||
|
}
|
||||||
|
|
||||||
function ensureProjectConversationPreview() {
|
function ensureProjectConversationPreview() {
|
||||||
let preview = document.getElementById('project-conversation-preview');
|
let preview = document.getElementById('project-conversation-preview');
|
||||||
if (preview) return preview;
|
if (preview) return preview;
|
||||||
@@ -3102,7 +3111,7 @@ function ensureProjectConversationPreview() {
|
|||||||
<span class="project-conversation-preview-project"></span>
|
<span class="project-conversation-preview-project"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="project-conversation-preview-meta">
|
<div class="project-conversation-preview-meta">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true"><circle cx="6" cy="5" r="2" stroke="currentColor" stroke-width="1.7"/><circle cx="6" cy="19" r="2" stroke="currentColor" stroke-width="1.7"/><circle cx="18" cy="9" r="2" stroke="currentColor" stroke-width="1.7"/><path d="M6 7v10M8 15c5 0 3-6 8-6" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>
|
<span class="project-conversation-preview-mode-icon agent-mode-logo agent-mode-logo--default" aria-hidden="true"><svg class="agent-mode-logo__svg" viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="5" r="2"/><path d="M12 7v4"/><path d="M8 16h.01"/><path d="M16 16h.01"/></svg></span>
|
||||||
<span class="project-conversation-preview-mode"></span>
|
<span class="project-conversation-preview-mode"></span>
|
||||||
<span class="project-conversation-preview-separator" aria-hidden="true">·</span>
|
<span class="project-conversation-preview-separator" aria-hidden="true">·</span>
|
||||||
<span class="project-conversation-preview-status"></span>
|
<span class="project-conversation-preview-status"></span>
|
||||||
@@ -3165,6 +3174,10 @@ function showProjectConversationPreview(conversation, project, row) {
|
|||||||
ageEl.hidden = !ageEl.textContent;
|
ageEl.hidden = !ageEl.textContent;
|
||||||
preview.querySelector('.project-conversation-preview-project').textContent = project?.name
|
preview.querySelector('.project-conversation-preview-project').textContent = project?.name
|
||||||
|| pickerMessage(tp, 'chat.conversationPreviewNoProject', '未绑定项目');
|
|| 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);
|
preview.querySelector('.project-conversation-preview-mode').textContent = getProjectConversationModeLabel(conversation);
|
||||||
statusEl.textContent = status;
|
statusEl.textContent = status;
|
||||||
statusEl.className = 'project-conversation-preview-status'
|
statusEl.className = 'project-conversation-preview-status'
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ function createHarness(nowMs) {
|
|||||||
clearInterval() {},
|
clearInterval() {},
|
||||||
};
|
};
|
||||||
vm.runInNewContext(
|
vm.runInNewContext(
|
||||||
`${timingSource(chat)}; this.setAssistantTurnTiming = setAssistantTurnTiming;`,
|
`${timingSource(chat)}; this.setAssistantTurnTiming = setAssistantTurnTiming; this.setAssistantTurnTokenUsage = setAssistantTurnTokenUsage; this.extractAssistantTurnTokenUsage = extractAssistantTurnTokenUsage;`,
|
||||||
context
|
context
|
||||||
);
|
);
|
||||||
return context;
|
return context;
|
||||||
@@ -103,6 +103,45 @@ test('已完成任务仍优先使用持久化耗时', () => {
|
|||||||
assert.match(message.label.innerHTML, /耗时 1 分钟 5 秒/);
|
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('已中断任务使用固定终态耗时且不再按当前时间增长', () => {
|
test('已中断任务使用固定终态耗时且不再按当前时间增长', () => {
|
||||||
const context = createHarness(Date.parse('2026-08-13T12:00:00.000Z'));
|
const context = createHarness(Date.parse('2026-08-13T12:00:00.000Z'));
|
||||||
const message = createMessage();
|
const message = createMessage();
|
||||||
|
|||||||
@@ -529,6 +529,16 @@
|
|||||||
<span class="dashboard-kpi-sub-text" id="dashboard-kpi-rate-sub-text" data-i18n="dashboard.healthyStatus">运行平稳</span>
|
<span class="dashboard-kpi-sub-text" id="dashboard-kpi-rate-sub-text" data-i18n="dashboard.healthyStatus">运行平稳</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="dashboard-kpi-card" role="button" tabindex="0" onclick="switchPage('chat')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('chat'); }" data-i18n="dashboard.clickToViewChat" data-i18n-attr="title" title="点击查看对话">
|
||||||
|
<div class="dashboard-kpi-head">
|
||||||
|
<div class="dashboard-kpi-label" data-i18n="dashboard.tokenUsage">Token 用量</div>
|
||||||
|
<span class="dashboard-kpi-icon dashboard-kpi-icon-tokens" aria-hidden="true"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16"/><path d="M4 12h16"/><path d="M4 17h16"/><path d="M8 3 6 21"/><path d="m18 3-2 18"/></svg></span>
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-kpi-value" id="dashboard-kpi-token-usage">-</div>
|
||||||
|
<div class="dashboard-kpi-sub">
|
||||||
|
<span class="dashboard-kpi-sub-text" id="dashboard-kpi-token-sub-text">-</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- 两列主内容区 -->
|
<!-- 两列主内容区 -->
|
||||||
<div class="dashboard-grid">
|
<div class="dashboard-grid">
|
||||||
@@ -1213,7 +1223,7 @@
|
|||||||
<div id="agent-mode-wrapper" class="agent-mode-wrapper" style="display: none;">
|
<div id="agent-mode-wrapper" class="agent-mode-wrapper" style="display: none;">
|
||||||
<div class="agent-mode-inner">
|
<div class="agent-mode-inner">
|
||||||
<button type="button" id="agent-mode-btn" class="role-selector-btn agent-mode-btn" onclick="toggleAgentModePanel()" data-i18n="chat.agentModeSelectAria" data-i18n-attr="aria-label,title" data-i18n-skip-text="true" aria-label="选择对话执行模式" aria-haspopup="listbox" aria-expanded="false" title="选择对话执行模式">
|
<button type="button" id="agent-mode-btn" class="role-selector-btn agent-mode-btn" onclick="toggleAgentModePanel()" data-i18n="chat.agentModeSelectAria" data-i18n-attr="aria-label,title" data-i18n-skip-text="true" aria-label="选择对话执行模式" aria-haspopup="listbox" aria-expanded="false" title="选择对话执行模式">
|
||||||
<span id="agent-mode-icon" class="role-selector-icon" aria-hidden="true">🤖</span>
|
<span id="agent-mode-icon" class="role-selector-icon agent-mode-logo agent-mode-logo--default" aria-hidden="true"><svg class="agent-mode-logo__svg" viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="5" r="2"/><path d="M12 7v4"/><path d="M8 16h.01"/><path d="M16 16h.01"/></svg></span>
|
||||||
<span id="agent-mode-text" class="role-selector-text">单代理</span>
|
<span id="agent-mode-text" class="role-selector-text">单代理</span>
|
||||||
<svg class="role-selector-arrow" width="10" height="10" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
<svg class="role-selector-arrow" width="10" height="10" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||||
<path d="M6 9l6 6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
<path d="M6 9l6 6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
@@ -1230,7 +1240,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="agent-mode-options">
|
<div class="agent-mode-options">
|
||||||
<button type="button" class="role-selection-item-main agent-mode-option" data-value="eino_single" role="option" onclick="selectAgentMode('eino_single')" data-agent-mode-detail="CloudWeGo Eino ChatModelAgent + Runner,MCP 工具(/api/eino-agent)" data-i18n="chat.agentModeEinoSingleHint" data-i18n-attr="data-agent-mode-detail" data-i18n-skip-text="true">
|
<button type="button" class="role-selection-item-main agent-mode-option" data-value="eino_single" role="option" onclick="selectAgentMode('eino_single')" data-agent-mode-detail="CloudWeGo Eino ChatModelAgent + Runner,MCP 工具(/api/eino-agent)" data-i18n="chat.agentModeEinoSingleHint" data-i18n-attr="data-agent-mode-detail" data-i18n-skip-text="true">
|
||||||
<div class="role-selection-item-icon-main" aria-hidden="true">⚡</div>
|
<div class="role-selection-item-icon-main agent-mode-logo agent-mode-logo--eino" aria-hidden="true"><svg class="agent-mode-logo__svg" viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="5" r="2"/><path d="M12 7v4"/><path d="M8 16h.01"/><path d="M16 16h.01"/></svg></div>
|
||||||
<div class="role-selection-item-content-main">
|
<div class="role-selection-item-content-main">
|
||||||
<div class="role-selection-item-name-main" data-i18n="chat.agentModeEinoSingle">Eino 单代理(ADK)</div>
|
<div class="role-selection-item-name-main" data-i18n="chat.agentModeEinoSingle">Eino 单代理(ADK)</div>
|
||||||
<div class="role-selection-item-description-main" data-i18n="chat.agentModeEinoSingleHint">CloudWeGo Eino ChatModelAgent + Runner,MCP 工具(/api/eino-agent)</div>
|
<div class="role-selection-item-description-main" data-i18n="chat.agentModeEinoSingleHint">CloudWeGo Eino ChatModelAgent + Runner,MCP 工具(/api/eino-agent)</div>
|
||||||
@@ -1238,7 +1248,7 @@
|
|||||||
<div class="role-selection-checkmark-main agent-mode-check" data-agent-mode-check="eino_single">✓</div>
|
<div class="role-selection-checkmark-main agent-mode-check" data-agent-mode-check="eino_single">✓</div>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="role-selection-item-main agent-mode-option" data-value="deep" role="option" onclick="selectAgentMode('deep')" data-agent-mode-detail="Eino DeepAgent,适合复杂安全测试、多阶段 task 子代理委派与汇总" data-i18n="chat.agentModeDeepHint" data-i18n-attr="data-agent-mode-detail" data-i18n-skip-text="true">
|
<button type="button" class="role-selection-item-main agent-mode-option" data-value="deep" role="option" onclick="selectAgentMode('deep')" data-agent-mode-detail="Eino DeepAgent,适合复杂安全测试、多阶段 task 子代理委派与汇总" data-i18n="chat.agentModeDeepHint" data-i18n-attr="data-agent-mode-detail" data-i18n-skip-text="true">
|
||||||
<div class="role-selection-item-icon-main" aria-hidden="true">🧩</div>
|
<div class="role-selection-item-icon-main agent-mode-logo agent-mode-logo--deep" aria-hidden="true"><svg class="agent-mode-logo__svg" viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="5" r="2"/><path d="M12 7v4"/><path d="M8 16h.01"/><path d="M16 16h.01"/></svg></div>
|
||||||
<div class="role-selection-item-content-main">
|
<div class="role-selection-item-content-main">
|
||||||
<div class="role-selection-item-name-main" data-i18n="chat.agentModeDeep">Deep(DeepAgent)</div>
|
<div class="role-selection-item-name-main" data-i18n="chat.agentModeDeep">Deep(DeepAgent)</div>
|
||||||
<div class="role-selection-item-description-main" data-i18n="chat.agentModeDeepHint">Eino DeepAgent,适合复杂安全测试、多阶段 task 子代理委派与汇总</div>
|
<div class="role-selection-item-description-main" data-i18n="chat.agentModeDeepHint">Eino DeepAgent,适合复杂安全测试、多阶段 task 子代理委派与汇总</div>
|
||||||
@@ -1246,7 +1256,7 @@
|
|||||||
<div class="role-selection-checkmark-main agent-mode-check" data-agent-mode-check="deep">✓</div>
|
<div class="role-selection-checkmark-main agent-mode-check" data-agent-mode-check="deep">✓</div>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="role-selection-item-main agent-mode-option" data-value="plan_execute" role="option" onclick="selectAgentMode('plan_execute')" data-agent-mode-detail="规划 → 执行 → 重规划(单执行器工具链)" data-i18n="chat.agentModePlanExecuteHint" data-i18n-attr="data-agent-mode-detail" data-i18n-skip-text="true">
|
<button type="button" class="role-selection-item-main agent-mode-option" data-value="plan_execute" role="option" onclick="selectAgentMode('plan_execute')" data-agent-mode-detail="规划 → 执行 → 重规划(单执行器工具链)" data-i18n="chat.agentModePlanExecuteHint" data-i18n-attr="data-agent-mode-detail" data-i18n-skip-text="true">
|
||||||
<div class="role-selection-item-icon-main" aria-hidden="true">📋</div>
|
<div class="role-selection-item-icon-main agent-mode-logo agent-mode-logo--plan" aria-hidden="true"><svg class="agent-mode-logo__svg" viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="5" r="2"/><path d="M12 7v4"/><path d="M8 16h.01"/><path d="M16 16h.01"/></svg></div>
|
||||||
<div class="role-selection-item-content-main">
|
<div class="role-selection-item-content-main">
|
||||||
<div class="role-selection-item-name-main" data-i18n="chat.agentModePlanExecuteLabel">Plan-Execute</div>
|
<div class="role-selection-item-name-main" data-i18n="chat.agentModePlanExecuteLabel">Plan-Execute</div>
|
||||||
<div class="role-selection-item-description-main" data-i18n="chat.agentModePlanExecuteHint">规划 → 执行 → 重规划(单执行器工具链)</div>
|
<div class="role-selection-item-description-main" data-i18n="chat.agentModePlanExecuteHint">规划 → 执行 → 重规划(单执行器工具链)</div>
|
||||||
@@ -1254,7 +1264,7 @@
|
|||||||
<div class="role-selection-checkmark-main agent-mode-check" data-agent-mode-check="plan_execute">✓</div>
|
<div class="role-selection-checkmark-main agent-mode-check" data-agent-mode-check="plan_execute">✓</div>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="role-selection-item-main agent-mode-option" data-value="supervisor" role="option" onclick="selectAgentMode('supervisor')" data-agent-mode-detail="专家路由场景:监督者通过 transfer 动态分派多个专业子代理" data-i18n="chat.agentModeSupervisorHint" data-i18n-attr="data-agent-mode-detail" data-i18n-skip-text="true">
|
<button type="button" class="role-selection-item-main agent-mode-option" data-value="supervisor" role="option" onclick="selectAgentMode('supervisor')" data-agent-mode-detail="专家路由场景:监督者通过 transfer 动态分派多个专业子代理" data-i18n="chat.agentModeSupervisorHint" data-i18n-attr="data-agent-mode-detail" data-i18n-skip-text="true">
|
||||||
<div class="role-selection-item-icon-main" aria-hidden="true">🎯</div>
|
<div class="role-selection-item-icon-main agent-mode-logo agent-mode-logo--supervisor" aria-hidden="true"><svg class="agent-mode-logo__svg" viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="5" r="2"/><path d="M12 7v4"/><path d="M8 16h.01"/><path d="M16 16h.01"/></svg></div>
|
||||||
<div class="role-selection-item-content-main">
|
<div class="role-selection-item-content-main">
|
||||||
<div class="role-selection-item-name-main" data-i18n="chat.agentModeSupervisorLabel">Supervisor(专家路由)</div>
|
<div class="role-selection-item-name-main" data-i18n="chat.agentModeSupervisorLabel">Supervisor(专家路由)</div>
|
||||||
<div class="role-selection-item-description-main" data-i18n="chat.agentModeSupervisorHint">专家路由场景:监督者通过 transfer 动态分派多个专业子代理</div>
|
<div class="role-selection-item-description-main" data-i18n="chat.agentModeSupervisorHint">专家路由场景:监督者通过 transfer 动态分派多个专业子代理</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user