mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-09-09 11:19:03 +02:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0a2f01427 | ||
|
|
baff533196 | ||
|
|
474238cfc5 | ||
|
|
b47f8df3b0 | ||
|
|
a67761e843 | ||
|
|
b41596d51f | ||
|
|
d80e27e950 | ||
|
|
e218316c55 | ||
|
|
a34cab431a | ||
|
|
3bcf4458c5 | ||
|
|
bf761e9cd5 | ||
|
|
d640ef09c8 | ||
|
|
d88cfea761 |
+2
-2
@@ -10,7 +10,7 @@
|
|||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
# 前端显示的版本号(可选,不填则显示默认版本)
|
# 前端显示的版本号(可选,不填则显示默认版本)
|
||||||
version: "v1.7.15"
|
version: "v1.7.17"
|
||||||
# 服务器配置
|
# 服务器配置
|
||||||
server:
|
server:
|
||||||
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
|
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
|
||||||
@@ -304,7 +304,7 @@ multi_agent:
|
|||||||
plan_execute_executed_steps_budget_ratio: 0.2 # plan_execute 中 executed_steps 预算比例
|
plan_execute_executed_steps_budget_ratio: 0.2 # plan_execute 中 executed_steps 预算比例
|
||||||
plan_execute_max_step_result_runes: 4000 # plan_execute 每步结果最大字符数(超出截断)
|
plan_execute_max_step_result_runes: 4000 # plan_execute 每步结果最大字符数(超出截断)
|
||||||
plan_execute_keep_last_steps: 8 # plan_execute 仅保留最近 N 步正文,早期步骤折叠为标题
|
plan_execute_keep_last_steps: 8 # plan_execute 仅保留最近 N 步正文,早期步骤折叠为标题
|
||||||
checkpoint_dir: data/eino-checkpoints # P0:进程崩溃/OOM 后同会话自动 ADK Resume;正常结束会删 .ckpt;与「中断并继续」(last_react_*) 是两套机制
|
checkpoint_dir: "" # 聊天链路不再使用 ADK checkpoint;跨轮模型态统一走 conversations.last_react_*,便于排查 stale context
|
||||||
model_retry_max_retries: 0 # Eino 原生 ChatModel retry;408/409/425/429/5xx/网络抖动/空流式输出会重试;0=默认 4(永久性 4xx 不重试)
|
model_retry_max_retries: 0 # Eino 原生 ChatModel retry;408/409/425/429/5xx/网络抖动/空流式输出会重试;0=默认 4(永久性 4xx 不重试)
|
||||||
model_retry_max_backoff_sec: 0 # Eino 原生 ChatModel retry 单次退避上限秒数;0=默认 30
|
model_retry_max_backoff_sec: 0 # Eino 原生 ChatModel retry 单次退避上限秒数;0=默认 30
|
||||||
model_failover_channels: [] # Eino 原生 ChatModel failover;填写 ai.channels ID,例如 [qwen-plus];retry 耗尽后按顺序切换
|
model_failover_channels: [] # Eino 原生 ChatModel failover;填写 ai.channels ID,例如 [qwen-plus];retry 耗尽后按顺序切换
|
||||||
|
|||||||
@@ -65,12 +65,6 @@ func FromRunResult(db *database.DB, result *multiagent.RunResult, in Input) Deci
|
|||||||
if len(in.MCPExecutionIDs) == 0 {
|
if len(in.MCPExecutionIDs) == 0 {
|
||||||
in.MCPExecutionIDs = result.MCPExecutionIDs
|
in.MCPExecutionIDs = result.MCPExecutionIDs
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(in.Status) == "" {
|
|
||||||
in.Status = result.Status
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(in.CompletionReason) == "" {
|
|
||||||
in.CompletionReason = result.CompletionReason
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
d := Decide(db, in)
|
d := Decide(db, in)
|
||||||
if result != nil {
|
if result != nil {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
|
|
||||||
"cyberstrike-ai/internal/database"
|
"cyberstrike-ai/internal/database"
|
||||||
"cyberstrike-ai/internal/mcp"
|
"cyberstrike-ai/internal/mcp"
|
||||||
|
"cyberstrike-ai/internal/multiagent"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
@@ -130,3 +131,23 @@ func TestDecideAllowsInformationalAnswerWhenExecutionEvidenceIsNotRequired(t *te
|
|||||||
t.Fatalf("informational response should finalize when execution evidence is not required: %+v", d)
|
t.Fatalf("informational response should finalize when execution evidence is not required: %+v", d)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFromRunResultDoesNotReusePreviousFinalizationStatusAsRunStatus(t *testing.T) {
|
||||||
|
db := newDecisionTestDB(t)
|
||||||
|
saveDecisionTestExecution(t, db, "run-slow", mcp.ToolExecutionStatusRunning)
|
||||||
|
result := &multiagent.RunResult{
|
||||||
|
Response: "工具已触发,按用户要求直接总结。",
|
||||||
|
MCPExecutionIDs: []string{"run-slow"},
|
||||||
|
}
|
||||||
|
|
||||||
|
first := FromRunResult(db, result, Input{})
|
||||||
|
if first.Finalizable || first.CompletionReason != ReasonPendingTools || result.Status != StatusInProgress {
|
||||||
|
t.Fatalf("first decision should mark pending and write metadata: decision=%+v result=%+v", first, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
saveDecisionTestExecution(t, db, "run-slow", mcp.ToolExecutionStatusCancelled)
|
||||||
|
second := FromRunResult(db, result, Input{})
|
||||||
|
if !second.Finalizable || !second.Finalized || second.Status != StatusCompleted {
|
||||||
|
t.Fatalf("second decision should ignore previous result status after pending cleanup: decision=%+v result=%+v", second, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -298,7 +298,8 @@ type MultiAgentEinoMiddlewareConfig struct {
|
|||||||
PlanExecuteMaxStepResultRunes int `yaml:"plan_execute_max_step_result_runes,omitempty" json:"plan_execute_max_step_result_runes,omitempty"`
|
PlanExecuteMaxStepResultRunes int `yaml:"plan_execute_max_step_result_runes,omitempty" json:"plan_execute_max_step_result_runes,omitempty"`
|
||||||
// PlanExecuteKeepLastSteps keeps only the tail steps in prompt view (default 8).
|
// PlanExecuteKeepLastSteps keeps only the tail steps in prompt view (default 8).
|
||||||
PlanExecuteKeepLastSteps int `yaml:"plan_execute_keep_last_steps,omitempty" json:"plan_execute_keep_last_steps,omitempty"`
|
PlanExecuteKeepLastSteps int `yaml:"plan_execute_keep_last_steps,omitempty" json:"plan_execute_keep_last_steps,omitempty"`
|
||||||
// CheckpointDir when non-empty enables adk.Runner CheckPointStore (file-backed) for interrupt/resume persistence.
|
// CheckpointDir is retained for config compatibility. Chat agent runs do
|
||||||
|
// not consume it; cross-turn recovery is centralized in conversations.last_react_*.
|
||||||
CheckpointDir string `yaml:"checkpoint_dir,omitempty" json:"checkpoint_dir,omitempty"`
|
CheckpointDir string `yaml:"checkpoint_dir,omitempty" json:"checkpoint_dir,omitempty"`
|
||||||
// DeepOutputKey passed to deep.Config OutputKey (session final text); empty = off.
|
// DeepOutputKey passed to deep.Config OutputKey (session final text); empty = off.
|
||||||
DeepOutputKey string `yaml:"deep_output_key,omitempty" json:"deep_output_key,omitempty"`
|
DeepOutputKey string `yaml:"deep_output_key,omitempty" json:"deep_output_key,omitempty"`
|
||||||
@@ -959,13 +960,12 @@ func (c OpenAIConfig) MaxCompletionTokensEffective() int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// IsDeepSeekEndpointOrModel reports whether the channel targets DeepSeek's
|
// IsDeepSeekEndpointOrModel reports whether the channel targets DeepSeek's
|
||||||
// official-compatible API or a DeepSeek model family. This is separate from the
|
// official-compatible API endpoint. The historical name is kept for compatibility;
|
||||||
// reasoning profile: profile controls field mapping, while DeepSeek has provider
|
// model names alone are not enough to infer DeepSeek wire behavior behind
|
||||||
// constraints such as default thinking mode and no tool_choice in thinking mode.
|
// OpenAI-compatible gateways.
|
||||||
func (c OpenAIConfig) IsDeepSeekEndpointOrModel() bool {
|
func (c OpenAIConfig) IsDeepSeekEndpointOrModel() bool {
|
||||||
baseURL := strings.ToLower(strings.TrimSpace(c.BaseURL))
|
baseURL := strings.ToLower(strings.TrimSpace(c.BaseURL))
|
||||||
model := strings.ToLower(strings.TrimSpace(c.Model))
|
return strings.Contains(baseURL, "deepseek")
|
||||||
return strings.Contains(baseURL, "deepseek") || strings.Contains(model, "deepseek")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenAIReasoningConfig 全局默认与网关 profile(对话页可通过 ChatRequest.reasoning 覆盖,受 AllowClientReasoning 约束)。
|
// OpenAIReasoningConfig 全局默认与网关 profile(对话页可通过 ChatRequest.reasoning 覆盖,受 AllowClientReasoning 约束)。
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1538,6 +1540,11 @@ LIMIT 1`, messageID).Scan(&terminalEvent, &terminalCreatedAt)
|
|||||||
return nil, fmt.Errorf("统计工具调用详情失败: %w", err)
|
return nil, fmt.Errorf("统计工具调用详情失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pendingToolStatus := "result_missing"
|
||||||
|
if summary.Status == "running" {
|
||||||
|
pendingToolStatus = "running"
|
||||||
|
}
|
||||||
|
|
||||||
execRows, err := db.Query(
|
execRows, err := db.Query(
|
||||||
"SELECT id, event_type, data FROM process_details WHERE message_id = ? AND event_type IN ('tool_call', 'tool_result') ORDER BY created_at ASC, rowid ASC",
|
"SELECT id, event_type, data FROM process_details WHERE message_id = ? AND event_type IN ('tool_call', 'tool_result') ORDER BY created_at ASC, rowid ASC",
|
||||||
messageID,
|
messageID,
|
||||||
@@ -1578,10 +1585,10 @@ LIMIT 1`, messageID).Scan(&terminalEvent, &terminalCreatedAt)
|
|||||||
ProcessDetailID: strings.TrimSpace(detailID),
|
ProcessDetailID: strings.TrimSpace(detailID),
|
||||||
ToolName: toolName,
|
ToolName: toolName,
|
||||||
ToolCallID: toolCallID,
|
ToolCallID: toolCallID,
|
||||||
// This summary is reconstructed from persisted history, not live
|
// This summary is reconstructed from persisted history. For an
|
||||||
// execution state. Until a matching result is found the honest state
|
// active assistant turn, a missing result means the call is still
|
||||||
// is "result_missing", never "running".
|
// pending; after the turn is terminal it is genuinely incomplete.
|
||||||
Status: "result_missing",
|
Status: pendingToolStatus,
|
||||||
})
|
})
|
||||||
matchedToolIndexes = append(matchedToolIndexes, false)
|
matchedToolIndexes = append(matchedToolIndexes, false)
|
||||||
if toolCallID != "" {
|
if toolCallID != "" {
|
||||||
@@ -1636,6 +1643,7 @@ LIMIT 1`, messageID).Scan(&terminalEvent, &terminalCreatedAt)
|
|||||||
return nil, fmt.Errorf("遍历工具执行摘要失败: %w", err)
|
return nil, fmt.Errorf("遍历工具执行摘要失败: %w", err)
|
||||||
}
|
}
|
||||||
execRows.Close()
|
execRows.Close()
|
||||||
|
db.applyPersistedToolExecutionStatuses(summary.ToolExecutions)
|
||||||
|
|
||||||
rows, err := db.Query(
|
rows, err := db.Query(
|
||||||
"SELECT data FROM process_details WHERE message_id = ? AND event_type = 'iteration' ORDER BY created_at ASC, rowid ASC",
|
"SELECT data FROM process_details WHERE message_id = ? AND event_type = 'iteration' ORDER BY created_at ASC, rowid ASC",
|
||||||
@@ -1704,6 +1712,24 @@ func toolResultStatusFromPayload(payload map[string]interface{}, eventType strin
|
|||||||
return "completed"
|
return "completed"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (db *DB) applyPersistedToolExecutionStatuses(executions []ProcessDetailsToolExecution) {
|
||||||
|
for i := range executions {
|
||||||
|
execID := strings.TrimSpace(executions[i].ExecutionID)
|
||||||
|
if execID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var status string
|
||||||
|
if err := db.QueryRow(`SELECT status FROM tool_executions WHERE id = ?`, execID).Scan(&status); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
status = strings.ToLower(strings.TrimSpace(status))
|
||||||
|
if status == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
executions[i].Status = status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func matchToolExecutionIndex(
|
func matchToolExecutionIndex(
|
||||||
executions []ProcessDetailsToolExecution,
|
executions []ProcessDetailsToolExecution,
|
||||||
matched []bool,
|
matched []bool,
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -165,6 +165,32 @@ func TestProcessDetailsSummaryDoesNotReportPersistedOrphanAsRunning(t *testing.T
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestProcessDetailsSummaryReportsUnmatchedToolCallAsRunningForActiveTurn(t *testing.T) {
|
||||||
|
db, conversationID, messageID := setupProcessDetailsSummaryTest(t)
|
||||||
|
if _, err := db.Exec(
|
||||||
|
"UPDATE messages SET content = ?, updated_at = ? WHERE id = ?",
|
||||||
|
"处理中...", "2026-08-10T08:00:00Z", messageID,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("update running message: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.AddProcessDetail(messageID, conversationID, "tool_call", "call", map[string]interface{}{
|
||||||
|
"toolName": "execute", "toolCallId": "pending",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("AddProcessDetail(tool_call): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
summary, err := db.GetProcessDetailsSummary(messageID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetProcessDetailsSummary: %v", err)
|
||||||
|
}
|
||||||
|
if summary.Status != "running" {
|
||||||
|
t.Fatalf("summary status = %q, want running", summary.Status)
|
||||||
|
}
|
||||||
|
if len(summary.ToolExecutions) != 1 || summary.ToolExecutions[0].Status != "running" {
|
||||||
|
t.Fatalf("tool executions = %#v, want running", summary.ToolExecutions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestProcessDetailsSummaryIncludesPersistedTurnTiming(t *testing.T) {
|
func TestProcessDetailsSummaryIncludesPersistedTurnTiming(t *testing.T) {
|
||||||
db, _, messageID := setupProcessDetailsSummaryTest(t)
|
db, _, messageID := setupProcessDetailsSummaryTest(t)
|
||||||
startedAt := "2026-08-10T08:00:00Z"
|
startedAt := "2026-08-10T08:00:00Z"
|
||||||
|
|||||||
+22
-16
@@ -698,18 +698,19 @@ func (h *AgentHandler) mergeAssistantMessagePartialOnCancel(messageID, partial s
|
|||||||
|
|
||||||
// ChatResponse 聊天响应
|
// ChatResponse 聊天响应
|
||||||
type ChatResponse struct {
|
type ChatResponse struct {
|
||||||
Response string `json:"response"`
|
Response string `json:"response"`
|
||||||
MCPExecutionIDs []string `json:"mcpExecutionIds,omitempty"` // 本次对话中执行的MCP调用ID列表
|
MCPExecutionIDs []string `json:"mcpExecutionIds,omitempty"` // 本次对话中执行的MCP调用ID列表
|
||||||
ConversationID string `json:"conversationId"` // 对话ID
|
ConversationID string `json:"conversationId"` // 对话ID
|
||||||
Time time.Time `json:"time"`
|
Time time.Time `json:"time"`
|
||||||
Finalizable bool `json:"finalizable"`
|
Finalizable bool `json:"finalizable"`
|
||||||
Finalized bool `json:"finalized"`
|
Finalized bool `json:"finalized"`
|
||||||
Status string `json:"status,omitempty"`
|
Status string `json:"status,omitempty"`
|
||||||
CompletionReason string `json:"completionReason,omitempty"`
|
CompletionReason string `json:"completionReason,omitempty"`
|
||||||
EvidenceVerified bool `json:"evidenceVerified"`
|
EvidenceVerified bool `json:"evidenceVerified"`
|
||||||
EvidenceRefs []string `json:"evidenceRefs,omitempty"`
|
EvidenceRefs []string `json:"evidenceRefs,omitempty"`
|
||||||
PendingExecutionIDs []string `json:"pendingExecutionIds,omitempty"`
|
PendingExecutionIDs []string `json:"pendingExecutionIds,omitempty"`
|
||||||
MissingChecks []string `json:"missingChecks,omitempty"`
|
MissingChecks []string `json:"missingChecks,omitempty"`
|
||||||
|
AutoCancelledPendingExecutionIDs []string `json:"autoCancelledPendingExecutionIds,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AgentHandler) finalizeRobotAgentError(ctx context.Context, assistantMessageID, conversationID string, resultMA *multiagent.RunResult, errMA error) (string, string, error) {
|
func (h *AgentHandler) finalizeRobotAgentError(ctx context.Context, assistantMessageID, conversationID string, resultMA *multiagent.RunResult, errMA error) (string, string, error) {
|
||||||
@@ -724,8 +725,13 @@ func (h *AgentHandler) finalizeRobotAgentError(ctx context.Context, assistantMes
|
|||||||
return "", conversationID, errMA
|
return "", conversationID, errMA
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AgentHandler) finalizeRobotAgentSuccess(assistantMessageID, conversationID string, resultMA *multiagent.RunResult) (string, string, error) {
|
func (h *AgentHandler) finalizeRobotAgentSuccess(taskCtx context.Context, assistantMessageID, conversationID string, resultMA *multiagent.RunResult) (string, string, error) {
|
||||||
decision := h.finalizeAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, "robot", resultMA, resultMA.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(resultMA.LastAgentTraceInput), true)
|
reasoningContent := multiagent.AggregatedReasoningFromTraceJSON(resultMA.LastAgentTraceInput)
|
||||||
|
decision := h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, "robot", resultMA, resultMA.MCPExecutionIDs, true)
|
||||||
|
if cancelled := h.cleanupPendingToolExecutionsAfterIteration(taskCtx, conversationID, decision, nil); len(cancelled) > 0 {
|
||||||
|
decision = h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, "robot", resultMA, resultMA.MCPExecutionIDs, true)
|
||||||
|
}
|
||||||
|
h.persistFinalizationDecision(conversationID, assistantMessageID, "robot", resultMA.MCPExecutionIDs, reasoningContent, decision)
|
||||||
responseText := decision.FinalText
|
responseText := decision.FinalText
|
||||||
if !decision.Finalizable {
|
if !decision.Finalizable {
|
||||||
responseText = finalizationBlockedMessage(decision)
|
responseText = finalizationBlockedMessage(decision)
|
||||||
@@ -758,7 +764,7 @@ func (h *AgentHandler) runRobotEinoSingleWithRetry(
|
|||||||
*taskStatus = "failed"
|
*taskStatus = "failed"
|
||||||
return h.finalizeRobotAgentError(taskCtx, assistantMessageID, conversationID, resultMA, errMA)
|
return h.finalizeRobotAgentError(taskCtx, assistantMessageID, conversationID, resultMA, errMA)
|
||||||
}
|
}
|
||||||
return h.finalizeRobotAgentSuccess(assistantMessageID, conversationID, resultMA)
|
return h.finalizeRobotAgentSuccess(taskCtx, assistantMessageID, conversationID, resultMA)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AgentHandler) runRobotMultiAgentWithRetry(
|
func (h *AgentHandler) runRobotMultiAgentWithRetry(
|
||||||
@@ -779,7 +785,7 @@ func (h *AgentHandler) runRobotMultiAgentWithRetry(
|
|||||||
*taskStatus = "failed"
|
*taskStatus = "failed"
|
||||||
return h.finalizeRobotAgentError(taskCtx, assistantMessageID, conversationID, resultMA, errMA)
|
return h.finalizeRobotAgentError(taskCtx, assistantMessageID, conversationID, resultMA, errMA)
|
||||||
}
|
}
|
||||||
return h.finalizeRobotAgentSuccess(assistantMessageID, conversationID, resultMA)
|
return h.finalizeRobotAgentSuccess(taskCtx, assistantMessageID, conversationID, resultMA)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProcessMessageForRobot 供机器人(企业微信/钉钉/飞书)调用:Eino 单/多代理执行路径(含 progressCallback、过程详情),仅不发送 SSE,最后返回完整回复
|
// ProcessMessageForRobot 供机器人(企业微信/钉钉/飞书)调用:Eino 单/多代理执行路径(含 progressCallback、过程详情),仅不发送 SSE,最后返回完整回复
|
||||||
|
|||||||
@@ -281,7 +281,12 @@ func (h *AgentHandler) executeOneBatchSubTask(queueID string, queue *BatchTaskQu
|
|||||||
if useBatchMulti {
|
if useBatchMulti {
|
||||||
agentMode = "batch_eino_" + batchOrch
|
agentMode = "batch_eino_" + batchOrch
|
||||||
}
|
}
|
||||||
decision := h.finalizeAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, agentMode, resultMA, mcpIDs, reasoningContent, true)
|
decision := h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, agentMode, resultMA, mcpIDs, true)
|
||||||
|
autoCancelledPendingExecutionIDs := h.cleanupPendingToolExecutionsAfterIteration(taskCtx, conversationID, decision, progressCallback)
|
||||||
|
if len(autoCancelledPendingExecutionIDs) > 0 {
|
||||||
|
decision = h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, agentMode, resultMA, mcpIDs, true)
|
||||||
|
}
|
||||||
|
h.persistFinalizationDecision(conversationID, assistantMessageID, agentMode, mcpIDs, reasoningContent, decision)
|
||||||
resText := decision.FinalText
|
resText := decision.FinalText
|
||||||
if !decision.Finalizable {
|
if !decision.Finalizable {
|
||||||
resText = finalizationBlockedMessage(decision)
|
resText = finalizationBlockedMessage(decision)
|
||||||
@@ -289,14 +294,15 @@ func (h *AgentHandler) executeOneBatchSubTask(queueID string, queue *BatchTaskQu
|
|||||||
sendEvent("finalization_check", resText, decision)
|
sendEvent("finalization_check", resText, decision)
|
||||||
}
|
}
|
||||||
sendEvent("response", resText, finalizationResponsePayload(decision, map[string]interface{}{
|
sendEvent("response", resText, finalizationResponsePayload(decision, map[string]interface{}{
|
||||||
"conversationId": conversationID,
|
"conversationId": conversationID,
|
||||||
"messageId": assistantMessageID,
|
"messageId": assistantMessageID,
|
||||||
"agentMode": agentMode,
|
"agentMode": agentMode,
|
||||||
"mcpExecutionIds": mcpIDs,
|
"mcpExecutionIds": mcpIDs,
|
||||||
"batchQueueId": queueID,
|
"batchQueueId": queueID,
|
||||||
"batchTaskId": task.ID,
|
"batchTaskId": task.ID,
|
||||||
"batchTaskStatus": map[bool]string{true: string(BatchTaskStatusCompleted), false: string(BatchTaskStatusFailed)}[decision.Finalizable],
|
"batchTaskStatus": map[bool]string{true: string(BatchTaskStatusCompleted), false: string(BatchTaskStatusFailed)}[decision.Finalizable],
|
||||||
"candidatePreview": safeTruncateString(resultMA.Response, 500),
|
"candidatePreview": safeTruncateString(resultMA.Response, 500),
|
||||||
|
"autoCancelledPendingExecutionIds": autoCancelledPendingExecutionIDs,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
if assistantMessageID == "" {
|
if assistantMessageID == "" {
|
||||||
|
|||||||
@@ -76,6 +76,65 @@ func TestProcessDetailsPageIncludesTerminalToolStatusAcrossPageBoundary(t *testi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestProcessDetailsPageUsesPersistedExecutionStatusAfterBackgroundCancel(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
db, err := database.NewDB(filepath.Join(t.TempDir(), "process-details-cancelled.db"), zap.NewNop())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewDB: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
conversation, err := db.CreateConversation("cancelled background", database.ConversationCreateMeta{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateConversation: %v", err)
|
||||||
|
}
|
||||||
|
message, err := db.AddMessage(conversation.ID, "assistant", "done", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AddMessage: %v", err)
|
||||||
|
}
|
||||||
|
execID := "exec-cancelled-after-background"
|
||||||
|
if err := db.AddProcessDetail(message.ID, conversation.ID, "tool_call", "call", map[string]interface{}{
|
||||||
|
"toolName": "exec", "toolCallId": "call-cancelled", "index": 1, "total": 1,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("AddProcessDetail(tool_call): %v", err)
|
||||||
|
}
|
||||||
|
if err := db.AddProcessDetail(message.ID, conversation.ID, "tool_result", "background", map[string]interface{}{
|
||||||
|
"toolName": "exec", "toolCallId": "call-cancelled", "executionId": execID, "status": "background_running", "success": true,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("AddProcessDetail(tool_result): %v", err)
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
if err := db.SaveToolExecution(&mcp.ToolExecution{
|
||||||
|
ID: execID,
|
||||||
|
ToolName: "exec",
|
||||||
|
Status: mcp.ToolExecutionStatusCancelled,
|
||||||
|
StartTime: now,
|
||||||
|
EndTime: &now,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("SaveToolExecution: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(w)
|
||||||
|
c.Request = httptest.NewRequest("GET", "/api/messages/"+message.ID+"/process-details?limit=10&offset=0", nil)
|
||||||
|
c.Params = gin.Params{{Key: "id", Value: message.ID}}
|
||||||
|
NewConversationHandler(db, zap.NewNop()).GetMessageProcessDetails(c)
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatalf("status = %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var response struct {
|
||||||
|
ToolExecutions []database.ProcessDetailsToolExecution `json:"toolExecutions"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
if len(response.ToolExecutions) != 1 {
|
||||||
|
t.Fatalf("tool executions = %d, want 1", len(response.ToolExecutions))
|
||||||
|
}
|
||||||
|
if got := response.ToolExecutions[0].Status; got != mcp.ToolExecutionStatusCancelled {
|
||||||
|
t.Fatalf("tool execution status = %q, want cancelled", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestProcessDetailsFullBackfillsEmptyToolCallArgumentsFromExecution(t *testing.T) {
|
func TestProcessDetailsFullBackfillsEmptyToolCallArgumentsFromExecution(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
db, err := database.NewDB(filepath.Join(t.TempDir(), "process-details-args.db"), zap.NewNop())
|
db, err := database.NewDB(filepath.Join(t.TempDir(), "process-details-args.db"), zap.NewNop())
|
||||||
|
|||||||
@@ -192,6 +192,7 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
|||||||
var emptyResponseContinueAttempt int
|
var emptyResponseContinueAttempt int
|
||||||
var finalizationAutoContinueAttempt int
|
var finalizationAutoContinueAttempt int
|
||||||
var decision agentfinalizer.Decision
|
var decision agentfinalizer.Decision
|
||||||
|
var autoCancelledPendingExecutionIDs []string
|
||||||
|
|
||||||
for {
|
for {
|
||||||
segmentMainIterationMax := 0
|
segmentMainIterationMax := 0
|
||||||
@@ -268,6 +269,10 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
decision = h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, "eino_single", result, cumulativeMCPExecutionIDs, requestRequiresExecutionEvidence(&req))
|
decision = h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, "eino_single", result, cumulativeMCPExecutionIDs, requestRequiresExecutionEvidence(&req))
|
||||||
|
if cancelled := h.cleanupPendingToolExecutionsAfterIteration(taskCtx, conversationID, decision, progressCallback); len(cancelled) > 0 {
|
||||||
|
autoCancelledPendingExecutionIDs = mergeMCPExecutionIDLists(autoCancelledPendingExecutionIDs, cancelled)
|
||||||
|
decision = h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, "eino_single", result, cumulativeMCPExecutionIDs, requestRequiresExecutionEvidence(&req))
|
||||||
|
}
|
||||||
if h.tryAutoContinueAfterFinalization(taskCtx, conversationID, result, decision, &finalizationAutoContinueAttempt, &curHistory, &curFinalMessage, progressCallback) {
|
if h.tryAutoContinueAfterFinalization(taskCtx, conversationID, result, decision, &finalizationAutoContinueAttempt, &curHistory, &curFinalMessage, progressCallback) {
|
||||||
mainIterationOffset += segmentMainIterationMax
|
mainIterationOffset += segmentMainIterationMax
|
||||||
timeoutCancel()
|
timeoutCancel()
|
||||||
@@ -384,6 +389,10 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
|||||||
|
|
||||||
if decision.CompletionReason == "" {
|
if decision.CompletionReason == "" {
|
||||||
decision = h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, "eino_single", result, cumulativeMCPExecutionIDs, requestRequiresExecutionEvidence(&req))
|
decision = h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, "eino_single", result, cumulativeMCPExecutionIDs, requestRequiresExecutionEvidence(&req))
|
||||||
|
if cancelled := h.cleanupPendingToolExecutionsAfterIteration(taskCtx, conversationID, decision, nil); len(cancelled) > 0 {
|
||||||
|
autoCancelledPendingExecutionIDs = mergeMCPExecutionIDLists(autoCancelledPendingExecutionIDs, cancelled)
|
||||||
|
decision = h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, "eino_single", result, cumulativeMCPExecutionIDs, requestRequiresExecutionEvidence(&req))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
h.persistFinalizationDecision(conversationID, assistantMessageID, "eino_single", cumulativeMCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput), decision)
|
h.persistFinalizationDecision(conversationID, assistantMessageID, "eino_single", cumulativeMCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput), decision)
|
||||||
|
|
||||||
@@ -401,10 +410,11 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
|||||||
h.tasks.UpdateTaskStatus(conversationID, taskStatus)
|
h.tasks.UpdateTaskStatus(conversationID, taskStatus)
|
||||||
}
|
}
|
||||||
sendEvent("response", responseText, finalizationResponsePayload(decision, map[string]interface{}{
|
sendEvent("response", responseText, finalizationResponsePayload(decision, map[string]interface{}{
|
||||||
"mcpExecutionIds": cumulativeMCPExecutionIDs,
|
"mcpExecutionIds": cumulativeMCPExecutionIDs,
|
||||||
"conversationId": conversationID,
|
"conversationId": conversationID,
|
||||||
"messageId": assistantMessageID,
|
"messageId": assistantMessageID,
|
||||||
"agentMode": "eino_single",
|
"agentMode": "eino_single",
|
||||||
|
"autoCancelledPendingExecutionIds": autoCancelledPendingExecutionIDs,
|
||||||
}))
|
}))
|
||||||
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
||||||
}
|
}
|
||||||
@@ -464,6 +474,7 @@ func (h *AgentHandler) EinoSingleAgentLoop(c *gin.Context) {
|
|||||||
var emptyResponseContinueAttempt int
|
var emptyResponseContinueAttempt int
|
||||||
var finalizationAutoContinueAttempt int
|
var finalizationAutoContinueAttempt int
|
||||||
var decision agentfinalizer.Decision
|
var decision agentfinalizer.Decision
|
||||||
|
var autoCancelledPendingExecutionIDs []string
|
||||||
for {
|
for {
|
||||||
result, runErr = multiagent.RunEinoSingleChatModelAgent(
|
result, runErr = multiagent.RunEinoSingleChatModelAgent(
|
||||||
taskCtx,
|
taskCtx,
|
||||||
@@ -493,6 +504,10 @@ func (h *AgentHandler) EinoSingleAgentLoop(c *gin.Context) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
decision = h.decideAgentRunForDeliveryWithPolicy(prep.ConversationID, prep.AssistantMessageID, "eino_single", result, result.MCPExecutionIDs, requestRequiresExecutionEvidence(&req))
|
decision = h.decideAgentRunForDeliveryWithPolicy(prep.ConversationID, prep.AssistantMessageID, "eino_single", result, result.MCPExecutionIDs, requestRequiresExecutionEvidence(&req))
|
||||||
|
if cancelled := h.cleanupPendingToolExecutionsAfterIteration(taskCtx, prep.ConversationID, decision, progressCallback); len(cancelled) > 0 {
|
||||||
|
autoCancelledPendingExecutionIDs = mergeMCPExecutionIDLists(autoCancelledPendingExecutionIDs, cancelled)
|
||||||
|
decision = h.decideAgentRunForDeliveryWithPolicy(prep.ConversationID, prep.AssistantMessageID, "eino_single", result, result.MCPExecutionIDs, requestRequiresExecutionEvidence(&req))
|
||||||
|
}
|
||||||
if h.tryAutoContinueAfterFinalization(taskCtx, prep.ConversationID, result, decision, &finalizationAutoContinueAttempt, &curHist, &curMsg, progressCallback) {
|
if h.tryAutoContinueAfterFinalization(taskCtx, prep.ConversationID, result, decision, &finalizationAutoContinueAttempt, &curHist, &curMsg, progressCallback) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -509,18 +524,19 @@ func (h *AgentHandler) EinoSingleAgentLoop(c *gin.Context) {
|
|||||||
responseText = finalizationBlockedMessage(decision)
|
responseText = finalizationBlockedMessage(decision)
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"response": responseText,
|
"response": responseText,
|
||||||
"conversationId": prep.ConversationID,
|
"conversationId": prep.ConversationID,
|
||||||
"mcpExecutionIds": result.MCPExecutionIDs,
|
"mcpExecutionIds": result.MCPExecutionIDs,
|
||||||
"assistantMessageId": prep.AssistantMessageID,
|
"assistantMessageId": prep.AssistantMessageID,
|
||||||
"agentMode": "eino_single",
|
"agentMode": "eino_single",
|
||||||
"finalized": decision.Finalized,
|
"finalized": decision.Finalized,
|
||||||
"finalizable": decision.Finalizable,
|
"finalizable": decision.Finalizable,
|
||||||
"status": decision.Status,
|
"status": decision.Status,
|
||||||
"completionReason": decision.CompletionReason,
|
"completionReason": decision.CompletionReason,
|
||||||
"evidenceVerified": decision.EvidenceVerified,
|
"evidenceVerified": decision.EvidenceVerified,
|
||||||
"evidenceRefs": decision.EvidenceRefs,
|
"evidenceRefs": decision.EvidenceRefs,
|
||||||
"pendingExecutionIds": decision.PendingExecutionIDs,
|
"pendingExecutionIds": decision.PendingExecutionIDs,
|
||||||
"missingChecks": decision.MissingChecks,
|
"missingChecks": decision.MissingChecks,
|
||||||
|
"autoCancelledPendingExecutionIds": autoCancelledPendingExecutionIDs,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,16 +2,21 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"cyberstrike-ai/internal/agent"
|
"cyberstrike-ai/internal/agent"
|
||||||
"cyberstrike-ai/internal/agentfinalizer"
|
"cyberstrike-ai/internal/agentfinalizer"
|
||||||
|
"cyberstrike-ai/internal/mcp"
|
||||||
"cyberstrike-ai/internal/multiagent"
|
"cyberstrike-ai/internal/multiagent"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
const finalizationAutoContinueMaxAttempts = 2
|
const finalizationAutoContinueMaxAttempts = 2
|
||||||
|
const finalizationPendingToolCancelWait = 2 * time.Second
|
||||||
|
const finalizationPendingToolCancelPoll = 50 * time.Millisecond
|
||||||
|
const finalizationPendingToolCancelNote = "Agent 迭代已结束,最终回复前自动终止未完成的工具执行"
|
||||||
|
|
||||||
func shouldAutoContinueAfterFinalization(d agentfinalizer.Decision, attempt int) bool {
|
func shouldAutoContinueAfterFinalization(d agentfinalizer.Decision, attempt int) bool {
|
||||||
if d.Finalizable || d.Finalized {
|
if d.Finalizable || d.Finalized {
|
||||||
@@ -75,3 +80,105 @@ func finalizationAutoContinueBackoff(attempt int) time.Duration {
|
|||||||
}
|
}
|
||||||
return time.Duration(attempt) * time.Second
|
return time.Duration(attempt) * time.Second
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *AgentHandler) cleanupPendingToolExecutionsAfterIteration(
|
||||||
|
taskCtx context.Context,
|
||||||
|
conversationID string,
|
||||||
|
decision agentfinalizer.Decision,
|
||||||
|
progressCallback func(eventType, message string, data interface{}),
|
||||||
|
) []string {
|
||||||
|
if h == nil || h.agent == nil || decision.CompletionReason != agentfinalizer.ReasonPendingTools {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
pending := uniqueNonEmptyStrings(decision.PendingExecutionIDs)
|
||||||
|
if len(pending) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cancelled := make([]string, 0, len(pending))
|
||||||
|
for _, executionID := range pending {
|
||||||
|
if h.agent.CancelMCPToolExecutionWithNote(executionID, finalizationPendingToolCancelNote) {
|
||||||
|
cancelled = append(cancelled, executionID)
|
||||||
|
} else if h.logger != nil {
|
||||||
|
h.logger.Warn("finalization pending tool cleanup could not cancel execution",
|
||||||
|
zap.String("conversationId", conversationID),
|
||||||
|
zap.String("executionId", executionID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(cancelled) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if progressCallback != nil {
|
||||||
|
progressCallback("finalization_pending_tools_cancelled", "迭代结束,已自动终止仍在运行的工具执行。", map[string]interface{}{
|
||||||
|
"conversationId": conversationID,
|
||||||
|
"source": "finalizer",
|
||||||
|
"autoCancelledPendingExecutionIds": cancelled,
|
||||||
|
"pendingExecutionIds": pending,
|
||||||
|
"reason": agentfinalizer.ReasonPendingTools,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
h.waitForToolExecutionsToLeavePending(taskCtx, cancelled, finalizationPendingToolCancelWait)
|
||||||
|
return cancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AgentHandler) waitForToolExecutionsToLeavePending(ctx context.Context, executionIDs []string, wait time.Duration) {
|
||||||
|
if h == nil || h.db == nil || len(executionIDs) == 0 || wait <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
timer := time.NewTimer(wait)
|
||||||
|
defer timer.Stop()
|
||||||
|
ticker := time.NewTicker(finalizationPendingToolCancelPoll)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
if !h.hasPendingToolExecutions(executionIDs) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-contextDone(ctx):
|
||||||
|
return
|
||||||
|
case <-timer.C:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AgentHandler) hasPendingToolExecutions(executionIDs []string) bool {
|
||||||
|
if h == nil || h.db == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, executionID := range uniqueNonEmptyStrings(executionIDs) {
|
||||||
|
exec, err := h.db.GetToolExecution(executionID)
|
||||||
|
if err != nil || exec == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch strings.TrimSpace(exec.Status) {
|
||||||
|
case mcp.ToolExecutionStatusQueued, mcp.ToolExecutionStatusRunning:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func uniqueNonEmptyStrings(values []string) []string {
|
||||||
|
seen := make(map[string]struct{}, len(values))
|
||||||
|
out := make([]string, 0, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[value]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[value] = struct{}{}
|
||||||
|
out = append(out, value)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func contextDone(ctx context.Context) <-chan struct{} {
|
||||||
|
if ctx == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return ctx.Done()
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
agentpkg "cyberstrike-ai/internal/agent"
|
||||||
"cyberstrike-ai/internal/agentfinalizer"
|
"cyberstrike-ai/internal/agentfinalizer"
|
||||||
|
"cyberstrike-ai/internal/config"
|
||||||
|
"cyberstrike-ai/internal/database"
|
||||||
|
"cyberstrike-ai/internal/mcp"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestShouldAutoContinueAfterFinalization(t *testing.T) {
|
func TestShouldAutoContinueAfterFinalization(t *testing.T) {
|
||||||
@@ -57,3 +66,66 @@ func TestRequestRequiresExecutionEvidenceUsesExplicitPolicyOnly(t *testing.T) {
|
|||||||
t.Fatal("explicit false policy should not require execution evidence")
|
t.Fatal("explicit false policy should not require execution evidence")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCleanupPendingToolExecutionsAfterIterationAllowsFinalization(t *testing.T) {
|
||||||
|
logger := zap.NewNop()
|
||||||
|
db, err := database.NewDB(filepath.Join(t.TempDir(), "cleanup-finalization.db"), logger)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewDB: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
|
||||||
|
server := mcp.NewServerWithStorage(logger, db)
|
||||||
|
server.ConfigureToolWaitTimeoutSeconds(1)
|
||||||
|
server.RegisterTool(mcp.Tool{Name: "block", InputSchema: map[string]interface{}{"type": "object"}}, func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
|
||||||
|
<-ctx.Done()
|
||||||
|
return nil, ctx.Err()
|
||||||
|
})
|
||||||
|
ag := agentpkg.NewAgent(&config.OpenAIConfig{}, &config.AgentConfig{}, server, nil, logger, 10)
|
||||||
|
h := &AgentHandler{agent: ag, db: db, logger: logger}
|
||||||
|
|
||||||
|
callCtx := mcp.WithMCPConversationID(context.Background(), "conv-cleanup")
|
||||||
|
result, execID, err := server.CallTool(callCtx, "block", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CallTool: %v", err)
|
||||||
|
}
|
||||||
|
if result == nil || !result.IsError || execID == "" {
|
||||||
|
t.Fatalf("expected background wait result, result=%#v execID=%q", result, execID)
|
||||||
|
}
|
||||||
|
|
||||||
|
decision := agentfinalizer.Decide(db, agentfinalizer.Input{
|
||||||
|
Response: "基于已完成信息的阶段性总结。",
|
||||||
|
MCPExecutionIDs: []string{execID},
|
||||||
|
})
|
||||||
|
if decision.CompletionReason != agentfinalizer.ReasonPendingTools {
|
||||||
|
t.Fatalf("decision reason = %s, want pending tools: %+v", decision.CompletionReason, decision)
|
||||||
|
}
|
||||||
|
|
||||||
|
var eventType string
|
||||||
|
cancelled := h.cleanupPendingToolExecutionsAfterIteration(context.Background(), "conv-cleanup", decision, func(et, _ string, _ interface{}) {
|
||||||
|
eventType = et
|
||||||
|
})
|
||||||
|
if len(cancelled) != 1 || cancelled[0] != execID {
|
||||||
|
t.Fatalf("cancelled = %#v, want [%s]", cancelled, execID)
|
||||||
|
}
|
||||||
|
if eventType != "finalization_pending_tools_cancelled" {
|
||||||
|
t.Fatalf("event type = %q", eventType)
|
||||||
|
}
|
||||||
|
|
||||||
|
deadline := time.Now().Add(time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
exec, err := db.GetToolExecution(execID)
|
||||||
|
if err == nil && exec != nil && exec.Status == mcp.ToolExecutionStatusCancelled {
|
||||||
|
after := agentfinalizer.Decide(db, agentfinalizer.Input{
|
||||||
|
Response: "基于已完成信息的阶段性总结。",
|
||||||
|
MCPExecutionIDs: []string{execID},
|
||||||
|
})
|
||||||
|
if !after.Finalizable || !after.Finalized {
|
||||||
|
t.Fatalf("decision should finalize after cleanup: %+v", after)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatal("execution did not become cancelled")
|
||||||
|
}
|
||||||
|
|||||||
@@ -205,6 +205,7 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
agentMode := "eino_" + effectiveOrch
|
agentMode := "eino_" + effectiveOrch
|
||||||
var decision agentfinalizer.Decision
|
var decision agentfinalizer.Decision
|
||||||
|
var autoCancelledPendingExecutionIDs []string
|
||||||
|
|
||||||
for {
|
for {
|
||||||
segmentMainIterationMax := 0
|
segmentMainIterationMax := 0
|
||||||
@@ -282,6 +283,10 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
decision = h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, agentMode, result, cumulativeMCPExecutionIDs, requestRequiresExecutionEvidence(&req))
|
decision = h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, agentMode, result, cumulativeMCPExecutionIDs, requestRequiresExecutionEvidence(&req))
|
||||||
|
if cancelled := h.cleanupPendingToolExecutionsAfterIteration(taskCtx, conversationID, decision, progressCallback); len(cancelled) > 0 {
|
||||||
|
autoCancelledPendingExecutionIDs = mergeMCPExecutionIDLists(autoCancelledPendingExecutionIDs, cancelled)
|
||||||
|
decision = h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, agentMode, result, cumulativeMCPExecutionIDs, requestRequiresExecutionEvidence(&req))
|
||||||
|
}
|
||||||
if h.tryAutoContinueAfterFinalization(taskCtx, conversationID, result, decision, &finalizationAutoContinueAttempt, &curHistory, &curFinalMessage, progressCallback) {
|
if h.tryAutoContinueAfterFinalization(taskCtx, conversationID, result, decision, &finalizationAutoContinueAttempt, &curHistory, &curFinalMessage, progressCallback) {
|
||||||
mainIterationOffset += segmentMainIterationMax
|
mainIterationOffset += segmentMainIterationMax
|
||||||
timeoutCancel()
|
timeoutCancel()
|
||||||
@@ -398,6 +403,10 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
|||||||
|
|
||||||
if decision.CompletionReason == "" {
|
if decision.CompletionReason == "" {
|
||||||
decision = h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, agentMode, result, cumulativeMCPExecutionIDs, requestRequiresExecutionEvidence(&req))
|
decision = h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, agentMode, result, cumulativeMCPExecutionIDs, requestRequiresExecutionEvidence(&req))
|
||||||
|
if cancelled := h.cleanupPendingToolExecutionsAfterIteration(taskCtx, conversationID, decision, nil); len(cancelled) > 0 {
|
||||||
|
autoCancelledPendingExecutionIDs = mergeMCPExecutionIDLists(autoCancelledPendingExecutionIDs, cancelled)
|
||||||
|
decision = h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, agentMode, result, cumulativeMCPExecutionIDs, requestRequiresExecutionEvidence(&req))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
h.persistFinalizationDecision(conversationID, assistantMessageID, agentMode, cumulativeMCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput), decision)
|
h.persistFinalizationDecision(conversationID, assistantMessageID, agentMode, cumulativeMCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput), decision)
|
||||||
|
|
||||||
@@ -415,10 +424,11 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
|||||||
h.tasks.UpdateTaskStatus(conversationID, taskStatus)
|
h.tasks.UpdateTaskStatus(conversationID, taskStatus)
|
||||||
}
|
}
|
||||||
sendEvent("response", responseText, finalizationResponsePayload(decision, map[string]interface{}{
|
sendEvent("response", responseText, finalizationResponsePayload(decision, map[string]interface{}{
|
||||||
"mcpExecutionIds": cumulativeMCPExecutionIDs,
|
"mcpExecutionIds": cumulativeMCPExecutionIDs,
|
||||||
"conversationId": conversationID,
|
"conversationId": conversationID,
|
||||||
"messageId": assistantMessageID,
|
"messageId": assistantMessageID,
|
||||||
"agentMode": agentMode,
|
"agentMode": agentMode,
|
||||||
|
"autoCancelledPendingExecutionIds": autoCancelledPendingExecutionIDs,
|
||||||
}))
|
}))
|
||||||
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
||||||
}
|
}
|
||||||
@@ -478,6 +488,7 @@ func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
agentMode := "eino_" + effectiveOrch
|
agentMode := "eino_" + effectiveOrch
|
||||||
var decision agentfinalizer.Decision
|
var decision agentfinalizer.Decision
|
||||||
|
var autoCancelledPendingExecutionIDs []string
|
||||||
for {
|
for {
|
||||||
result, runErr = multiagent.RunDeepAgent(
|
result, runErr = multiagent.RunDeepAgent(
|
||||||
taskCtx,
|
taskCtx,
|
||||||
@@ -514,6 +525,10 @@ func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
decision = h.decideAgentRunForDeliveryWithPolicy(prep.ConversationID, prep.AssistantMessageID, agentMode, result, result.MCPExecutionIDs, requestRequiresExecutionEvidence(&req))
|
decision = h.decideAgentRunForDeliveryWithPolicy(prep.ConversationID, prep.AssistantMessageID, agentMode, result, result.MCPExecutionIDs, requestRequiresExecutionEvidence(&req))
|
||||||
|
if cancelled := h.cleanupPendingToolExecutionsAfterIteration(taskCtx, prep.ConversationID, decision, progressCallback); len(cancelled) > 0 {
|
||||||
|
autoCancelledPendingExecutionIDs = mergeMCPExecutionIDLists(autoCancelledPendingExecutionIDs, cancelled)
|
||||||
|
decision = h.decideAgentRunForDeliveryWithPolicy(prep.ConversationID, prep.AssistantMessageID, agentMode, result, result.MCPExecutionIDs, requestRequiresExecutionEvidence(&req))
|
||||||
|
}
|
||||||
if h.tryAutoContinueAfterFinalization(taskCtx, prep.ConversationID, result, decision, &finalizationAutoContinueAttempt, &curHist, &curMsg, progressCallback) {
|
if h.tryAutoContinueAfterFinalization(taskCtx, prep.ConversationID, result, decision, &finalizationAutoContinueAttempt, &curHist, &curMsg, progressCallback) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -533,18 +548,19 @@ func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
|
|||||||
responseText = finalizationBlockedMessage(decision)
|
responseText = finalizationBlockedMessage(decision)
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, ChatResponse{
|
c.JSON(http.StatusOK, ChatResponse{
|
||||||
Response: responseText,
|
Response: responseText,
|
||||||
MCPExecutionIDs: result.MCPExecutionIDs,
|
MCPExecutionIDs: result.MCPExecutionIDs,
|
||||||
ConversationID: prep.ConversationID,
|
ConversationID: prep.ConversationID,
|
||||||
Time: time.Now(),
|
Time: time.Now(),
|
||||||
Finalizable: decision.Finalizable,
|
Finalizable: decision.Finalizable,
|
||||||
Finalized: decision.Finalized,
|
Finalized: decision.Finalized,
|
||||||
Status: decision.Status,
|
Status: decision.Status,
|
||||||
CompletionReason: decision.CompletionReason,
|
CompletionReason: decision.CompletionReason,
|
||||||
EvidenceVerified: decision.EvidenceVerified,
|
EvidenceVerified: decision.EvidenceVerified,
|
||||||
EvidenceRefs: decision.EvidenceRefs,
|
EvidenceRefs: decision.EvidenceRefs,
|
||||||
PendingExecutionIDs: decision.PendingExecutionIDs,
|
PendingExecutionIDs: decision.PendingExecutionIDs,
|
||||||
MissingChecks: decision.MissingChecks,
|
MissingChecks: decision.MissingChecks,
|
||||||
|
AutoCancelledPendingExecutionIDs: autoCancelledPendingExecutionIDs,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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{}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package multiagent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/cloudwego/eino/adk"
|
||||||
|
"github.com/cloudwego/eino/schema"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// agenticOrphanToolPrunerMiddleware is the AgenticMessage equivalent of
|
||||||
|
// orphanToolPrunerMiddleware. It removes user-role messages whose content
|
||||||
|
// blocks are exclusively FunctionToolResult entries with CallIDs that do not
|
||||||
|
// match any FunctionToolCall in the history.
|
||||||
|
//
|
||||||
|
// This is a defense-in-depth layer after agenticToolPairReconcilerMiddleware;
|
||||||
|
// the reconciler handles the common case (assistant followed by its results)
|
||||||
|
// while this pruner catches stray results that appear before their assistant
|
||||||
|
// or in non-adjacent positions (e.g. after summarization rewriting).
|
||||||
|
type agenticOrphanToolPrunerMiddleware struct {
|
||||||
|
*adk.TypedBaseChatModelAgentMiddleware[*schema.AgenticMessage]
|
||||||
|
logger *zap.Logger
|
||||||
|
phase string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAgenticOrphanToolPrunerMiddleware(logger *zap.Logger, phase string) adk.TypedChatModelAgentMiddleware[*schema.AgenticMessage] {
|
||||||
|
return &agenticOrphanToolPrunerMiddleware{
|
||||||
|
TypedBaseChatModelAgentMiddleware: &adk.TypedBaseChatModelAgentMiddleware[*schema.AgenticMessage]{},
|
||||||
|
logger: logger,
|
||||||
|
phase: phase,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *agenticOrphanToolPrunerMiddleware) BeforeModelRewriteState(
|
||||||
|
ctx context.Context,
|
||||||
|
state *adk.TypedChatModelAgentState[*schema.AgenticMessage],
|
||||||
|
mc *adk.TypedModelContext[*schema.AgenticMessage],
|
||||||
|
) (context.Context, *adk.TypedChatModelAgentState[*schema.AgenticMessage], error) {
|
||||||
|
_ = mc
|
||||||
|
if m == nil || state == nil || len(state.Messages) == 0 {
|
||||||
|
return ctx, state, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 1: collect all provided CallIDs from assistant FunctionToolCall blocks.
|
||||||
|
provided := make(map[string]struct{}, 8)
|
||||||
|
for _, msg := range state.Messages {
|
||||||
|
if msg == nil || msg.Role != schema.AgenticRoleTypeAssistant {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, block := range msg.ContentBlocks {
|
||||||
|
if block != nil && block.FunctionToolCall != nil && block.FunctionToolCall.CallID != "" {
|
||||||
|
provided[block.FunctionToolCall.CallID] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fast path: check if any orphan exists.
|
||||||
|
hasOrphan := false
|
||||||
|
for _, msg := range state.Messages {
|
||||||
|
if msg == nil || !isPureAgenticToolResult(msg) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, id := range agenticToolResultCallIDs(msg) {
|
||||||
|
if _, ok := provided[id]; !ok {
|
||||||
|
hasOrphan = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hasOrphan {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasOrphan {
|
||||||
|
return ctx, state, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 2: build pruned list.
|
||||||
|
pruned := make([]*schema.AgenticMessage, 0, len(state.Messages))
|
||||||
|
var droppedIDs []string
|
||||||
|
var droppedNames []string
|
||||||
|
for _, msg := range state.Messages {
|
||||||
|
if msg == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !isPureAgenticToolResult(msg) {
|
||||||
|
pruned = append(pruned, msg)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Check if ALL result call IDs are orphans. If any is matched, keep the
|
||||||
|
// message (the reconciler already handled partial mismatches).
|
||||||
|
allOrphan := true
|
||||||
|
for _, id := range agenticToolResultCallIDs(msg) {
|
||||||
|
if _, ok := provided[id]; ok {
|
||||||
|
allOrphan = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if allOrphan {
|
||||||
|
for _, block := range msg.ContentBlocks {
|
||||||
|
if block != nil && block.FunctionToolResult != nil {
|
||||||
|
droppedIDs = append(droppedIDs, block.FunctionToolResult.CallID)
|
||||||
|
droppedNames = append(droppedNames, block.FunctionToolResult.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pruned = append(pruned, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(droppedIDs) == 0 {
|
||||||
|
return ctx, state, nil
|
||||||
|
}
|
||||||
|
if m.logger != nil {
|
||||||
|
m.logger.Warn("agentic orphan tool messages pruned before model call",
|
||||||
|
zap.String("phase", m.phase),
|
||||||
|
zap.Int("dropped_count", len(droppedIDs)),
|
||||||
|
zap.Strings("dropped_tool_call_ids", droppedIDs),
|
||||||
|
zap.Strings("dropped_tool_names", droppedNames),
|
||||||
|
zap.Int("messages_before", len(state.Messages)),
|
||||||
|
zap.Int("messages_after", len(pruned)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ns := *state
|
||||||
|
ns.Messages = pruned
|
||||||
|
return ctx, &ns, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
package multiagent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/cloudwego/eino/adk"
|
||||||
|
"github.com/cloudwego/eino/schema"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// agenticToolPairReconcilerMiddleware is the AgenticMessage equivalent of
|
||||||
|
// toolPairReconcilerMiddleware. It ensures every assistant FunctionToolCall
|
||||||
|
// block is followed by a matching FunctionToolResult message, patching or
|
||||||
|
// dropping as needed so the downstream model never receives an unpaired
|
||||||
|
// tool-call history.
|
||||||
|
//
|
||||||
|
// In the AgenticMessage protocol:
|
||||||
|
// - Assistant tool calls: Role=AgenticRoleTypeAssistant with FunctionToolCall content blocks.
|
||||||
|
// - Tool results: Role=AgenticRoleTypeUser with FunctionToolResult content blocks.
|
||||||
|
//
|
||||||
|
// This middleware runs after summarization which may truncate history and
|
||||||
|
// break pairings.
|
||||||
|
type agenticToolPairReconcilerMiddleware struct {
|
||||||
|
*adk.TypedBaseChatModelAgentMiddleware[*schema.AgenticMessage]
|
||||||
|
logger *zap.Logger
|
||||||
|
phase string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAgenticToolPairReconcilerMiddleware(logger *zap.Logger, phase string) adk.TypedChatModelAgentMiddleware[*schema.AgenticMessage] {
|
||||||
|
return &agenticToolPairReconcilerMiddleware{
|
||||||
|
TypedBaseChatModelAgentMiddleware: &adk.TypedBaseChatModelAgentMiddleware[*schema.AgenticMessage]{},
|
||||||
|
logger: logger,
|
||||||
|
phase: phase,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *agenticToolPairReconcilerMiddleware) BeforeModelRewriteState(
|
||||||
|
ctx context.Context,
|
||||||
|
state *adk.TypedChatModelAgentState[*schema.AgenticMessage],
|
||||||
|
mc *adk.TypedModelContext[*schema.AgenticMessage],
|
||||||
|
) (context.Context, *adk.TypedChatModelAgentState[*schema.AgenticMessage], error) {
|
||||||
|
_ = mc
|
||||||
|
if m == nil || state == nil || len(state.Messages) == 0 {
|
||||||
|
return ctx, state, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
usedIDs := make(map[string]struct{}, 16)
|
||||||
|
changed := false
|
||||||
|
patched := 0
|
||||||
|
dropped := 0
|
||||||
|
out := make([]*schema.AgenticMessage, 0, len(state.Messages))
|
||||||
|
|
||||||
|
for i := 0; i < len(state.Messages); {
|
||||||
|
msg := state.Messages[i]
|
||||||
|
if msg == nil {
|
||||||
|
changed = true
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
calls := agenticFunctionToolCalls(msg)
|
||||||
|
|
||||||
|
// Non-assistant or assistant without tool calls — but check for orphan
|
||||||
|
// tool-result messages (user role with only FunctionToolResult blocks).
|
||||||
|
if len(calls) == 0 {
|
||||||
|
if isPureAgenticToolResult(msg) {
|
||||||
|
// Orphan tool result not preceded by its assistant; drop it.
|
||||||
|
changed = true
|
||||||
|
dropped++
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, msg)
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduplicate / fix empty call IDs.
|
||||||
|
idsChanged := false
|
||||||
|
for ci := range calls {
|
||||||
|
id := calls[ci].CallID
|
||||||
|
_, duplicate := usedIDs[id]
|
||||||
|
if id == "" || duplicate {
|
||||||
|
base := fmt.Sprintf("patched_agentic_call_%d_%d", i, ci)
|
||||||
|
id = base
|
||||||
|
for suffix := 1; ; suffix++ {
|
||||||
|
if _, exists := usedIDs[id]; !exists {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
id = fmt.Sprintf("%s_%d", base, suffix)
|
||||||
|
}
|
||||||
|
calls[ci].CallID = id
|
||||||
|
idsChanged = true
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
usedIDs[id] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
assistant := msg
|
||||||
|
if idsChanged {
|
||||||
|
assistant = cloneAgenticMessageWithCalls(msg, calls)
|
||||||
|
}
|
||||||
|
out = append(out, assistant)
|
||||||
|
|
||||||
|
// Build expected set.
|
||||||
|
expected := make(map[string]*schema.FunctionToolCall, len(calls))
|
||||||
|
for ci := range calls {
|
||||||
|
expected[calls[ci].CallID] = calls[ci]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consume following tool-result messages.
|
||||||
|
results := make(map[string]*schema.AgenticMessage, len(calls))
|
||||||
|
j := i + 1
|
||||||
|
for j < len(state.Messages) {
|
||||||
|
next := state.Messages[j]
|
||||||
|
if next == nil {
|
||||||
|
changed = true
|
||||||
|
j++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !isPureAgenticToolResult(next) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
resultCallIDs := agenticToolResultCallIDs(next)
|
||||||
|
consumed := false
|
||||||
|
for _, rid := range resultCallIDs {
|
||||||
|
if _, wanted := expected[rid]; !wanted {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, dup := results[rid]; dup {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
results[rid] = next
|
||||||
|
consumed = true
|
||||||
|
}
|
||||||
|
if !consumed {
|
||||||
|
changed = true
|
||||||
|
dropped++
|
||||||
|
}
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit results in call order, patching missing ones.
|
||||||
|
for _, tc := range calls {
|
||||||
|
if result, ok := results[tc.CallID]; ok {
|
||||||
|
out = append(out, result)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, makeAgenticPatchedToolResult(tc.CallID, tc.Name))
|
||||||
|
changed = true
|
||||||
|
patched++
|
||||||
|
}
|
||||||
|
i = j
|
||||||
|
}
|
||||||
|
|
||||||
|
if !changed {
|
||||||
|
return ctx, state, nil
|
||||||
|
}
|
||||||
|
if m.logger != nil {
|
||||||
|
m.logger.Warn("agentic tool-call/result pairs reconciled before model call",
|
||||||
|
zap.String("phase", m.phase),
|
||||||
|
zap.Int("patched_results", patched),
|
||||||
|
zap.Int("dropped_results", dropped),
|
||||||
|
zap.Int("messages_before", len(state.Messages)),
|
||||||
|
zap.Int("messages_after", len(out)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ns := *state
|
||||||
|
ns.Messages = out
|
||||||
|
return ctx, &ns, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// agenticFunctionToolCalls extracts FunctionToolCall pointers from an
|
||||||
|
// assistant message's content blocks. Returns nil for non-assistant messages.
|
||||||
|
func agenticFunctionToolCalls(msg *schema.AgenticMessage) []*schema.FunctionToolCall {
|
||||||
|
if msg == nil || msg.Role != schema.AgenticRoleTypeAssistant {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []*schema.FunctionToolCall
|
||||||
|
for _, block := range msg.ContentBlocks {
|
||||||
|
if block != nil && block.FunctionToolCall != nil {
|
||||||
|
out = append(out, block.FunctionToolCall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// isPureAgenticToolResult returns true when the message is a user-role
|
||||||
|
// message whose content blocks are exclusively FunctionToolResult entries.
|
||||||
|
func isPureAgenticToolResult(msg *schema.AgenticMessage) bool {
|
||||||
|
if msg == nil || msg.Role != schema.AgenticRoleTypeUser || len(msg.ContentBlocks) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, block := range msg.ContentBlocks {
|
||||||
|
if block == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if block.FunctionToolResult == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// agenticToolResultCallIDs extracts all CallIDs from FunctionToolResult blocks.
|
||||||
|
func agenticToolResultCallIDs(msg *schema.AgenticMessage) []string {
|
||||||
|
if msg == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var ids []string
|
||||||
|
for _, block := range msg.ContentBlocks {
|
||||||
|
if block != nil && block.FunctionToolResult != nil && block.FunctionToolResult.CallID != "" {
|
||||||
|
ids = append(ids, block.FunctionToolResult.CallID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneAgenticMessageWithCalls(msg *schema.AgenticMessage, calls []*schema.FunctionToolCall) *schema.AgenticMessage {
|
||||||
|
cloned := *msg
|
||||||
|
cloned.ContentBlocks = make([]*schema.ContentBlock, 0, len(msg.ContentBlocks))
|
||||||
|
callIdx := 0
|
||||||
|
for _, block := range msg.ContentBlocks {
|
||||||
|
if block != nil && block.FunctionToolCall != nil && callIdx < len(calls) {
|
||||||
|
cloned.ContentBlocks = append(cloned.ContentBlocks, schema.NewContentBlock(calls[callIdx]))
|
||||||
|
callIdx++
|
||||||
|
} else {
|
||||||
|
cloned.ContentBlocks = append(cloned.ContentBlocks, block)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeAgenticPatchedToolResult(callID, name string) *schema.AgenticMessage {
|
||||||
|
return &schema.AgenticMessage{
|
||||||
|
Role: schema.AgenticRoleTypeUser,
|
||||||
|
ContentBlocks: []*schema.ContentBlock{schema.NewContentBlock(&schema.FunctionToolResult{
|
||||||
|
CallID: callID,
|
||||||
|
Name: name,
|
||||||
|
Content: []*schema.FunctionToolResultContentBlock{{
|
||||||
|
Type: schema.FunctionToolResultContentBlockTypeText,
|
||||||
|
Text: &schema.UserInputText{Text: patchedMissingToolResult},
|
||||||
|
}},
|
||||||
|
})},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
package multiagent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/cloudwego/eino/adk"
|
||||||
|
"github.com/cloudwego/eino/schema"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAgenticToolPairReconcilerPatchesMissing(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
mw := newAgenticToolPairReconcilerMiddleware(nil, "test")
|
||||||
|
state := &adk.TypedChatModelAgentState[*schema.AgenticMessage]{
|
||||||
|
Messages: []*schema.AgenticMessage{
|
||||||
|
agenticAssistantToolCall("c1", "search", `{"q":"x"}`),
|
||||||
|
agenticAssistantToolCall("c2", "execute", `{"cmd":"ls"}`),
|
||||||
|
// c1 result present, c2 missing
|
||||||
|
agenticToolResult("c1", "search", "found it"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_, out, err := mw.BeforeModelRewriteState(context.Background(), state, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Expected: assistant(c1) -> result(c1) -> assistant(c2) -> patched_result(c2)
|
||||||
|
if len(out.Messages) != 4 {
|
||||||
|
t.Fatalf("messages = %d, want 4", len(out.Messages))
|
||||||
|
}
|
||||||
|
// c1 assistant
|
||||||
|
if calls := agenticFunctionToolCalls(out.Messages[0]); len(calls) != 1 || calls[0].CallID != "c1" {
|
||||||
|
t.Fatal("msg[0] should be assistant(c1)")
|
||||||
|
}
|
||||||
|
// c1 result
|
||||||
|
if ids := agenticToolResultCallIDs(out.Messages[1]); len(ids) != 1 || ids[0] != "c1" {
|
||||||
|
t.Fatal("msg[1] should be result(c1)")
|
||||||
|
}
|
||||||
|
// c2 assistant
|
||||||
|
if calls := agenticFunctionToolCalls(out.Messages[2]); len(calls) != 1 || calls[0].CallID != "c2" {
|
||||||
|
t.Fatal("msg[2] should be assistant(c2)")
|
||||||
|
}
|
||||||
|
// c2 patched result
|
||||||
|
if ids := agenticToolResultCallIDs(out.Messages[3]); len(ids) != 1 || ids[0] != "c2" {
|
||||||
|
t.Fatal("msg[3] should be patched result(c2)")
|
||||||
|
}
|
||||||
|
resultText := out.Messages[3].ContentBlocks[0].FunctionToolResult.Content[0].Text.Text
|
||||||
|
if resultText != patchedMissingToolResult {
|
||||||
|
t.Fatalf("patched text = %q", resultText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgenticToolPairReconcilerDropsOrphan(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
mw := newAgenticToolPairReconcilerMiddleware(nil, "test")
|
||||||
|
state := &adk.TypedChatModelAgentState[*schema.AgenticMessage]{
|
||||||
|
Messages: []*schema.AgenticMessage{
|
||||||
|
// Orphan tool result with no preceding assistant
|
||||||
|
agenticToolResult("orphan", "deleted_tool", "stale data"),
|
||||||
|
{Role: schema.AgenticRoleTypeUser, ContentBlocks: []*schema.ContentBlock{
|
||||||
|
schema.NewContentBlock(&schema.UserInputText{Text: "hello"}),
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_, out, err := mw.BeforeModelRewriteState(context.Background(), state, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(out.Messages) != 1 {
|
||||||
|
t.Fatalf("messages = %d, want 1 (orphan dropped)", len(out.Messages))
|
||||||
|
}
|
||||||
|
if out.Messages[0].ContentBlocks[0].UserInputText == nil {
|
||||||
|
t.Fatal("remaining message should be the user text")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgenticToolPairReconcilerNoopWhenPaired(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
mw := newAgenticToolPairReconcilerMiddleware(nil, "test")
|
||||||
|
state := &adk.TypedChatModelAgentState[*schema.AgenticMessage]{
|
||||||
|
Messages: []*schema.AgenticMessage{
|
||||||
|
agenticAssistantToolCall("c1", "search", `{}`),
|
||||||
|
agenticToolResult("c1", "search", "ok"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_, out, err := mw.BeforeModelRewriteState(context.Background(), state, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Should return original state unchanged
|
||||||
|
if &out.Messages[0] == &state.Messages[0] {
|
||||||
|
// pointer equality on slice — state not cloned
|
||||||
|
}
|
||||||
|
if len(out.Messages) != 2 {
|
||||||
|
t.Fatalf("messages = %d, want 2", len(out.Messages))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgenticToolPairReconcilerFixesEmptyCallID(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
mw := newAgenticToolPairReconcilerMiddleware(nil, "test")
|
||||||
|
state := &adk.TypedChatModelAgentState[*schema.AgenticMessage]{
|
||||||
|
Messages: []*schema.AgenticMessage{
|
||||||
|
{
|
||||||
|
Role: schema.AgenticRoleTypeAssistant,
|
||||||
|
ContentBlocks: []*schema.ContentBlock{schema.NewContentBlock(&schema.FunctionToolCall{
|
||||||
|
CallID: "", Name: "search", Arguments: `{}`,
|
||||||
|
})},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_, out, err := mw.BeforeModelRewriteState(context.Background(), state, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
calls := agenticFunctionToolCalls(out.Messages[0])
|
||||||
|
if len(calls) != 1 || calls[0].CallID == "" {
|
||||||
|
t.Fatalf("empty call ID should be patched, got %q", calls[0].CallID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgenticOrphanToolPrunerRemovesOrphan(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
mw := newAgenticOrphanToolPrunerMiddleware(nil, "test")
|
||||||
|
state := &adk.TypedChatModelAgentState[*schema.AgenticMessage]{
|
||||||
|
Messages: []*schema.AgenticMessage{
|
||||||
|
agenticAssistantToolCall("c1", "search", `{}`),
|
||||||
|
agenticToolResult("c1", "search", "ok"),
|
||||||
|
// Orphan: no assistant has call_id "c_orphan"
|
||||||
|
agenticToolResult("c_orphan", "deleted", "stale"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_, out, err := mw.BeforeModelRewriteState(context.Background(), state, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(out.Messages) != 2 {
|
||||||
|
t.Fatalf("messages = %d, want 2 (orphan pruned)", len(out.Messages))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgenticOrphanToolPrunerNoopWhenClean(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
mw := newAgenticOrphanToolPrunerMiddleware(nil, "test")
|
||||||
|
state := &adk.TypedChatModelAgentState[*schema.AgenticMessage]{
|
||||||
|
Messages: []*schema.AgenticMessage{
|
||||||
|
agenticAssistantToolCall("c1", "search", `{}`),
|
||||||
|
agenticToolResult("c1", "search", "ok"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_, out, err := mw.BeforeModelRewriteState(context.Background(), state, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(out.Messages) != 2 {
|
||||||
|
t.Fatalf("messages = %d, want 2", len(out.Messages))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,8 +20,13 @@ func appendEinoAgenticChatModelTailMiddlewares(
|
|||||||
handlers = append(handlers, newAgenticSystemMessageNormalizerMiddleware(cfg.logger, cfg.phase))
|
handlers = append(handlers, newAgenticSystemMessageNormalizerMiddleware(cfg.logger, cfg.phase))
|
||||||
handlers = append(handlers, newAgenticContinuationUserDedupMiddleware(cfg.logger, cfg.phase))
|
handlers = append(handlers, newAgenticContinuationUserDedupMiddleware(cfg.logger, cfg.phase))
|
||||||
if cfg.agenticSummarization != nil {
|
if cfg.agenticSummarization != nil {
|
||||||
|
handlers = append(handlers, newAgenticToolPairReconcilerMiddleware(cfg.logger, cfg.phase+"_pre_summarization"))
|
||||||
handlers = append(handlers, cfg.agenticSummarization)
|
handlers = append(handlers, cfg.agenticSummarization)
|
||||||
}
|
}
|
||||||
|
handlers = append(handlers, newAgenticToolPairReconcilerMiddleware(cfg.logger, cfg.phase))
|
||||||
|
if !cfg.skipOrphanPruner {
|
||||||
|
handlers = append(handlers, newAgenticOrphanToolPrunerMiddleware(cfg.logger, cfg.phase))
|
||||||
|
}
|
||||||
if !cfg.skipTrace && cfg.trace != nil {
|
if !cfg.skipTrace && cfg.trace != nil {
|
||||||
if capMw := newAgenticModelFacingTraceMiddleware(cfg.trace); capMw != nil {
|
if capMw := newAgenticModelFacingTraceMiddleware(cfg.trace); capMw != nil {
|
||||||
handlers = append(handlers, capMw)
|
handlers = append(handlers, capMw)
|
||||||
|
|||||||
@@ -106,7 +106,8 @@ func TestAppendEinoAgenticChatModelTailMiddlewares(t *testing.T) {
|
|||||||
phase: "agentic",
|
phase: "agentic",
|
||||||
trace: holder,
|
trace: holder,
|
||||||
})
|
})
|
||||||
if len(handlers) != 3 {
|
// system + continuation + reconciler + orphan_pruner + trace
|
||||||
t.Fatalf("handlers = %d, want system + continuation + trace", len(handlers))
|
if len(handlers) != 5 {
|
||||||
|
t.Fatalf("handlers = %d, want system + continuation + reconciler + orphan_pruner + trace", len(handlers))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,70 @@ func TestEinoExtractFallbackAssistantFromMsgs_prefersToolOverEarlierAssistant(t
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEinoExtractFallbackAssistantFromMsgs_plainAssistant(t *testing.T) {
|
||||||
|
msgs := []*schema.Message{
|
||||||
|
schema.UserMessage("hi"),
|
||||||
|
schema.AssistantMessage("plain answer", nil),
|
||||||
|
}
|
||||||
|
if got := einoExtractFallbackAssistantFromMsgs(msgs); got != "plain answer" {
|
||||||
|
t.Fatalf("got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEinoExtractFallbackAssistantFromMsgs_finalAssistantAfterToolResult(t *testing.T) {
|
||||||
|
msgs := []*schema.Message{
|
||||||
|
schema.UserMessage("hi"),
|
||||||
|
schema.AssistantMessage("", []schema.ToolCall{{
|
||||||
|
ID: "call-1",
|
||||||
|
Type: "function",
|
||||||
|
Function: schema.FunctionCall{
|
||||||
|
Name: "execute",
|
||||||
|
Arguments: `{"command":"pwd"}`,
|
||||||
|
},
|
||||||
|
}}),
|
||||||
|
schema.ToolMessage("/tmp", "call-1", schema.WithToolName("execute")),
|
||||||
|
schema.AssistantMessage("final after tool", nil),
|
||||||
|
}
|
||||||
|
if got := einoExtractFallbackAssistantFromMsgs(msgs); got != "final after tool" {
|
||||||
|
t.Fatalf("got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEinoExtractFallbackAssistantFromMsgs_doesNotUseAssistantBeforeUnfinishedToolResult(t *testing.T) {
|
||||||
|
msgs := []*schema.Message{
|
||||||
|
schema.UserMessage("hi"),
|
||||||
|
schema.AssistantMessage("I will inspect that.", nil),
|
||||||
|
schema.AssistantMessage("", []schema.ToolCall{{
|
||||||
|
ID: "call-1",
|
||||||
|
Type: "function",
|
||||||
|
Function: schema.FunctionCall{
|
||||||
|
Name: "execute",
|
||||||
|
Arguments: `{"command":"pwd"}`,
|
||||||
|
},
|
||||||
|
}}),
|
||||||
|
schema.ToolMessage("/tmp", "call-1", schema.WithToolName("execute")),
|
||||||
|
}
|
||||||
|
if got := einoExtractFallbackAssistantFromMsgs(msgs); got != "" {
|
||||||
|
t.Fatalf("got %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEinoRunResultBuilderFinalFallsBackToPlainAssistantTrace(t *testing.T) {
|
||||||
|
runMessages := newEinoRunMessageAccumulator(nil)
|
||||||
|
runMessages.Append(schema.UserMessage("hi"))
|
||||||
|
runMessages.Append(schema.AssistantMessage("plain answer", nil))
|
||||||
|
|
||||||
|
got := newEinoRunResultBuilder(einoRunResultBuilderConfig{
|
||||||
|
OrchMode: "deep",
|
||||||
|
EmptyHint: "empty",
|
||||||
|
RunMessages: runMessages,
|
||||||
|
}).BuildFinal()
|
||||||
|
|
||||||
|
if got.Response != "plain answer" {
|
||||||
|
t.Fatalf("response = %q, want plain answer", got.Response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func toolExitMsg(content, callID string) *schema.Message {
|
func toolExitMsg(content, callID string) *schema.Message {
|
||||||
m := schema.ToolMessage(content, callID)
|
m := schema.ToolMessage(content, callID)
|
||||||
m.ToolName = "exit"
|
m.ToolName = "exit"
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package multiagent
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/cloudwego/eino/adk"
|
"github.com/cloudwego/eino/adk"
|
||||||
)
|
)
|
||||||
@@ -82,12 +84,109 @@ func (h *einoRunErrorHandler) emitError(err error, kind string) {
|
|||||||
if h == nil || h.progress == nil || err == nil {
|
if h == nil || h.progress == nil || err == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
userErr := einoUserFacingRunError(err)
|
||||||
data := map[string]interface{}{
|
data := map[string]interface{}{
|
||||||
"conversationId": h.conversationID,
|
"conversationId": h.conversationID,
|
||||||
"source": "eino",
|
"source": "eino",
|
||||||
|
"error": err.Error(),
|
||||||
}
|
}
|
||||||
if kind != "" {
|
if kind != "" {
|
||||||
data["errorKind"] = kind
|
data["errorKind"] = kind
|
||||||
|
} else if userErr.kind != "" {
|
||||||
|
data["errorKind"] = userErr.kind
|
||||||
}
|
}
|
||||||
h.progress("error", err.Error(), data)
|
if userErr.summary != "" {
|
||||||
|
data["errorSummary"] = userErr.summary
|
||||||
|
}
|
||||||
|
if userErr.retryExhausted {
|
||||||
|
data["retryExhausted"] = true
|
||||||
|
if userErr.totalRetries > 0 {
|
||||||
|
data["totalRetries"] = userErr.totalRetries
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if userErr.rawLastError != "" {
|
||||||
|
data["lastError"] = userErr.rawLastError
|
||||||
|
}
|
||||||
|
if userErr.technicalError != "" {
|
||||||
|
data["technicalError"] = userErr.technicalError
|
||||||
|
}
|
||||||
|
if userErr.hasModelOriginalError {
|
||||||
|
data["modelOriginalError"] = userErr.rawLastError
|
||||||
|
} else if userErr.retryExhausted {
|
||||||
|
data["hasModelOriginalError"] = false
|
||||||
|
}
|
||||||
|
message := err.Error()
|
||||||
|
if userErr.message != "" {
|
||||||
|
message = userErr.message
|
||||||
|
}
|
||||||
|
h.progress("error", message, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
type einoRunUserError struct {
|
||||||
|
message string
|
||||||
|
kind string
|
||||||
|
summary string
|
||||||
|
rawLastError string
|
||||||
|
technicalError string
|
||||||
|
retryExhausted bool
|
||||||
|
totalRetries int
|
||||||
|
hasModelOriginalError bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func einoUserFacingRunError(err error) einoRunUserError {
|
||||||
|
var out einoRunUserError
|
||||||
|
if err == nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
var retryErr *adk.RetryExhaustedError
|
||||||
|
if !errors.As(err, &retryErr) {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
out.retryExhausted = true
|
||||||
|
out.totalRetries = retryErr.TotalRetries
|
||||||
|
lastErr := retryErr.LastErr
|
||||||
|
if lastErr == nil {
|
||||||
|
out.kind = "model_retry_exhausted"
|
||||||
|
out.summary = "模型调用多次重试后仍未成功。"
|
||||||
|
out.message = out.summary
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
out.rawLastError = strings.TrimSpace(lastErr.Error())
|
||||||
|
if isEinoShouldRetryOutputRejected(lastErr) {
|
||||||
|
out.kind = "model_output_rejected"
|
||||||
|
out.summary = "模型未返回原始错误;输出被重试策略拒绝。"
|
||||||
|
out.technicalError = out.rawLastError
|
||||||
|
out.message = formatEinoRetryExhaustedMessage(out.summary, retryErr.TotalRetries)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
kind, summary := einoTransientRunErrorUserDetail(lastErr)
|
||||||
|
if strings.TrimSpace(summary) == "" {
|
||||||
|
summary = einoTrimRetryErrorSummary(lastErr.Error())
|
||||||
|
}
|
||||||
|
if kind == "" {
|
||||||
|
kind = "model_retry_exhausted"
|
||||||
|
}
|
||||||
|
out.kind = kind
|
||||||
|
out.summary = summary
|
||||||
|
out.hasModelOriginalError = out.rawLastError != ""
|
||||||
|
out.message = formatEinoRetryExhaustedMessage(summary, retryErr.TotalRetries)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func isEinoShouldRetryOutputRejected(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.Contains(strings.ToLower(err.Error()), "model output rejected by shouldretry")
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatEinoRetryExhaustedMessage(summary string, totalRetries int) string {
|
||||||
|
summary = strings.TrimSpace(summary)
|
||||||
|
if summary == "" {
|
||||||
|
summary = "模型调用多次重试后仍未成功。"
|
||||||
|
}
|
||||||
|
if totalRetries > 0 {
|
||||||
|
return fmt.Sprintf("模型调用重试已耗尽(已重试 %d 次):%s", totalRetries, summary)
|
||||||
|
}
|
||||||
|
return "模型调用重试已耗尽:" + summary
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package multiagent
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/cloudwego/eino/adk"
|
"github.com/cloudwego/eino/adk"
|
||||||
@@ -61,6 +62,99 @@ func TestEinoRunErrorHandlerTimeoutAndGeneralErrorProgress(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEinoRunErrorHandlerRetryExhaustedEmptyOutputProgress(t *testing.T) {
|
||||||
|
err := &adk.RetryExhaustedError{
|
||||||
|
LastErr: errors.New("model output rejected by ShouldRetry at attempt 5"),
|
||||||
|
TotalRetries: 4,
|
||||||
|
}
|
||||||
|
var message string
|
||||||
|
var data map[string]interface{}
|
||||||
|
|
||||||
|
got := newEinoRunErrorHandler(einoRunErrorHandlerConfig{
|
||||||
|
ConversationID: "conv-1",
|
||||||
|
Progress: func(eventType, msg string, raw interface{}) {
|
||||||
|
if eventType == "error" {
|
||||||
|
message = msg
|
||||||
|
data, _ = raw.(map[string]interface{})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}).Handle(err)
|
||||||
|
|
||||||
|
if !errors.Is(got, err) {
|
||||||
|
t.Fatalf("err = %v", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(message, "模型调用重试已耗尽") ||
|
||||||
|
!strings.Contains(message, "模型未返回原始错误;输出被重试策略拒绝。") ||
|
||||||
|
strings.Contains(message, "model output rejected by ShouldRetry at attempt 5") {
|
||||||
|
t.Fatalf("message = %q", message)
|
||||||
|
}
|
||||||
|
if data["errorKind"] != "model_output_rejected" {
|
||||||
|
t.Fatalf("errorKind = %#v", data["errorKind"])
|
||||||
|
}
|
||||||
|
if data["errorSummary"] != "模型未返回原始错误;输出被重试策略拒绝。" {
|
||||||
|
t.Fatalf("errorSummary = %#v", data["errorSummary"])
|
||||||
|
}
|
||||||
|
if data["hasModelOriginalError"] != false {
|
||||||
|
t.Fatalf("hasModelOriginalError = %#v", data["hasModelOriginalError"])
|
||||||
|
}
|
||||||
|
if data["retryExhausted"] != true || data["totalRetries"] != 4 {
|
||||||
|
t.Fatalf("retry metadata = %#v", data)
|
||||||
|
}
|
||||||
|
if data["lastError"] != "model output rejected by ShouldRetry at attempt 5" {
|
||||||
|
t.Fatalf("lastError = %#v", data["lastError"])
|
||||||
|
}
|
||||||
|
if data["technicalError"] != "model output rejected by ShouldRetry at attempt 5" {
|
||||||
|
t.Fatalf("technicalError = %#v", data["technicalError"])
|
||||||
|
}
|
||||||
|
if _, ok := data["modelOriginalError"]; ok {
|
||||||
|
t.Fatalf("modelOriginalError should be absent for ShouldRetry rejection, got %#v", data["modelOriginalError"])
|
||||||
|
}
|
||||||
|
if data["error"] != err.Error() {
|
||||||
|
t.Fatalf("raw error = %#v, want %#v", data["error"], err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEinoRunErrorHandlerRetryExhaustedOriginalErrorProgress(t *testing.T) {
|
||||||
|
err := &adk.RetryExhaustedError{
|
||||||
|
LastErr: errors.New("HTTP 429 Too Many Requests"),
|
||||||
|
TotalRetries: 3,
|
||||||
|
}
|
||||||
|
var message string
|
||||||
|
var data map[string]interface{}
|
||||||
|
|
||||||
|
got := newEinoRunErrorHandler(einoRunErrorHandlerConfig{
|
||||||
|
ConversationID: "conv-1",
|
||||||
|
Progress: func(eventType, msg string, raw interface{}) {
|
||||||
|
if eventType == "error" {
|
||||||
|
message = msg
|
||||||
|
data, _ = raw.(map[string]interface{})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}).Handle(err)
|
||||||
|
|
||||||
|
if !errors.Is(got, err) {
|
||||||
|
t.Fatalf("err = %v", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(message, "HTTP 429 Too Many Requests") {
|
||||||
|
t.Fatalf("message = %q", message)
|
||||||
|
}
|
||||||
|
if data["errorKind"] != "rate_limit" {
|
||||||
|
t.Fatalf("errorKind = %#v", data["errorKind"])
|
||||||
|
}
|
||||||
|
if data["errorSummary"] != "HTTP 429 Too Many Requests" {
|
||||||
|
t.Fatalf("errorSummary = %#v", data["errorSummary"])
|
||||||
|
}
|
||||||
|
if data["lastError"] != "HTTP 429 Too Many Requests" {
|
||||||
|
t.Fatalf("lastError = %#v", data["lastError"])
|
||||||
|
}
|
||||||
|
if data["modelOriginalError"] != "HTTP 429 Too Many Requests" {
|
||||||
|
t.Fatalf("modelOriginalError = %#v", data["modelOriginalError"])
|
||||||
|
}
|
||||||
|
if _, ok := data["hasModelOriginalError"]; ok {
|
||||||
|
t.Fatalf("hasModelOriginalError should be absent when original error is present, got %#v", data["hasModelOriginalError"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestEinoRunErrorHandlerIterationLimitProgress(t *testing.T) {
|
func TestEinoRunErrorHandlerIterationLimitProgress(t *testing.T) {
|
||||||
var events []string
|
var events []string
|
||||||
var errorKind interface{}
|
var errorKind interface{}
|
||||||
|
|||||||
@@ -57,6 +57,16 @@ func (a *einoRunMessageAccumulator) Messages() []adk.Message {
|
|||||||
return a.msgs
|
return a.msgs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *einoRunMessageAccumulator) NewMessages() []adk.Message {
|
||||||
|
if a == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if a.baseCount < 0 || a.baseCount >= len(a.msgs) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return a.msgs[a.baseCount:]
|
||||||
|
}
|
||||||
|
|
||||||
func (a *einoRunMessageAccumulator) BaseCount() int {
|
func (a *einoRunMessageAccumulator) BaseCount() int {
|
||||||
if a == nil {
|
if a == nil {
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ func TestEinoRunMessageAccumulatorTracksBaseAndAppends(t *testing.T) {
|
|||||||
if len(msgs) != 2 || msgs[1].Role != schema.Assistant || msgs[1].Content != "hello" {
|
if len(msgs) != 2 || msgs[1].Role != schema.Assistant || msgs[1].Content != "hello" {
|
||||||
t.Fatalf("messages = %#v", msgs)
|
t.Fatalf("messages = %#v", msgs)
|
||||||
}
|
}
|
||||||
|
newMsgs := acc.NewMessages()
|
||||||
|
if len(newMsgs) != 1 || newMsgs[0].Content != "hello" {
|
||||||
|
t.Fatalf("new messages = %#v", newMsgs)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEinoRunMessageAccumulatorToolMessage(t *testing.T) {
|
func TestEinoRunMessageAccumulatorToolMessage(t *testing.T) {
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ func (b *einoRunResultBuilder) BuildFinal() *RunResult {
|
|||||||
func (b *einoRunResultBuilder) build(partial bool) *RunResult {
|
func (b *einoRunResultBuilder) build(partial bool) *RunResult {
|
||||||
var runMsgs []adk.Message
|
var runMsgs []adk.Message
|
||||||
if b.cfg.RunMessages != nil {
|
if b.cfg.RunMessages != nil {
|
||||||
runMsgs = b.cfg.RunMessages.Messages()
|
runMsgs = b.cfg.RunMessages.NewMessages()
|
||||||
}
|
}
|
||||||
var lastAssistant string
|
var lastAssistant string
|
||||||
var lastPlanExecuteExecutor string
|
var lastPlanExecuteExecutor string
|
||||||
@@ -107,6 +107,9 @@ func buildEinoRunResultFromAccumulated(
|
|||||||
if cleaned == "" {
|
if cleaned == "" {
|
||||||
if fb := strings.TrimSpace(einoExtractFallbackAssistantFromMsgs(runAccumulatedMsgs)); fb != "" {
|
if fb := strings.TrimSpace(einoExtractFallbackAssistantFromMsgs(runAccumulatedMsgs)); fb != "" {
|
||||||
cleaned = fb
|
cleaned = fb
|
||||||
|
if orchMode == "plan_execute" {
|
||||||
|
cleaned = UnwrapPlanExecuteUserText(cleaned)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cleaned = dedupeRepeatedParagraphs(cleaned, 80)
|
cleaned = dedupeRepeatedParagraphs(cleaned, 80)
|
||||||
@@ -146,32 +149,38 @@ func markModelFacingTraceForPersistence(msgs []adk.Message) []adk.Message {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// einoExtractFallbackAssistantFromMsgs 在「主通道未产出助手正文」时,从 Eino ADK 轨迹中回填用户可见回复。
|
// einoExtractFallbackAssistantFromMsgs 在「主通道未产出助手正文」时,从 Eino ADK
|
||||||
// 典型场景:监督者仅调用 exit(final_result 落在 Tool 消息中),或工具结果已写入历史但 lastAssistant 未更新。
|
// 原生消息轨迹中回填用户可见回复。这里保持克制:只采纳倒序最近的可交付终态,
|
||||||
|
// 避免把工具调用前的过渡语或子任务过程误升为最终回复。
|
||||||
//
|
//
|
||||||
// 优先级:最后一次 exit 工具输出 → 最后一条含 exit 的助手 tool_calls 参数中的 final_result。
|
// 可交付终态:
|
||||||
|
// - exit 工具输出;
|
||||||
|
// - assistant 调用 exit 时 arguments.final_result;
|
||||||
|
// - 没有后续普通工具结果截断的纯 assistant 正文。
|
||||||
func einoExtractFallbackAssistantFromMsgs(msgs []adk.Message) string {
|
func einoExtractFallbackAssistantFromMsgs(msgs []adk.Message) string {
|
||||||
for i := len(msgs) - 1; i >= 0; i-- {
|
for i := len(msgs) - 1; i >= 0; i-- {
|
||||||
m := msgs[i]
|
m := msgs[i]
|
||||||
if m == nil || m.Role != schema.Tool {
|
if m == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !strings.EqualFold(strings.TrimSpace(m.ToolName), adk.ToolInfoExit.Name) {
|
switch m.Role {
|
||||||
continue
|
case schema.Tool:
|
||||||
}
|
if strings.EqualFold(strings.TrimSpace(m.ToolName), adk.ToolInfoExit.Name) {
|
||||||
content := strings.TrimSpace(m.Content)
|
content := strings.TrimSpace(m.Content)
|
||||||
if content == "" || strings.HasPrefix(content, einomcp.ToolErrorPrefix) {
|
if content != "" && !strings.HasPrefix(content, einomcp.ToolErrorPrefix) {
|
||||||
continue
|
return content
|
||||||
}
|
}
|
||||||
return content
|
}
|
||||||
}
|
return ""
|
||||||
for i := len(msgs) - 1; i >= 0; i-- {
|
case schema.Assistant:
|
||||||
m := msgs[i]
|
if s := einoExtractExitFinalFromAssistantToolCalls(m); s != "" {
|
||||||
if m == nil || m.Role != schema.Assistant {
|
return s
|
||||||
continue
|
}
|
||||||
}
|
if len(m.ToolCalls) == 0 {
|
||||||
if s := einoExtractExitFinalFromAssistantToolCalls(m); s != "" {
|
if content := strings.TrimSpace(m.Content); content != "" {
|
||||||
return s
|
return content
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -55,6 +55,24 @@ func TestEinoRunResultBuilderFinalUsesSnapshots(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEinoRunResultBuilderFallbackIgnoresBaseHistory(t *testing.T) {
|
||||||
|
runMessages := newEinoRunMessageAccumulator([]adk.Message{
|
||||||
|
schema.UserMessage("previous request"),
|
||||||
|
schema.AssistantMessage("previous answer", nil),
|
||||||
|
schema.UserMessage("new request"),
|
||||||
|
})
|
||||||
|
|
||||||
|
got := newEinoRunResultBuilder(einoRunResultBuilderConfig{
|
||||||
|
OrchMode: "deep",
|
||||||
|
EmptyHint: "empty",
|
||||||
|
RunMessages: runMessages,
|
||||||
|
}).BuildFinal()
|
||||||
|
|
||||||
|
if got.Response != "empty" {
|
||||||
|
t.Fatalf("response = %q, want empty hint", got.Response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestEinoRunResultBuilderPlanExecutePrefersExecutorOutput(t *testing.T) {
|
func TestEinoRunResultBuilderPlanExecutePrefersExecutorOutput(t *testing.T) {
|
||||||
runMessages := newEinoRunMessageAccumulator(nil)
|
runMessages := newEinoRunMessageAccumulator(nil)
|
||||||
runMessages.Append(schema.AssistantMessage(`{"response":"planner text"}`, nil))
|
runMessages.Append(schema.AssistantMessage(`{"response":"planner text"}`, nil))
|
||||||
@@ -73,3 +91,18 @@ func TestEinoRunResultBuilderPlanExecutePrefersExecutorOutput(t *testing.T) {
|
|||||||
t.Fatalf("response = %q, want executor text", got.Response)
|
t.Fatalf("response = %q, want executor text", got.Response)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEinoRunResultBuilderPlanExecuteUnwrapsFallbackAssistant(t *testing.T) {
|
||||||
|
runMessages := newEinoRunMessageAccumulator(nil)
|
||||||
|
runMessages.Append(schema.AssistantMessage(`{"response":"fallback executor text"}`, nil))
|
||||||
|
|
||||||
|
got := newEinoRunResultBuilder(einoRunResultBuilderConfig{
|
||||||
|
OrchMode: "plan_execute",
|
||||||
|
EmptyHint: "empty",
|
||||||
|
RunMessages: runMessages,
|
||||||
|
}).BuildFinal()
|
||||||
|
|
||||||
|
if got.Response != "fallback executor text" {
|
||||||
|
t.Fatalf("response = %q, want fallback executor text", got.Response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -203,15 +203,18 @@ func RunEinoSingleChatModelAgent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return runEinoADKAgentLoop(ctx, &einoADKRunLoopArgs{
|
return runEinoADKAgentLoop(ctx, &einoADKRunLoopArgs{
|
||||||
OrchMode: "eino_single",
|
OrchMode: "eino_single",
|
||||||
OrchestratorName: einoSingleAgentName,
|
OrchestratorName: einoSingleAgentName,
|
||||||
ConversationID: conversationID,
|
ConversationID: conversationID,
|
||||||
Progress: progress,
|
Progress: progress,
|
||||||
Logger: logger,
|
Logger: logger,
|
||||||
SnapshotMCPIDs: snapshotMCPIDs,
|
SnapshotMCPIDs: snapshotMCPIDs,
|
||||||
StreamsMainAssistant: streamsMainAssistant,
|
StreamsMainAssistant: streamsMainAssistant,
|
||||||
EinoRoleTag: einoRoleTag,
|
EinoRoleTag: einoRoleTag,
|
||||||
CheckpointDir: ma.EinoMiddleware.CheckpointDir,
|
// Chat history recovery is intentionally centralized in last_react_*.
|
||||||
|
// ADK checkpoints are a second persisted model-state channel and make
|
||||||
|
// stale-context bugs hard to reason about across user turns.
|
||||||
|
CheckpointDir: "",
|
||||||
RunRetryMaxAttempts: RunRetryMaxAttemptsFromConfig(&ma.EinoMiddleware),
|
RunRetryMaxAttempts: RunRetryMaxAttemptsFromConfig(&ma.EinoMiddleware),
|
||||||
RunRetryMaxBackoffSec: int(einoRunRetryMaxBackoffFromConfig(&ma.EinoMiddleware).Seconds()),
|
RunRetryMaxBackoffSec: int(einoRunRetryMaxBackoffFromConfig(&ma.EinoMiddleware).Seconds()),
|
||||||
McpIDsMu: &mcpIDsMu,
|
McpIDsMu: &mcpIDsMu,
|
||||||
|
|||||||
@@ -614,15 +614,18 @@ func RunDeepAgent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return runEinoADKAgentLoop(ctx, &einoADKRunLoopArgs{
|
return runEinoADKAgentLoop(ctx, &einoADKRunLoopArgs{
|
||||||
OrchMode: orchMode,
|
OrchMode: orchMode,
|
||||||
OrchestratorName: orchestratorName,
|
OrchestratorName: orchestratorName,
|
||||||
ConversationID: conversationID,
|
ConversationID: conversationID,
|
||||||
Progress: progress,
|
Progress: progress,
|
||||||
Logger: logger,
|
Logger: logger,
|
||||||
SnapshotMCPIDs: snapshotMCPIDs,
|
SnapshotMCPIDs: snapshotMCPIDs,
|
||||||
StreamsMainAssistant: streamsMainAssistant,
|
StreamsMainAssistant: streamsMainAssistant,
|
||||||
EinoRoleTag: einoRoleTag,
|
EinoRoleTag: einoRoleTag,
|
||||||
CheckpointDir: ma.EinoMiddleware.CheckpointDir,
|
// Chat history recovery is intentionally centralized in last_react_*.
|
||||||
|
// ADK checkpoints are a second persisted model-state channel and make
|
||||||
|
// stale-context bugs hard to reason about across user turns.
|
||||||
|
CheckpointDir: "",
|
||||||
RunRetryMaxAttempts: RunRetryMaxAttemptsFromConfig(&ma.EinoMiddleware),
|
RunRetryMaxAttempts: RunRetryMaxAttemptsFromConfig(&ma.EinoMiddleware),
|
||||||
RunRetryMaxBackoffSec: int(einoRunRetryMaxBackoffFromConfig(&ma.EinoMiddleware).Seconds()),
|
RunRetryMaxBackoffSec: int(einoRunRetryMaxBackoffFromConfig(&ma.EinoMiddleware).Seconds()),
|
||||||
McpIDsMu: &mcpIDsMu,
|
McpIDsMu: &mcpIDsMu,
|
||||||
|
|||||||
@@ -205,7 +205,7 @@ func TestReasoningToolChoiceCompatRoundTripperDeepSeek(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestReasoningToolChoiceCompatRoundTripperDeepSeekEndpointWinsOverProfile(t *testing.T) {
|
func TestReasoningToolChoiceCompatRoundTripperOpenAIProfileWinsOverDeepSeekEndpoint(t *testing.T) {
|
||||||
var gotBody string
|
var gotBody string
|
||||||
rt := &reasoningToolChoiceCompatRoundTripper{
|
rt := &reasoningToolChoiceCompatRoundTripper{
|
||||||
cfg: &config.OpenAIConfig{
|
cfg: &config.OpenAIConfig{
|
||||||
@@ -235,11 +235,11 @@ func TestReasoningToolChoiceCompatRoundTripperDeepSeekEndpointWinsOverProfile(t
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if strings.Contains(gotBody, "tool_choice") {
|
if !strings.Contains(gotBody, "tool_choice") {
|
||||||
t.Fatalf("expected DeepSeek tool_choice stripped despite openai_compat profile, got %s", gotBody)
|
t.Fatalf("expected tool_choice preserved for explicit openai_compat profile, got %s", gotBody)
|
||||||
}
|
}
|
||||||
if !strings.Contains(gotBody, "tools") {
|
if !strings.Contains(gotBody, "tools") {
|
||||||
t.Fatalf("expected tools preserved for DeepSeek, got %s", gotBody)
|
t.Fatalf("expected tools preserved for explicit openai_compat profile, got %s", gotBody)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,9 +55,6 @@ func isDeepSeekToolChoiceCompatProfile(cfg *config.OpenAIConfig) bool {
|
|||||||
if cfg == nil {
|
if cfg == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if cfg.IsDeepSeekEndpointOrModel() {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
profile := strings.ToLower(strings.TrimSpace(cfg.Reasoning.ProfileEffective()))
|
profile := strings.ToLower(strings.TrimSpace(cfg.Reasoning.ProfileEffective()))
|
||||||
if profile == "deepseek" || profile == "deepseek_compat" {
|
if profile == "deepseek" || profile == "deepseek_compat" {
|
||||||
return true
|
return true
|
||||||
@@ -65,5 +62,5 @@ func isDeepSeekToolChoiceCompatProfile(cfg *config.OpenAIConfig) bool {
|
|||||||
if profile != "" && profile != "auto" {
|
if profile != "" && profile != "auto" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return false
|
return cfg.IsDeepSeekEndpointOrModel()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ func ApplyPlanExecutePlannerModelConfig(cfg *einoopenai.ChatModelConfig, oa *con
|
|||||||
}
|
}
|
||||||
mergeExtraRequestFields(cfg, oa.Reasoning.ExtraRequestFields)
|
mergeExtraRequestFields(cfg, oa.Reasoning.ExtraRequestFields)
|
||||||
clearReasoningFromChatModelConfig(cfg)
|
clearReasoningFromChatModelConfig(cfg)
|
||||||
if resolveWireProfile(oa, &oa.Reasoning) == wireDeepseek || oa.IsDeepSeekEndpointOrModel() {
|
if resolveWireProfile(oa, &oa.Reasoning) == wireDeepseek {
|
||||||
// DeepSeek enables thinking by default, so omission would not actually
|
// DeepSeek enables thinking by default, so omission would not actually
|
||||||
// disable it for the planner's forced tool-choice requests.
|
// disable it for the planner's forced tool-choice requests.
|
||||||
applyThinkingDisabled(cfg)
|
applyThinkingDisabled(cfg)
|
||||||
@@ -88,9 +88,9 @@ func ApplyToEinoChatModelConfig(cfg *einoopenai.ChatModelConfig, oa *config.Open
|
|||||||
clearReasoningFromChatModelConfig(cfg)
|
clearReasoningFromChatModelConfig(cfg)
|
||||||
// Strict OpenAI endpoints reject unknown `thinking` fields, whereas the
|
// Strict OpenAI endpoints reject unknown `thinking` fields, whereas the
|
||||||
// DeepSeek API enables thinking by default and requires an explicit
|
// DeepSeek API enables thinking by default and requires an explicit
|
||||||
// thinking.type=disabled switch. Detect the actual DeepSeek target even
|
// thinking.type=disabled switch. The configured profile is authoritative;
|
||||||
// when the configured reasoning profile was left as openai_compat.
|
// auto-detection only happens inside resolveWireProfile for profile=auto.
|
||||||
if resolveWireProfile(oa, sr) == wireDeepseek || oa.IsDeepSeekEndpointOrModel() {
|
if resolveWireProfile(oa, sr) == wireDeepseek {
|
||||||
applyThinkingDisabled(cfg)
|
applyThinkingDisabled(cfg)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@@ -132,7 +132,7 @@ func AgenticOpenAIExtraFields(oa *config.OpenAIConfig, client *ClientIntent) map
|
|||||||
fields := cloneExtraRequestFields(sr.ExtraRequestFields)
|
fields := cloneExtraRequestFields(sr.ExtraRequestFields)
|
||||||
if mode == "off" {
|
if mode == "off" {
|
||||||
clearReasoningExtraFields(fields)
|
clearReasoningExtraFields(fields)
|
||||||
if resolveWireProfile(oa, sr) == wireDeepseek || oa.IsDeepSeekEndpointOrModel() {
|
if resolveWireProfile(oa, sr) == wireDeepseek {
|
||||||
if fields == nil {
|
if fields == nil {
|
||||||
fields = make(map[string]any)
|
fields = make(map[string]any)
|
||||||
}
|
}
|
||||||
@@ -194,7 +194,7 @@ func AgenticOpenAIPlannerExtraFields(oa *config.OpenAIConfig) map[string]any {
|
|||||||
}
|
}
|
||||||
fields := cloneExtraRequestFields(oa.Reasoning.ExtraRequestFields)
|
fields := cloneExtraRequestFields(oa.Reasoning.ExtraRequestFields)
|
||||||
clearReasoningExtraFields(fields)
|
clearReasoningExtraFields(fields)
|
||||||
if resolveWireProfile(oa, &oa.Reasoning) == wireDeepseek || oa.IsDeepSeekEndpointOrModel() {
|
if resolveWireProfile(oa, &oa.Reasoning) == wireDeepseek {
|
||||||
if fields == nil {
|
if fields == nil {
|
||||||
fields = make(map[string]any)
|
fields = make(map[string]any)
|
||||||
}
|
}
|
||||||
|
|||||||
+110
-19
@@ -140,7 +140,7 @@ func TestAgenticOpenAIPlannerExtraFields_deepseekDisablesThinking(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAgenticOpenAIPlannerExtraFields_deepseekEndpointWinsOverOpenAIProfile(t *testing.T) {
|
func TestAgenticOpenAIPlannerExtraFields_openAIProfileWinsOverDeepseekEndpoint(t *testing.T) {
|
||||||
oa := &config.OpenAIConfig{
|
oa := &config.OpenAIConfig{
|
||||||
BaseURL: "https://api.deepseek.com/v1",
|
BaseURL: "https://api.deepseek.com/v1",
|
||||||
Model: "deepseek-v4-flash",
|
Model: "deepseek-v4-flash",
|
||||||
@@ -155,12 +155,10 @@ func TestAgenticOpenAIPlannerExtraFields_deepseekEndpointWinsOverOpenAIProfile(t
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
got := AgenticOpenAIPlannerExtraFields(oa)
|
got := AgenticOpenAIPlannerExtraFields(oa)
|
||||||
if _, ok := got["reasoning_effort"]; ok {
|
for _, key := range reasoningPayloadKeysForTest {
|
||||||
t.Fatalf("planner should strip reasoning_effort: %#v", got)
|
if _, ok := got[key]; ok {
|
||||||
}
|
t.Fatalf("planner fields unexpectedly contain %q: %#v", key, got)
|
||||||
thinking, ok := got["thinking"].(map[string]any)
|
}
|
||||||
if !ok || thinking["type"] != "disabled" {
|
|
||||||
t.Fatalf("expected deepseek thinking disabled despite openai_compat profile, got %#v", got)
|
|
||||||
}
|
}
|
||||||
if got["vendor_option"] != true {
|
if got["vendor_option"] != true {
|
||||||
t.Fatalf("vendor option not preserved: %#v", got)
|
t.Fatalf("vendor option not preserved: %#v", got)
|
||||||
@@ -189,7 +187,7 @@ func TestApplyPlanExecutePlannerModelConfig_stripsReasoningWhenGlobalOn(t *testi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestApplyPlanExecutePlannerModelConfig_deepseekEndpointWinsOverOpenAIProfile(t *testing.T) {
|
func TestApplyPlanExecutePlannerModelConfig_openAIProfileWinsOverDeepseekEndpoint(t *testing.T) {
|
||||||
cfg := &einoopenai.ChatModelConfig{ExtraFields: map[string]any{
|
cfg := &einoopenai.ChatModelConfig{ExtraFields: map[string]any{
|
||||||
"thinking": map[string]any{"type": "enabled"},
|
"thinking": map[string]any{"type": "enabled"},
|
||||||
"reasoning_effort": "high",
|
"reasoning_effort": "high",
|
||||||
@@ -205,16 +203,7 @@ func TestApplyPlanExecutePlannerModelConfig_deepseekEndpointWinsOverOpenAIProfil
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
ApplyPlanExecutePlannerModelConfig(cfg, oa)
|
ApplyPlanExecutePlannerModelConfig(cfg, oa)
|
||||||
if cfg.ReasoningEffort != "" {
|
assertNoReasoningFields(t, cfg)
|
||||||
t.Fatalf("expected ReasoningEffort omitted, got %q", cfg.ReasoningEffort)
|
|
||||||
}
|
|
||||||
if _, ok := cfg.ExtraFields["reasoning_effort"]; ok {
|
|
||||||
t.Fatalf("expected reasoning_effort omitted, got %#v", cfg.ExtraFields)
|
|
||||||
}
|
|
||||||
thinking, ok := cfg.ExtraFields["thinking"].(map[string]any)
|
|
||||||
if !ok || thinking["type"] != "disabled" {
|
|
||||||
t.Fatalf("expected deepseek thinking disabled despite openai_compat profile, got %#v", cfg.ExtraFields)
|
|
||||||
}
|
|
||||||
if cfg.ExtraFields["vendor_option"] != true {
|
if cfg.ExtraFields["vendor_option"] != true {
|
||||||
t.Fatalf("expected unrelated extra field preserved, got %#v", cfg.ExtraFields)
|
t.Fatalf("expected unrelated extra field preserved, got %#v", cfg.ExtraFields)
|
||||||
}
|
}
|
||||||
@@ -246,6 +235,89 @@ func TestApplyReasoningOff_omitsAllReasoningFields(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestApplyReasoningOff_openAICompatDeepseekModelOmitsAllReasoningFields(t *testing.T) {
|
||||||
|
allowClient := false
|
||||||
|
cfg := &einoopenai.ChatModelConfig{ExtraFields: map[string]any{
|
||||||
|
"thinking": map[string]any{"type": "enabled"},
|
||||||
|
"reasoning_effort": "high",
|
||||||
|
}}
|
||||||
|
oa := &config.OpenAIConfig{
|
||||||
|
Provider: "openai_compatible",
|
||||||
|
BaseURL: "http://your-gateway:port/v1",
|
||||||
|
Model: "deepseek-v4-flash-0731",
|
||||||
|
Reasoning: config.OpenAIReasoningConfig{
|
||||||
|
Mode: "off",
|
||||||
|
Effort: "high",
|
||||||
|
Profile: "openai_compat",
|
||||||
|
AllowClientReasoning: &allowClient,
|
||||||
|
ExtraRequestFields: map[string]interface{}{
|
||||||
|
"thinking": map[string]any{"type": "disabled"},
|
||||||
|
"output_config": map[string]any{"effort": "high"},
|
||||||
|
"vendor_option": true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ApplyToEinoChatModelConfig(cfg, oa, nil)
|
||||||
|
assertNoReasoningFields(t, cfg)
|
||||||
|
if cfg.ExtraFields["vendor_option"] != true {
|
||||||
|
t.Fatalf("expected unrelated extra field preserved, got %#v", cfg.ExtraFields)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgenticOpenAIExtraFields_openAICompatDeepseekModelOmitsAllReasoningFields(t *testing.T) {
|
||||||
|
oa := &config.OpenAIConfig{
|
||||||
|
Provider: "openai_compatible",
|
||||||
|
BaseURL: "http://your-gateway:port/v1",
|
||||||
|
Model: "deepseek-v4-flash-0731",
|
||||||
|
Reasoning: config.OpenAIReasoningConfig{
|
||||||
|
Mode: "off",
|
||||||
|
Effort: "high",
|
||||||
|
Profile: "openai_compat",
|
||||||
|
ExtraRequestFields: map[string]interface{}{
|
||||||
|
"thinking": map[string]any{"type": "disabled"},
|
||||||
|
"reasoning_effort": "high",
|
||||||
|
"vendor_option": true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
got := AgenticOpenAIExtraFields(oa, nil)
|
||||||
|
for _, key := range reasoningPayloadKeysForTest {
|
||||||
|
if _, ok := got[key]; ok {
|
||||||
|
t.Fatalf("agentic fields unexpectedly contain %q: %#v", key, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got["vendor_option"] != true {
|
||||||
|
t.Fatalf("vendor option not preserved: %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgenticOpenAIPlannerExtraFields_openAICompatDeepseekModelOmitsAllReasoningFields(t *testing.T) {
|
||||||
|
oa := &config.OpenAIConfig{
|
||||||
|
Provider: "openai_compatible",
|
||||||
|
BaseURL: "http://your-gateway:port/v1",
|
||||||
|
Model: "deepseek-v4-flash-0731",
|
||||||
|
Reasoning: config.OpenAIReasoningConfig{
|
||||||
|
Mode: "on",
|
||||||
|
Effort: "high",
|
||||||
|
Profile: "openai_compat",
|
||||||
|
ExtraRequestFields: map[string]interface{}{
|
||||||
|
"thinking": map[string]any{"type": "enabled"},
|
||||||
|
"reasoning_effort": "high",
|
||||||
|
"vendor_option": true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
got := AgenticOpenAIPlannerExtraFields(oa)
|
||||||
|
for _, key := range reasoningPayloadKeysForTest {
|
||||||
|
if _, ok := got[key]; ok {
|
||||||
|
t.Fatalf("planner fields unexpectedly contain %q: %#v", key, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got["vendor_option"] != true {
|
||||||
|
t.Fatalf("vendor option not preserved: %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestApplyReasoningOff_clientOverrideOmit(t *testing.T) {
|
func TestApplyReasoningOff_clientOverrideOmit(t *testing.T) {
|
||||||
cfg := &einoopenai.ChatModelConfig{}
|
cfg := &einoopenai.ChatModelConfig{}
|
||||||
oa := &config.OpenAIConfig{Reasoning: config.OpenAIReasoningConfig{
|
oa := &config.OpenAIConfig{Reasoning: config.OpenAIReasoningConfig{
|
||||||
@@ -256,7 +328,7 @@ func TestApplyReasoningOff_clientOverrideOmit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestApplyReasoningOff_deepseekExplicitlyDisablesDefaultThinking(t *testing.T) {
|
func TestApplyReasoningOff_deepseekExplicitlyDisablesDefaultThinking(t *testing.T) {
|
||||||
for _, profile := range []string{"deepseek_compat", "auto", "openai_compat"} {
|
for _, profile := range []string{"deepseek_compat", "auto"} {
|
||||||
t.Run(profile, func(t *testing.T) {
|
t.Run(profile, func(t *testing.T) {
|
||||||
cfg := &einoopenai.ChatModelConfig{ExtraFields: map[string]any{
|
cfg := &einoopenai.ChatModelConfig{ExtraFields: map[string]any{
|
||||||
"reasoning_effort": "high",
|
"reasoning_effort": "high",
|
||||||
@@ -287,6 +359,25 @@ func TestApplyReasoningOff_deepseekExplicitlyDisablesDefaultThinking(t *testing.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestApplyReasoningOff_openAIProfileWinsOverDeepseekEndpoint(t *testing.T) {
|
||||||
|
cfg := &einoopenai.ChatModelConfig{ExtraFields: map[string]any{
|
||||||
|
"reasoning_effort": "high",
|
||||||
|
"vendor_option": true,
|
||||||
|
}}
|
||||||
|
oa := &config.OpenAIConfig{
|
||||||
|
BaseURL: "https://api.deepseek.com",
|
||||||
|
Model: "deepseek-v4-pro",
|
||||||
|
Reasoning: config.OpenAIReasoningConfig{
|
||||||
|
Mode: "off", Effort: "high", Profile: "openai_compat",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ApplyToEinoChatModelConfig(cfg, oa, nil)
|
||||||
|
assertNoReasoningFields(t, cfg)
|
||||||
|
if cfg.ExtraFields["vendor_option"] != true {
|
||||||
|
t.Fatalf("expected unrelated extra field preserved, got %#v", cfg.ExtraFields)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestApplyReasoningOff_wirePayloadOmitsThinking(t *testing.T) {
|
func TestApplyReasoningOff_wirePayloadOmitsThinking(t *testing.T) {
|
||||||
var requestBody map[string]any
|
var requestBody map[string]any
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
+140
-64
@@ -858,7 +858,7 @@ html[data-theme="dark"] .vulnerability-alert-switch input:disabled + .vulnerabil
|
|||||||
}
|
}
|
||||||
|
|
||||||
.conversation-sidebar {
|
.conversation-sidebar {
|
||||||
width: 280px;
|
width: 320px;
|
||||||
background: linear-gradient(180deg, #ffffff 0%, #fafbfc 100%);
|
background: linear-gradient(180deg, #ffffff 0%, #fafbfc 100%);
|
||||||
border-right: 1px solid var(--border-color);
|
border-right: 1px solid var(--border-color);
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -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,
|
||||||
@@ -44995,7 +45048,7 @@ html[data-theme="dark"] .project-folder-preview-edit:focus-visible {
|
|||||||
.project-conversation-preview {
|
.project-conversation-preview {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 1200;
|
z-index: 1200;
|
||||||
width: min(300px, calc(100vw - 32px));
|
width: min(340px, calc(100vw - 32px));
|
||||||
padding: 12px 14px 11px;
|
padding: 12px 14px 11px;
|
||||||
border: 1px solid rgba(30, 41, 59, 0.15);
|
border: 1px solid rgba(30, 41, 59, 0.15);
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
@@ -45011,11 +45064,12 @@ html[data-theme="dark"] .project-folder-preview-edit:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.project-conversation-preview-header {
|
.project-conversation-preview-header {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
flex-direction: column;
|
||||||
align-items: baseline;
|
align-items: flex-start;
|
||||||
gap: 10px;
|
gap: 3px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-conversation-preview-title {
|
.project-conversation-preview-title {
|
||||||
@@ -45025,8 +45079,11 @@ html[data-theme="dark"] .project-folder-preview-edit:focus-visible {
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
text-overflow: ellipsis;
|
display: -webkit-box;
|
||||||
white-space: nowrap;
|
-webkit-line-clamp: 2;
|
||||||
|
line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-conversation-preview-age {
|
.project-conversation-preview-age {
|
||||||
@@ -45058,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;
|
||||||
@@ -46346,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;
|
||||||
@@ -46404,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 {
|
||||||
@@ -46422,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\)/);
|
||||||
|
});
|
||||||
+259
-23
@@ -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);
|
||||||
@@ -4428,7 +4485,7 @@ function renderProcessDetails(messageId, processDetails, options) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!timelineOpts.toolStatus && eventType === 'tool_call' && detail.id && toolStatusByProcessDetailId.has(String(detail.id))) {
|
if (eventType === 'tool_call' && detail.id && toolStatusByProcessDetailId.has(String(detail.id))) {
|
||||||
timelineOpts.toolStatus = toolStatusByProcessDetailId.get(String(detail.id));
|
timelineOpts.toolStatus = toolStatusByProcessDetailId.get(String(detail.id));
|
||||||
}
|
}
|
||||||
const itemId = addTimelineItem(timeline, eventType, timelineOpts);
|
const itemId = addTimelineItem(timeline, eventType, timelineOpts);
|
||||||
@@ -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';
|
||||||
|
|||||||
+110
-14
@@ -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);
|
||||||
@@ -3372,6 +3374,18 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'finalization_pending_tools_cancelled': {
|
||||||
|
const d = event.data || {};
|
||||||
|
markToolExecutionItemsCancelled(timeline, autoCancelledExecutionIdsFromData(d));
|
||||||
|
addTimelineItem(timeline, 'progress', {
|
||||||
|
title: '工具执行已收尾',
|
||||||
|
message: event.message,
|
||||||
|
data: d,
|
||||||
|
expanded: false
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case 'hitl_audit_agent_started': {
|
case 'hitl_audit_agent_started': {
|
||||||
const auditData = Object.assign({}, event.data || {}, {
|
const auditData = Object.assign({}, event.data || {}, {
|
||||||
reviewer: 'audit_agent',
|
reviewer: 'audit_agent',
|
||||||
@@ -3984,6 +3998,7 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
const responseData = event.data || {};
|
const responseData = event.data || {};
|
||||||
const mcpIds = mergeMcpExecutionIDLists(typeof getMcpIds === 'function' ? (getMcpIds() || []) : [], responseData.mcpExecutionIds || []);
|
const mcpIds = mergeMcpExecutionIDLists(typeof getMcpIds === 'function' ? (getMcpIds() || []) : [], responseData.mcpExecutionIds || []);
|
||||||
setMcpIds(mcpIds);
|
setMcpIds(mcpIds);
|
||||||
|
markToolExecutionItemsCancelled(timeline, autoCancelledExecutionIdsFromData(responseData));
|
||||||
|
|
||||||
// 更新对话ID
|
// 更新对话ID
|
||||||
if (responseData.conversationId) {
|
if (responseData.conversationId) {
|
||||||
@@ -5886,6 +5901,9 @@ function getToolResultDisplayState(data, opts) {
|
|||||||
}
|
}
|
||||||
return { kind: 'background_running', isError: false, success: false };
|
return { kind: 'background_running', isError: false, success: false };
|
||||||
}
|
}
|
||||||
|
if (explicitStatus === 'cancelled' || explicitStatus === 'canceled') {
|
||||||
|
return { kind: 'cancelled', isError: true, success: false };
|
||||||
|
}
|
||||||
const parts = [];
|
const parts = [];
|
||||||
if (opts.rawText != null) parts.push(String(opts.rawText));
|
if (opts.rawText != null) parts.push(String(opts.rawText));
|
||||||
collectToolResultTextParts(data.result, parts, 0);
|
collectToolResultTextParts(data.result, parts, 0);
|
||||||
@@ -5917,6 +5935,13 @@ function getBackgroundRunningToolLabel() {
|
|||||||
return '后台执行中';
|
return '后台执行中';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toolDisplayStatusFromState(displayState) {
|
||||||
|
if (!displayState) return 'completed';
|
||||||
|
if (displayState.kind === 'background_running') return 'background_running';
|
||||||
|
if (displayState.kind === 'cancelled') return 'cancelled';
|
||||||
|
return displayState.isError ? 'failed' : 'completed';
|
||||||
|
}
|
||||||
|
|
||||||
function buildToolResultSectionHtml(data, opts) {
|
function buildToolResultSectionHtml(data, opts) {
|
||||||
opts = opts || {};
|
opts = opts || {};
|
||||||
const _t = function (k, o) {
|
const _t = function (k, o) {
|
||||||
@@ -6158,7 +6183,10 @@ function mergeToolResultIntoCallItem(item, data, options) {
|
|||||||
}
|
}
|
||||||
item.dataset.toolResultMerged = '1';
|
item.dataset.toolResultMerged = '1';
|
||||||
item.dataset.toolSuccess = (!displayState.isError && !backgroundRunning) ? '1' : '0';
|
item.dataset.toolSuccess = (!displayState.isError && !backgroundRunning) ? '1' : '0';
|
||||||
item.dataset.toolDisplayStatus = backgroundRunning ? 'background_running' : (displayState.isError ? 'failed' : 'completed');
|
item.dataset.toolDisplayStatus = toolDisplayStatusFromState(displayState);
|
||||||
|
if (data.executionId != null && String(data.executionId).trim() !== '') {
|
||||||
|
item.dataset.toolExecutionId = String(data.executionId).trim();
|
||||||
|
}
|
||||||
item.classList.remove('tool-call-running', 'tool-call-completed', 'tool-call-failed');
|
item.classList.remove('tool-call-running', 'tool-call-completed', 'tool-call-failed');
|
||||||
item.classList.add(backgroundRunning ? 'tool-call-running' : (displayState.isError ? 'tool-call-failed' : 'tool-call-completed'));
|
item.classList.add(backgroundRunning ? 'tool-call-running' : (displayState.isError ? 'tool-call-failed' : 'tool-call-completed'));
|
||||||
applyToolCallStatus(item, item.dataset.toolDisplayStatus);
|
applyToolCallStatus(item, item.dataset.toolDisplayStatus);
|
||||||
@@ -6198,7 +6226,10 @@ function mergeToolResultIntoCallItem(item, data, options) {
|
|||||||
|
|
||||||
item.dataset.toolResultMerged = '1';
|
item.dataset.toolResultMerged = '1';
|
||||||
item.dataset.toolSuccess = (!displayState.isError && !backgroundRunning) ? '1' : '0';
|
item.dataset.toolSuccess = (!displayState.isError && !backgroundRunning) ? '1' : '0';
|
||||||
item.dataset.toolDisplayStatus = backgroundRunning ? 'background_running' : (displayState.isError ? 'failed' : 'completed');
|
item.dataset.toolDisplayStatus = toolDisplayStatusFromState(displayState);
|
||||||
|
if (data.executionId != null && String(data.executionId).trim() !== '') {
|
||||||
|
item.dataset.toolExecutionId = String(data.executionId).trim();
|
||||||
|
}
|
||||||
item.classList.remove('tool-call-running', 'tool-call-completed', 'tool-call-failed');
|
item.classList.remove('tool-call-running', 'tool-call-completed', 'tool-call-failed');
|
||||||
item.classList.add(backgroundRunning ? 'tool-call-running' : (displayState.isError ? 'tool-call-failed' : 'tool-call-completed'));
|
item.classList.add(backgroundRunning ? 'tool-call-running' : (displayState.isError ? 'tool-call-failed' : 'tool-call-completed'));
|
||||||
applyToolCallStatus(item, item.dataset.toolDisplayStatus);
|
applyToolCallStatus(item, item.dataset.toolDisplayStatus);
|
||||||
@@ -6361,6 +6392,9 @@ function getToolCallStatusPresentation(status) {
|
|||||||
if (normalized === 'failed') {
|
if (normalized === 'failed') {
|
||||||
return { status: normalized, itemClass: 'tool-call-failed', badgeClass: 'tool-status-failed', label: translate('timeline.execFailed', '执行失败'), icon: '❌ ' };
|
return { status: normalized, itemClass: 'tool-call-failed', badgeClass: 'tool-status-failed', label: translate('timeline.execFailed', '执行失败'), icon: '❌ ' };
|
||||||
}
|
}
|
||||||
|
if (normalized === 'cancelled' || normalized === 'canceled') {
|
||||||
|
return { status: 'cancelled', itemClass: 'tool-call-failed', badgeClass: 'tool-status-failed', label: translate('tasks.statusCancelled', '已取消'), icon: '⛔ ' };
|
||||||
|
}
|
||||||
if (normalized === 'result_missing') {
|
if (normalized === 'result_missing') {
|
||||||
return { status: normalized, itemClass: 'tool-call-incomplete', badgeClass: 'tool-status-incomplete', label: translate('timeline.resultMissing', '结果记录缺失'), icon: '⚠️ ' };
|
return { status: normalized, itemClass: 'tool-call-incomplete', badgeClass: 'tool-status-incomplete', label: translate('timeline.resultMissing', '结果记录缺失'), icon: '⚠️ ' };
|
||||||
}
|
}
|
||||||
@@ -6401,6 +6435,52 @@ function updateToolCallStatus(progressId, toolCallId, status) {
|
|||||||
applyToolCallStatus(item, status);
|
applyToolCallStatus(item, status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeExecutionIdList(value) {
|
||||||
|
const input = Array.isArray(value) ? value : (value == null ? [] : [value]);
|
||||||
|
const seen = new Set();
|
||||||
|
const out = [];
|
||||||
|
input.forEach(function (v) {
|
||||||
|
const s = String(v == null ? '' : v).trim();
|
||||||
|
if (!s || seen.has(s)) return;
|
||||||
|
seen.add(s);
|
||||||
|
out.push(s);
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function autoCancelledExecutionIdsFromData(data) {
|
||||||
|
data = data || {};
|
||||||
|
return normalizeExecutionIdList(data.autoCancelledPendingExecutionIds || data.autoCancelledExecutionIds || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function markToolExecutionItemsCancelled(root, executionIds) {
|
||||||
|
const ids = normalizeExecutionIdList(executionIds);
|
||||||
|
if (!root || ids.length === 0) return 0;
|
||||||
|
const idSet = new Set(ids);
|
||||||
|
let count = 0;
|
||||||
|
root.querySelectorAll('.timeline-item[data-tool-execution-id]').forEach(function (item) {
|
||||||
|
const execId = String(item.dataset.toolExecutionId || '').trim();
|
||||||
|
if (!execId || !idSet.has(execId)) return;
|
||||||
|
item.dataset.toolSuccess = '0';
|
||||||
|
item.dataset.toolDisplayStatus = 'cancelled';
|
||||||
|
item.classList.remove('tool-call-running', 'tool-call-completed', 'tool-call-incomplete');
|
||||||
|
item.classList.add('tool-call-failed');
|
||||||
|
const state = toolCallDetailStateByItemId.get(item.id);
|
||||||
|
if (state && state.resultData && typeof state.resultData === 'object') {
|
||||||
|
state.resultData = Object.assign({}, state.resultData, {
|
||||||
|
status: 'cancelled',
|
||||||
|
success: false,
|
||||||
|
isError: true
|
||||||
|
});
|
||||||
|
state.pending = false;
|
||||||
|
setToolCallDetailState(item, state);
|
||||||
|
}
|
||||||
|
applyToolCallStatus(item, 'cancelled');
|
||||||
|
count++;
|
||||||
|
});
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
// 添加时间线项目
|
// 添加时间线项目
|
||||||
function buildWorkflowConditionResultHtml(data) {
|
function buildWorkflowConditionResultHtml(data) {
|
||||||
const output = (data && data.output) || {};
|
const output = (data && data.output) || {};
|
||||||
@@ -6547,17 +6627,23 @@ function addTimelineItem(timeline, type, options) {
|
|||||||
const mergedDisplayState = merged ? getToolResultDisplayState(merged) : null;
|
const mergedDisplayState = merged ? getToolResultDisplayState(merged) : null;
|
||||||
const mergedBackgroundRunning = mergedDisplayState && mergedDisplayState.kind === 'background_running';
|
const mergedBackgroundRunning = mergedDisplayState && mergedDisplayState.kind === 'background_running';
|
||||||
const terminalStatus = String(options.toolStatus || '').toLowerCase();
|
const terminalStatus = String(options.toolStatus || '').toLowerCase();
|
||||||
|
const forcedStatus = (terminalStatus === 'completed' || terminalStatus === 'failed' || terminalStatus === 'cancelled' || terminalStatus === 'canceled')
|
||||||
|
? (terminalStatus === 'canceled' ? 'cancelled' : terminalStatus)
|
||||||
|
: '';
|
||||||
if (merged) {
|
if (merged) {
|
||||||
item.dataset.toolResultMerged = '1';
|
item.dataset.toolResultMerged = '1';
|
||||||
item.dataset.toolSuccess = (!mergedDisplayState.isError && !mergedBackgroundRunning) ? '1' : '0';
|
item.dataset.toolSuccess = forcedStatus ? (forcedStatus === 'completed' ? '1' : '0') : ((!mergedDisplayState.isError && !mergedBackgroundRunning) ? '1' : '0');
|
||||||
item.dataset.toolDisplayStatus = mergedBackgroundRunning ? 'background_running' : (mergedDisplayState.isError ? 'failed' : 'completed');
|
item.dataset.toolDisplayStatus = forcedStatus || toolDisplayStatusFromState(mergedDisplayState);
|
||||||
item.classList.add(mergedBackgroundRunning ? 'tool-call-running' : (mergedDisplayState.isError ? 'tool-call-failed' : 'tool-call-completed'));
|
if (merged.executionId != null && String(merged.executionId).trim() !== '') {
|
||||||
|
item.dataset.toolExecutionId = String(merged.executionId).trim();
|
||||||
|
}
|
||||||
|
item.classList.add(item.dataset.toolDisplayStatus === 'background_running' ? 'tool-call-running' : (item.dataset.toolDisplayStatus === 'completed' ? 'tool-call-completed' : 'tool-call-failed'));
|
||||||
if (d._mergedResultDetailId) {
|
if (d._mergedResultDetailId) {
|
||||||
item.dataset.toolResultDetailId = String(d._mergedResultDetailId);
|
item.dataset.toolResultDetailId = String(d._mergedResultDetailId);
|
||||||
}
|
}
|
||||||
} else if (terminalStatus === 'completed' || terminalStatus === 'failed') {
|
} else if (terminalStatus === 'completed' || terminalStatus === 'failed' || terminalStatus === 'cancelled' || terminalStatus === 'canceled') {
|
||||||
item.dataset.toolSuccess = terminalStatus === 'completed' ? '1' : '0';
|
item.dataset.toolSuccess = terminalStatus === 'completed' ? '1' : '0';
|
||||||
item.dataset.toolDisplayStatus = terminalStatus;
|
item.dataset.toolDisplayStatus = terminalStatus === 'canceled' ? 'cancelled' : terminalStatus;
|
||||||
item.classList.add(terminalStatus === 'completed' ? 'tool-call-completed' : 'tool-call-failed');
|
item.classList.add(terminalStatus === 'completed' ? 'tool-call-completed' : 'tool-call-failed');
|
||||||
} else if (terminalStatus === 'result_missing') {
|
} else if (terminalStatus === 'result_missing') {
|
||||||
item.dataset.toolDisplayStatus = 'result_missing';
|
item.dataset.toolDisplayStatus = 'result_missing';
|
||||||
@@ -6588,7 +6674,10 @@ function addTimelineItem(timeline, type, options) {
|
|||||||
}
|
}
|
||||||
item.dataset.toolName = (d.toolName != null && d.toolName !== '') ? String(d.toolName) : '';
|
item.dataset.toolName = (d.toolName != null && d.toolName !== '') ? String(d.toolName) : '';
|
||||||
item.dataset.toolSuccess = (!displayState.isError && displayState.kind !== 'background_running') ? '1' : '0';
|
item.dataset.toolSuccess = (!displayState.isError && displayState.kind !== 'background_running') ? '1' : '0';
|
||||||
item.dataset.toolDisplayStatus = displayState.kind === 'background_running' ? 'background_running' : (displayState.isError ? 'failed' : 'completed');
|
item.dataset.toolDisplayStatus = toolDisplayStatusFromState(displayState);
|
||||||
|
if (d.executionId != null && String(d.executionId).trim() !== '') {
|
||||||
|
item.dataset.toolExecutionId = String(d.executionId).trim();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (type === 'eino_usage_summary' && options.data) {
|
if (type === 'eino_usage_summary' && options.data) {
|
||||||
const d = options.data;
|
const d = options.data;
|
||||||
@@ -6658,10 +6747,14 @@ function addTimelineItem(timeline, type, options) {
|
|||||||
const mergedDisplayState = merged ? getToolResultDisplayState(merged) : null;
|
const mergedDisplayState = merged ? getToolResultDisplayState(merged) : null;
|
||||||
const mergedBackgroundRunning = mergedDisplayState && mergedDisplayState.kind === 'background_running';
|
const mergedBackgroundRunning = mergedDisplayState && mergedDisplayState.kind === 'background_running';
|
||||||
const terminalStatus = String(options.toolStatus || '').toLowerCase();
|
const terminalStatus = String(options.toolStatus || '').toLowerCase();
|
||||||
const hasTerminalStatus = terminalStatus === 'completed' || terminalStatus === 'failed';
|
const forcedStatus = (terminalStatus === 'completed' || terminalStatus === 'failed' || terminalStatus === 'cancelled' || terminalStatus === 'canceled')
|
||||||
|
? (terminalStatus === 'canceled' ? 'cancelled' : terminalStatus)
|
||||||
|
: '';
|
||||||
|
const hasTerminalStatus = terminalStatus === 'completed' || terminalStatus === 'failed' || terminalStatus === 'cancelled' || terminalStatus === 'canceled';
|
||||||
const hasHistoricalStatus = hasTerminalStatus || terminalStatus === 'result_missing';
|
const hasHistoricalStatus = hasTerminalStatus || terminalStatus === 'result_missing';
|
||||||
if (merged) {
|
if (merged) {
|
||||||
item.classList.add(mergedBackgroundRunning ? 'tool-call-running' : (mergedDisplayState.isError ? 'tool-call-failed' : 'tool-call-completed'));
|
const statusForClass = forcedStatus || toolDisplayStatusFromState(mergedDisplayState);
|
||||||
|
item.classList.add(statusForClass === 'background_running' ? 'tool-call-running' : (statusForClass === 'completed' ? 'tool-call-completed' : 'tool-call-failed'));
|
||||||
} else if (hasTerminalStatus) {
|
} else if (hasTerminalStatus) {
|
||||||
item.classList.add(terminalStatus === 'completed' ? 'tool-call-completed' : 'tool-call-failed');
|
item.classList.add(terminalStatus === 'completed' ? 'tool-call-completed' : 'tool-call-failed');
|
||||||
} else if (terminalStatus === 'result_missing') {
|
} else if (terminalStatus === 'result_missing') {
|
||||||
@@ -6671,7 +6764,7 @@ function addTimelineItem(timeline, type, options) {
|
|||||||
}
|
}
|
||||||
setToolCallDetailState(item, {
|
setToolCallDetailState(item, {
|
||||||
args: args,
|
args: args,
|
||||||
resultData: merged || null,
|
resultData: (merged && forcedStatus) ? Object.assign({}, merged, { status: forcedStatus, success: forcedStatus === 'completed', isError: forcedStatus !== 'completed' }) : (merged || null),
|
||||||
pending: !merged && !hasHistoricalStatus && !options.skipPendingResult,
|
pending: !merged && !hasHistoricalStatus && !options.skipPendingResult,
|
||||||
processDetailId: options.processDetailId || '',
|
processDetailId: options.processDetailId || '',
|
||||||
resultDetailId: data._mergedResultDetailId || (merged && merged.processDetailId) || '',
|
resultDetailId: data._mergedResultDetailId || (merged && merged.processDetailId) || '',
|
||||||
@@ -6723,7 +6816,10 @@ function addTimelineItem(timeline, type, options) {
|
|||||||
payloadDeferred: data._payloadDeferred === true,
|
payloadDeferred: data._payloadDeferred === true,
|
||||||
payloadLoaded: data._payloadDeferred !== true
|
payloadLoaded: data._payloadDeferred !== true
|
||||||
});
|
});
|
||||||
item.dataset.toolDisplayStatus = displayState.kind === 'background_running' ? 'background_running' : (displayState.isError ? 'failed' : 'completed');
|
item.dataset.toolDisplayStatus = toolDisplayStatusFromState(displayState);
|
||||||
|
if (data.executionId != null && String(data.executionId).trim() !== '') {
|
||||||
|
item.dataset.toolExecutionId = String(data.executionId).trim();
|
||||||
|
}
|
||||||
item.classList.add(displayState.kind === 'background_running' ? 'tool-call-running' : (displayState.isError ? 'tool-call-failed' : 'tool-call-completed'));
|
item.classList.add(displayState.kind === 'background_running' ? 'tool-call-running' : (displayState.isError ? 'tool-call-failed' : 'tool-call-completed'));
|
||||||
} else if (type === 'cancelled') {
|
} else if (type === 'cancelled') {
|
||||||
const taskCancelledLabel = typeof window.t === 'function' ? window.t('chat.taskCancelled') : '任务已取消';
|
const taskCancelledLabel = typeof window.t === 'function' ? window.t('chat.taskCancelled') : '任务已取消';
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ function functionSource(source, name, nextName) {
|
|||||||
return source.slice(start, end);
|
return source.slice(start, end);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cssBlock(source, selector) {
|
||||||
|
const match = source.match(new RegExp(`${selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\{[^}]*\\}`));
|
||||||
|
assert.ok(match, `${selector} style block should exist`);
|
||||||
|
return match[0];
|
||||||
|
}
|
||||||
|
|
||||||
test('无项目文件夹与普通项目共用悬浮和键盘聚焦预览', () => {
|
test('无项目文件夹与普通项目共用悬浮和键盘聚焦预览', () => {
|
||||||
const source = functionSource(projects, 'appendChatProjectFolderItem', 'appendChatProjectConversationItem');
|
const source = functionSource(projects, 'appendChatProjectFolderItem', 'appendChatProjectConversationItem');
|
||||||
|
|
||||||
@@ -122,3 +128,27 @@ test('对话悬浮预览显示本地年月日时分', () => {
|
|||||||
assert.match(zh, /"conversationPreviewDateTime": "\{\{year\}\}年\{\{month\}\}月\{\{day\}\}日 \{\{hour\}\}:\{\{minute\}\}"/);
|
assert.match(zh, /"conversationPreviewDateTime": "\{\{year\}\}年\{\{month\}\}月\{\{day\}\}日 \{\{hour\}\}:\{\{minute\}\}"/);
|
||||||
assert.match(en, /"conversationPreviewDateTime": "\{\{year\}\}-\{\{month\}\}-\{\{day\}\} \{\{hour\}\}:\{\{minute\}\}"/);
|
assert.match(en, /"conversationPreviewDateTime": "\{\{year\}\}-\{\{month\}\}-\{\{day\}\} \{\{hour\}\}:\{\{minute\}\}"/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('对话悬浮预览标题与时间分行显示并保留更多标题内容', () => {
|
||||||
|
const titleStyles = cssBlock(styles, '.project-conversation-preview-title');
|
||||||
|
|
||||||
|
assert.match(styles, /\.conversation-sidebar\s*\{[\s\S]*?width: 320px;/);
|
||||||
|
assert.match(styles, /\.project-conversation-preview\s*\{[\s\S]*?width: min\(340px, calc\(100vw - 32px\)\);/);
|
||||||
|
assert.match(styles, /\.project-conversation-preview-header\s*\{[\s\S]*?flex-direction: column;[\s\S]*?align-items: flex-start;/);
|
||||||
|
assert.match(titleStyles, /-webkit-line-clamp: 2;/);
|
||||||
|
assert.doesNotMatch(titleStyles, /white-space: nowrap;/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('对话悬浮预览使用美化后的代理模式徽标', () => {
|
||||||
|
const projects = fs.readFileSync('web/static/js/projects.js', 'utf8');
|
||||||
|
|
||||||
|
assert.match(projects, /function getProjectConversationModeIconClass\(conversation\)/);
|
||||||
|
assert.match(projects, /project-conversation-preview-mode-icon agent-mode-logo agent-mode-logo--default/);
|
||||||
|
assert.match(projects, /agent-mode-logo__svg/);
|
||||||
|
assert.match(projects, /<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