mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-09-09 03:08:56 +02:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97d53193d1 |
+3
-7
@@ -10,7 +10,7 @@
|
||||
# ============================================
|
||||
|
||||
# 前端显示的版本号(可选,不填则显示默认版本)
|
||||
version: "v1.7.17"
|
||||
version: "v1.7.15"
|
||||
# 服务器配置
|
||||
server:
|
||||
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
|
||||
@@ -135,12 +135,8 @@ agent:
|
||||
# approval → audit_agent_prompt
|
||||
# review_edit → audit_agent_prompt_review_edit(可改参后放行)
|
||||
hitl:
|
||||
# 全局默认人机协同模式:off=关闭,approval=审批模式,review_edit=审查编辑;新建会话无独立配置时沿用
|
||||
default_mode: off
|
||||
# 全局默认审批方:human=人工审批,audit_agent=审计 Agent;新建会话无独立配置时沿用
|
||||
# 全局默认审批方:human=人工审批,audit_agent=审计 Agent;未选会话时切换会写入本项,重启后仍生效
|
||||
default_reviewer: human
|
||||
# 全局默认审批等待时限(秒):300=5分钟,0=不限时;新建会话无独立配置时沿用
|
||||
default_timeout_seconds: 300
|
||||
# 审计 Agent 专用模型;字段留空则复用上方 openai 配置。建议 model 填小模型,用于降低审批成本。
|
||||
audit_model:
|
||||
provider: "" # openai / claude;留空跟随 openai.provider
|
||||
@@ -308,7 +304,7 @@ multi_agent:
|
||||
plan_execute_executed_steps_budget_ratio: 0.2 # plan_execute 中 executed_steps 预算比例
|
||||
plan_execute_max_step_result_runes: 4000 # plan_execute 每步结果最大字符数(超出截断)
|
||||
plan_execute_keep_last_steps: 8 # plan_execute 仅保留最近 N 步正文,早期步骤折叠为标题
|
||||
checkpoint_dir: "" # 聊天链路不再使用 ADK checkpoint;跨轮模型态统一走 conversations.last_react_*,便于排查 stale context
|
||||
checkpoint_dir: data/eino-checkpoints # P0:进程崩溃/OOM 后同会话自动 ADK Resume;正常结束会删 .ckpt;与「中断并继续」(last_react_*) 是两套机制
|
||||
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_failover_channels: [] # Eino 原生 ChatModel failover;填写 ai.channels ID,例如 [qwen-plus];retry 耗尽后按顺序切换
|
||||
|
||||
@@ -65,6 +65,12 @@ func FromRunResult(db *database.DB, result *multiagent.RunResult, in Input) Deci
|
||||
if len(in.MCPExecutionIDs) == 0 {
|
||||
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)
|
||||
if result != nil {
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
|
||||
"cyberstrike-ai/internal/database"
|
||||
"cyberstrike-ai/internal/mcp"
|
||||
"cyberstrike-ai/internal/multiagent"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
@@ -131,23 +130,3 @@ func TestDecideAllowsInformationalAnswerWhenExecutionEvidenceIsNotRequired(t *te
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -972,8 +972,6 @@ func setupRoutes(
|
||||
protected.GET("/hitl/tool-whitelist", agentHandler.GetHITLGlobalToolWhitelist)
|
||||
protected.PUT("/hitl/tool-whitelist", agentHandler.SetHITLGlobalToolWhitelist)
|
||||
protected.POST("/hitl/tool-whitelist", agentHandler.MergeHITLGlobalToolWhitelist)
|
||||
protected.GET("/hitl/default-config", agentHandler.GetHITLDefaultConfig)
|
||||
protected.PUT("/hitl/default-config", agentHandler.UpdateHITLDefaultConfig)
|
||||
protected.GET("/hitl/default-reviewer", agentHandler.GetHITLDefaultReviewer)
|
||||
protected.PUT("/hitl/default-reviewer", agentHandler.UpdateHITLDefaultReviewer)
|
||||
protected.GET("/hitl/audit-strategy", agentHandler.GetHITLAuditStrategy)
|
||||
@@ -1029,11 +1027,9 @@ func setupRoutes(
|
||||
protected.DELETE("/batch-tasks/:queueId/tasks/:taskId", agentHandler.DeleteBatchTask)
|
||||
|
||||
// 对话历史
|
||||
protected.GET("/usage/tokens", conversationHandler.GetTokenUsageStats)
|
||||
protected.POST("/conversations", conversationHandler.CreateConversation)
|
||||
protected.GET("/conversations", conversationHandler.ListConversations)
|
||||
protected.GET("/conversations/:id", conversationHandler.GetConversation)
|
||||
protected.GET("/conversations/:id/token-usage", conversationHandler.GetConversationTokenUsageStats)
|
||||
protected.GET("/conversations/:id/plan-tasks", conversationHandler.GetConversationPlanTasks)
|
||||
protected.GET("/messages/:id/process-details", conversationHandler.GetMessageProcessDetails)
|
||||
protected.GET("/process-details/:id", conversationHandler.GetProcessDetail)
|
||||
|
||||
@@ -298,8 +298,7 @@ type MultiAgentEinoMiddlewareConfig struct {
|
||||
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 int `yaml:"plan_execute_keep_last_steps,omitempty" json:"plan_execute_keep_last_steps,omitempty"`
|
||||
// CheckpointDir is retained for config compatibility. Chat agent runs do
|
||||
// not consume it; cross-turn recovery is centralized in conversations.last_react_*.
|
||||
// CheckpointDir when non-empty enables adk.Runner CheckPointStore (file-backed) for interrupt/resume persistence.
|
||||
CheckpointDir string `yaml:"checkpoint_dir,omitempty" json:"checkpoint_dir,omitempty"`
|
||||
// DeepOutputKey passed to deep.Config OutputKey (session final text); empty = off.
|
||||
DeepOutputKey string `yaml:"deep_output_key,omitempty" json:"deep_output_key,omitempty"`
|
||||
@@ -960,12 +959,13 @@ func (c OpenAIConfig) MaxCompletionTokensEffective() int {
|
||||
}
|
||||
|
||||
// IsDeepSeekEndpointOrModel reports whether the channel targets DeepSeek's
|
||||
// official-compatible API endpoint. The historical name is kept for compatibility;
|
||||
// model names alone are not enough to infer DeepSeek wire behavior behind
|
||||
// OpenAI-compatible gateways.
|
||||
// official-compatible API or a DeepSeek model family. This is separate from the
|
||||
// reasoning profile: profile controls field mapping, while DeepSeek has provider
|
||||
// constraints such as default thinking mode and no tool_choice in thinking mode.
|
||||
func (c OpenAIConfig) IsDeepSeekEndpointOrModel() bool {
|
||||
baseURL := strings.ToLower(strings.TrimSpace(c.BaseURL))
|
||||
return strings.Contains(baseURL, "deepseek")
|
||||
model := strings.ToLower(strings.TrimSpace(c.Model))
|
||||
return strings.Contains(baseURL, "deepseek") || strings.Contains(model, "deepseek")
|
||||
}
|
||||
|
||||
// OpenAIReasoningConfig 全局默认与网关 profile(对话页可通过 ChatRequest.reasoning 覆盖,受 AllowClientReasoning 约束)。
|
||||
@@ -1062,24 +1062,8 @@ type HitlConfig struct {
|
||||
AuditAgentPromptReviewEdit string `yaml:"audit_agent_prompt_review_edit,omitempty" json:"audit_agent_prompt_review_edit,omitempty"`
|
||||
// RetentionDays 已决策审计日志(hitl_interrupts 非 pending)保留天数;省略时默认 90;0 表示不自动清理。
|
||||
RetentionDays *int `yaml:"retention_days,omitempty" json:"retention_days,omitempty"`
|
||||
// DefaultMode 全局默认人机协同模式(off | approval | review_edit);新建会话无独立配置时沿用。
|
||||
DefaultMode string `yaml:"default_mode,omitempty" json:"default_mode,omitempty"`
|
||||
// DefaultReviewer 全局默认审批方(human | audit_agent);新建会话无独立配置时沿用。
|
||||
// DefaultReviewer 全局默认审批方(human | audit_agent);未选会话时切换会写入 config.yaml;新建会话无独立配置时沿用。
|
||||
DefaultReviewer string `yaml:"default_reviewer,omitempty" json:"default_reviewer,omitempty"`
|
||||
// DefaultTimeoutSeconds 全局默认审批等待秒数;nil 表示使用前端历史默认 300 秒,0 表示不限时。
|
||||
DefaultTimeoutSeconds *int `yaml:"default_timeout_seconds,omitempty" json:"default_timeout_seconds,omitempty"`
|
||||
}
|
||||
|
||||
// EffectiveDefaultMode returns off, approval, or review_edit; omitted or unknown values default to off.
|
||||
func (h HitlConfig) EffectiveDefaultMode() string {
|
||||
switch strings.ToLower(strings.TrimSpace(h.DefaultMode)) {
|
||||
case "feedback", "followup":
|
||||
return "approval"
|
||||
case "approval", "review_edit":
|
||||
return strings.ToLower(strings.TrimSpace(h.DefaultMode))
|
||||
default:
|
||||
return "off"
|
||||
}
|
||||
}
|
||||
|
||||
// EffectiveDefaultReviewer returns human or audit_agent; omitted or unknown values default to human.
|
||||
@@ -1092,17 +1076,6 @@ func (h HitlConfig) EffectiveDefaultReviewer() string {
|
||||
}
|
||||
}
|
||||
|
||||
// EffectiveDefaultTimeoutSeconds returns the default HITL approval timeout; nil defaults to 5 minutes.
|
||||
func (h HitlConfig) EffectiveDefaultTimeoutSeconds() int {
|
||||
if h.DefaultTimeoutSeconds == nil {
|
||||
return 300
|
||||
}
|
||||
if *h.DefaultTimeoutSeconds < 0 {
|
||||
return 0
|
||||
}
|
||||
return *h.DefaultTimeoutSeconds
|
||||
}
|
||||
|
||||
// RetentionDaysEffective returns retention; 0 means keep forever; omitted defaults to 90.
|
||||
func (h HitlConfig) RetentionDaysEffective() int {
|
||||
if h.RetentionDays == nil {
|
||||
|
||||
@@ -95,29 +95,6 @@ func TestHitlAuditModelEffectiveFallsBackToMainConfig(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHitlDefaultConfigEffectiveValues(t *testing.T) {
|
||||
if got := (HitlConfig{}).EffectiveDefaultMode(); got != "off" {
|
||||
t.Fatalf("empty default mode = %q, want off", got)
|
||||
}
|
||||
if got := (HitlConfig{DefaultMode: "review-edit"}).EffectiveDefaultMode(); got != "off" {
|
||||
t.Fatalf("unknown default mode = %q, want off", got)
|
||||
}
|
||||
if got := (HitlConfig{DefaultMode: "review_edit"}).EffectiveDefaultMode(); got != "review_edit" {
|
||||
t.Fatalf("review_edit default mode = %q, want review_edit", got)
|
||||
}
|
||||
if got := (HitlConfig{}).EffectiveDefaultTimeoutSeconds(); got != 300 {
|
||||
t.Fatalf("empty default timeout = %d, want 300", got)
|
||||
}
|
||||
zero := 0
|
||||
if got := (HitlConfig{DefaultTimeoutSeconds: &zero}).EffectiveDefaultTimeoutSeconds(); got != 0 {
|
||||
t.Fatalf("zero default timeout = %d, want 0", got)
|
||||
}
|
||||
neg := -1
|
||||
if got := (HitlConfig{DefaultTimeoutSeconds: &neg}).EffectiveDefaultTimeoutSeconds(); got != 0 {
|
||||
t.Fatalf("negative default timeout = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadUsesAIDefaultChannelAsRuntimeOpenAI(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
|
||||
@@ -1350,8 +1350,6 @@ func (db *DB) AddProcessDetailWithID(messageID, conversationID, eventType, messa
|
||||
return "", fmt.Errorf("添加过程详情失败: %w", err)
|
||||
}
|
||||
|
||||
db.maybeRecordModelTokenUsage(messageID, conversationID, id, eventType, data)
|
||||
|
||||
return id, nil
|
||||
}
|
||||
|
||||
@@ -1540,11 +1538,6 @@ LIMIT 1`, messageID).Scan(&terminalEvent, &terminalCreatedAt)
|
||||
return nil, fmt.Errorf("统计工具调用详情失败: %w", err)
|
||||
}
|
||||
|
||||
pendingToolStatus := "result_missing"
|
||||
if summary.Status == "running" {
|
||||
pendingToolStatus = "running"
|
||||
}
|
||||
|
||||
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",
|
||||
messageID,
|
||||
@@ -1585,10 +1578,10 @@ LIMIT 1`, messageID).Scan(&terminalEvent, &terminalCreatedAt)
|
||||
ProcessDetailID: strings.TrimSpace(detailID),
|
||||
ToolName: toolName,
|
||||
ToolCallID: toolCallID,
|
||||
// This summary is reconstructed from persisted history. For an
|
||||
// active assistant turn, a missing result means the call is still
|
||||
// pending; after the turn is terminal it is genuinely incomplete.
|
||||
Status: pendingToolStatus,
|
||||
// This summary is reconstructed from persisted history, not live
|
||||
// execution state. Until a matching result is found the honest state
|
||||
// is "result_missing", never "running".
|
||||
Status: "result_missing",
|
||||
})
|
||||
matchedToolIndexes = append(matchedToolIndexes, false)
|
||||
if toolCallID != "" {
|
||||
@@ -1643,7 +1636,6 @@ LIMIT 1`, messageID).Scan(&terminalEvent, &terminalCreatedAt)
|
||||
return nil, fmt.Errorf("遍历工具执行摘要失败: %w", err)
|
||||
}
|
||||
execRows.Close()
|
||||
db.applyPersistedToolExecutionStatuses(summary.ToolExecutions)
|
||||
|
||||
rows, err := db.Query(
|
||||
"SELECT data FROM process_details WHERE message_id = ? AND event_type = 'iteration' ORDER BY created_at ASC, rowid ASC",
|
||||
@@ -1712,24 +1704,6 @@ func toolResultStatusFromPayload(payload map[string]interface{}, eventType strin
|
||||
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(
|
||||
executions []ProcessDetailsToolExecution,
|
||||
matched []bool,
|
||||
|
||||
@@ -216,32 +216,6 @@ func (db *DB) initTables() error {
|
||||
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE
|
||||
);`
|
||||
|
||||
// 创建模型 Token 用量表:process_details 负责时间线回放,本表负责结构化聚合统计。
|
||||
createModelTokenUsageTable := `
|
||||
CREATE TABLE IF NOT EXISTS model_token_usage (
|
||||
id TEXT PRIMARY KEY,
|
||||
process_detail_id TEXT NOT NULL UNIQUE,
|
||||
message_id TEXT NOT NULL,
|
||||
conversation_id TEXT NOT NULL,
|
||||
project_id TEXT,
|
||||
source TEXT NOT NULL DEFAULT '',
|
||||
orchestration TEXT NOT NULL DEFAULT '',
|
||||
reason TEXT NOT NULL DEFAULT '',
|
||||
model TEXT NOT NULL DEFAULT '',
|
||||
model_calls INTEGER NOT NULL DEFAULT 0,
|
||||
prompt_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
completion_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
total_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cached_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
reasoning_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
FOREIGN KEY (process_detail_id) REFERENCES process_details(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE SET NULL
|
||||
);`
|
||||
|
||||
// 创建工具执行记录表
|
||||
createToolExecutionsTable := `
|
||||
CREATE TABLE IF NOT EXISTS tool_executions (
|
||||
@@ -745,10 +719,6 @@ func (db *DB) initTables() error {
|
||||
CREATE INDEX IF NOT EXISTS idx_conversations_updated_at ON conversations(updated_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_process_details_message_id ON process_details(message_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_process_details_conversation_id ON process_details(conversation_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_model_token_usage_created_at ON model_token_usage(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_model_token_usage_conversation ON model_token_usage(conversation_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_model_token_usage_project ON model_token_usage(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_model_token_usage_model ON model_token_usage(model);
|
||||
CREATE INDEX IF NOT EXISTS idx_tool_executions_tool_name ON tool_executions(tool_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_tool_executions_start_time ON tool_executions(start_time);
|
||||
CREATE INDEX IF NOT EXISTS idx_tool_executions_status ON tool_executions(status);
|
||||
@@ -836,10 +806,6 @@ func (db *DB) initTables() error {
|
||||
return fmt.Errorf("创建process_details表失败: %w", err)
|
||||
}
|
||||
|
||||
if _, err := db.Exec(createModelTokenUsageTable); err != nil {
|
||||
return fmt.Errorf("创建model_token_usage表失败: %w", err)
|
||||
}
|
||||
|
||||
if _, err := db.Exec(createToolExecutionsTable); err != nil {
|
||||
return fmt.Errorf("创建tool_executions表失败: %w", err)
|
||||
}
|
||||
@@ -1015,10 +981,6 @@ func (db *DB) initTables() error {
|
||||
if _, err := db.Exec(createIndexes); err != nil {
|
||||
return fmt.Errorf("创建索引失败: %w", err)
|
||||
}
|
||||
|
||||
if err := db.BackfillModelTokenUsageFromProcessDetails(); err != nil {
|
||||
return fmt.Errorf("回填模型Token用量失败: %w", err)
|
||||
}
|
||||
db.logger.Debug("数据库表初始化完成")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,485 +0,0 @@
|
||||
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{}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
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,32 +165,6 @@ 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) {
|
||||
db, _, messageID := setupProcessDetailsSummaryTest(t)
|
||||
startedAt := "2026-08-10T08:00:00Z"
|
||||
|
||||
+18
-54
@@ -315,13 +315,12 @@ func (h *AgentHandler) SetHitlToolWhitelistSaver(s HitlToolWhitelistSaver) {
|
||||
h.hitlWhitelistSaver = s
|
||||
}
|
||||
|
||||
// HitlDefaultReviewerSaver 持久化全局默认人机协同配置到 config.yaml。
|
||||
// HitlDefaultReviewerSaver 持久化全局默认审批方到 config.yaml。
|
||||
type HitlDefaultReviewerSaver interface {
|
||||
UpdateHitlDefaultConfig(mode, reviewer string, timeoutSeconds int) error
|
||||
UpdateHitlDefaultReviewer(reviewer string) error
|
||||
}
|
||||
|
||||
// SetHitlDefaultReviewerSaver 设置 HITL 默认配置落盘。
|
||||
// SetHitlDefaultReviewerSaver 设置 HITL 默认审批方落盘。
|
||||
func (h *AgentHandler) SetHitlDefaultReviewerSaver(s HitlDefaultReviewerSaver) {
|
||||
h.hitlDefaultReviewerSaver = s
|
||||
}
|
||||
@@ -333,35 +332,6 @@ func (h *AgentHandler) hitlEffectiveDefaultReviewer() string {
|
||||
return "human"
|
||||
}
|
||||
|
||||
func (h *AgentHandler) hitlEffectiveDefaultMode() string {
|
||||
if h != nil && h.config != nil {
|
||||
return normalizeHitlDefaultMode(h.config.Hitl.EffectiveDefaultMode())
|
||||
}
|
||||
return "off"
|
||||
}
|
||||
|
||||
func (h *AgentHandler) hitlEffectiveDefaultTimeoutSeconds() int {
|
||||
if h != nil && h.config != nil {
|
||||
timeout := h.config.Hitl.EffectiveDefaultTimeoutSeconds()
|
||||
if timeout < 0 {
|
||||
return 0
|
||||
}
|
||||
return timeout
|
||||
}
|
||||
return 300
|
||||
}
|
||||
|
||||
func (h *AgentHandler) hitlEffectiveDefaultRequest() *HITLRequest {
|
||||
mode := h.hitlEffectiveDefaultMode()
|
||||
return &HITLRequest{
|
||||
Enabled: mode != "off",
|
||||
Mode: mode,
|
||||
Reviewer: h.hitlEffectiveDefaultReviewer(),
|
||||
SensitiveTools: []string{},
|
||||
TimeoutSeconds: h.hitlEffectiveDefaultTimeoutSeconds(),
|
||||
}
|
||||
}
|
||||
|
||||
// HITLNeedsToolApproval 供 C2 危险任务门控:与会话侧人机协同及免审批白名单判定一致。
|
||||
func (h *AgentHandler) HITLNeedsToolApproval(conversationID, toolName string) bool {
|
||||
if h == nil || h.hitlManager == nil {
|
||||
@@ -728,19 +698,18 @@ func (h *AgentHandler) mergeAssistantMessagePartialOnCancel(messageID, partial s
|
||||
|
||||
// ChatResponse 聊天响应
|
||||
type ChatResponse struct {
|
||||
Response string `json:"response"`
|
||||
MCPExecutionIDs []string `json:"mcpExecutionIds,omitempty"` // 本次对话中执行的MCP调用ID列表
|
||||
ConversationID string `json:"conversationId"` // 对话ID
|
||||
Time time.Time `json:"time"`
|
||||
Finalizable bool `json:"finalizable"`
|
||||
Finalized bool `json:"finalized"`
|
||||
Status string `json:"status,omitempty"`
|
||||
CompletionReason string `json:"completionReason,omitempty"`
|
||||
EvidenceVerified bool `json:"evidenceVerified"`
|
||||
EvidenceRefs []string `json:"evidenceRefs,omitempty"`
|
||||
PendingExecutionIDs []string `json:"pendingExecutionIds,omitempty"`
|
||||
MissingChecks []string `json:"missingChecks,omitempty"`
|
||||
AutoCancelledPendingExecutionIDs []string `json:"autoCancelledPendingExecutionIds,omitempty"`
|
||||
Response string `json:"response"`
|
||||
MCPExecutionIDs []string `json:"mcpExecutionIds,omitempty"` // 本次对话中执行的MCP调用ID列表
|
||||
ConversationID string `json:"conversationId"` // 对话ID
|
||||
Time time.Time `json:"time"`
|
||||
Finalizable bool `json:"finalizable"`
|
||||
Finalized bool `json:"finalized"`
|
||||
Status string `json:"status,omitempty"`
|
||||
CompletionReason string `json:"completionReason,omitempty"`
|
||||
EvidenceVerified bool `json:"evidenceVerified"`
|
||||
EvidenceRefs []string `json:"evidenceRefs,omitempty"`
|
||||
PendingExecutionIDs []string `json:"pendingExecutionIds,omitempty"`
|
||||
MissingChecks []string `json:"missingChecks,omitempty"`
|
||||
}
|
||||
|
||||
func (h *AgentHandler) finalizeRobotAgentError(ctx context.Context, assistantMessageID, conversationID string, resultMA *multiagent.RunResult, errMA error) (string, string, error) {
|
||||
@@ -755,13 +724,8 @@ func (h *AgentHandler) finalizeRobotAgentError(ctx context.Context, assistantMes
|
||||
return "", conversationID, errMA
|
||||
}
|
||||
|
||||
func (h *AgentHandler) finalizeRobotAgentSuccess(taskCtx context.Context, assistantMessageID, conversationID string, resultMA *multiagent.RunResult) (string, string, error) {
|
||||
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)
|
||||
func (h *AgentHandler) finalizeRobotAgentSuccess(assistantMessageID, conversationID string, resultMA *multiagent.RunResult) (string, string, error) {
|
||||
decision := h.finalizeAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, "robot", resultMA, resultMA.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(resultMA.LastAgentTraceInput), true)
|
||||
responseText := decision.FinalText
|
||||
if !decision.Finalizable {
|
||||
responseText = finalizationBlockedMessage(decision)
|
||||
@@ -794,7 +758,7 @@ func (h *AgentHandler) runRobotEinoSingleWithRetry(
|
||||
*taskStatus = "failed"
|
||||
return h.finalizeRobotAgentError(taskCtx, assistantMessageID, conversationID, resultMA, errMA)
|
||||
}
|
||||
return h.finalizeRobotAgentSuccess(taskCtx, assistantMessageID, conversationID, resultMA)
|
||||
return h.finalizeRobotAgentSuccess(assistantMessageID, conversationID, resultMA)
|
||||
}
|
||||
|
||||
func (h *AgentHandler) runRobotMultiAgentWithRetry(
|
||||
@@ -815,7 +779,7 @@ func (h *AgentHandler) runRobotMultiAgentWithRetry(
|
||||
*taskStatus = "failed"
|
||||
return h.finalizeRobotAgentError(taskCtx, assistantMessageID, conversationID, resultMA, errMA)
|
||||
}
|
||||
return h.finalizeRobotAgentSuccess(taskCtx, assistantMessageID, conversationID, resultMA)
|
||||
return h.finalizeRobotAgentSuccess(assistantMessageID, conversationID, resultMA)
|
||||
}
|
||||
|
||||
// ProcessMessageForRobot 供机器人(企业微信/钉钉/飞书)调用:Eino 单/多代理执行路径(含 progressCallback、过程详情),仅不发送 SSE,最后返回完整回复
|
||||
|
||||
@@ -281,12 +281,7 @@ func (h *AgentHandler) executeOneBatchSubTask(queueID string, queue *BatchTaskQu
|
||||
if useBatchMulti {
|
||||
agentMode = "batch_eino_" + batchOrch
|
||||
}
|
||||
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)
|
||||
decision := h.finalizeAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, agentMode, resultMA, mcpIDs, reasoningContent, true)
|
||||
resText := decision.FinalText
|
||||
if !decision.Finalizable {
|
||||
resText = finalizationBlockedMessage(decision)
|
||||
@@ -294,15 +289,14 @@ func (h *AgentHandler) executeOneBatchSubTask(queueID string, queue *BatchTaskQu
|
||||
sendEvent("finalization_check", resText, decision)
|
||||
}
|
||||
sendEvent("response", resText, finalizationResponsePayload(decision, map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"messageId": assistantMessageID,
|
||||
"agentMode": agentMode,
|
||||
"mcpExecutionIds": mcpIDs,
|
||||
"batchQueueId": queueID,
|
||||
"batchTaskId": task.ID,
|
||||
"batchTaskStatus": map[bool]string{true: string(BatchTaskStatusCompleted), false: string(BatchTaskStatusFailed)}[decision.Finalizable],
|
||||
"candidatePreview": safeTruncateString(resultMA.Response, 500),
|
||||
"autoCancelledPendingExecutionIds": autoCancelledPendingExecutionIDs,
|
||||
"conversationId": conversationID,
|
||||
"messageId": assistantMessageID,
|
||||
"agentMode": agentMode,
|
||||
"mcpExecutionIds": mcpIDs,
|
||||
"batchQueueId": queueID,
|
||||
"batchTaskId": task.ID,
|
||||
"batchTaskStatus": map[bool]string{true: string(BatchTaskStatusCompleted), false: string(BatchTaskStatusFailed)}[decision.Finalizable],
|
||||
"candidatePreview": safeTruncateString(resultMA.Response, 500),
|
||||
}))
|
||||
|
||||
if assistantMessageID == "" {
|
||||
|
||||
@@ -891,14 +891,7 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) {
|
||||
if req.Hitl != nil {
|
||||
h.config.Hitl.AuditModel = req.Hitl.AuditModel
|
||||
h.config.Hitl.ToolWhitelist = mergeHitlToolWhitelistSlice(nil, req.Hitl.ToolWhitelist)
|
||||
if strings.TrimSpace(req.Hitl.DefaultMode) != "" {
|
||||
h.config.Hitl.DefaultMode = req.Hitl.EffectiveDefaultMode()
|
||||
}
|
||||
h.config.Hitl.DefaultReviewer = req.Hitl.EffectiveDefaultReviewer()
|
||||
if req.Hitl.DefaultTimeoutSeconds != nil {
|
||||
v := req.Hitl.EffectiveDefaultTimeoutSeconds()
|
||||
h.config.Hitl.DefaultTimeoutSeconds = &v
|
||||
}
|
||||
h.config.Hitl.AuditAgentPrompt = strings.TrimSpace(req.Hitl.AuditAgentPrompt)
|
||||
h.config.Hitl.AuditAgentPromptReviewEdit = strings.TrimSpace(req.Hitl.AuditAgentPromptReviewEdit)
|
||||
if req.Hitl.RetentionDays != nil {
|
||||
@@ -2148,35 +2141,12 @@ func updateHitlConfig(doc *yaml.Node, cfg config.HitlConfig) {
|
||||
setStringInMap(auditModelNode, "model", cfg.AuditModel.Model)
|
||||
// flow 样式 [a, b, c] 单行展示,工具多时比块序列省行数
|
||||
setFlowStringSliceInMap(hitlNode, "tool_whitelist", cfg.ToolWhitelist)
|
||||
setStringInMap(hitlNode, "default_mode", cfg.EffectiveDefaultMode())
|
||||
setStringInMap(hitlNode, "default_reviewer", cfg.EffectiveDefaultReviewer())
|
||||
setIntInMap(hitlNode, "default_timeout_seconds", cfg.EffectiveDefaultTimeoutSeconds())
|
||||
setIntInMap(hitlNode, "retention_days", cfg.RetentionDaysEffective())
|
||||
setStringInMap(hitlNode, "audit_agent_prompt", cfg.AuditAgentPrompt)
|
||||
setStringInMap(hitlNode, "audit_agent_prompt_review_edit", cfg.AuditAgentPromptReviewEdit)
|
||||
}
|
||||
|
||||
// UpdateHitlDefaultConfig 更新全局默认人机协同配置并写入 config.yaml。
|
||||
func (h *ConfigHandler) UpdateHitlDefaultConfig(mode, reviewer string, timeoutSeconds int) error {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.config.Hitl.DefaultMode = config.HitlConfig{DefaultMode: mode}.EffectiveDefaultMode()
|
||||
h.config.Hitl.DefaultReviewer = config.HitlConfig{DefaultReviewer: reviewer}.EffectiveDefaultReviewer()
|
||||
if timeoutSeconds < 0 {
|
||||
timeoutSeconds = 0
|
||||
}
|
||||
h.config.Hitl.DefaultTimeoutSeconds = &timeoutSeconds
|
||||
if err := h.saveConfig(); err != nil {
|
||||
return err
|
||||
}
|
||||
h.logger.Info("HITL 全局默认配置已写入配置文件",
|
||||
zap.String("default_mode", h.config.Hitl.DefaultMode),
|
||||
zap.String("default_reviewer", h.config.Hitl.DefaultReviewer),
|
||||
zap.Int("default_timeout_seconds", timeoutSeconds),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateHitlDefaultReviewer 更新全局默认审批方并写入 config.yaml。
|
||||
func (h *ConfigHandler) UpdateHitlDefaultReviewer(reviewer string) error {
|
||||
h.mu.Lock()
|
||||
|
||||
@@ -76,65 +76,6 @@ 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) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := database.NewDB(filepath.Join(t.TempDir(), "process-details-args.db"), zap.NewNop())
|
||||
|
||||
@@ -192,7 +192,6 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
||||
var emptyResponseContinueAttempt int
|
||||
var finalizationAutoContinueAttempt int
|
||||
var decision agentfinalizer.Decision
|
||||
var autoCancelledPendingExecutionIDs []string
|
||||
|
||||
for {
|
||||
segmentMainIterationMax := 0
|
||||
@@ -269,10 +268,6 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
||||
continue
|
||||
}
|
||||
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) {
|
||||
mainIterationOffset += segmentMainIterationMax
|
||||
timeoutCancel()
|
||||
@@ -389,10 +384,6 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
||||
|
||||
if decision.CompletionReason == "" {
|
||||
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)
|
||||
|
||||
@@ -410,11 +401,10 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
||||
h.tasks.UpdateTaskStatus(conversationID, taskStatus)
|
||||
}
|
||||
sendEvent("response", responseText, finalizationResponsePayload(decision, map[string]interface{}{
|
||||
"mcpExecutionIds": cumulativeMCPExecutionIDs,
|
||||
"conversationId": conversationID,
|
||||
"messageId": assistantMessageID,
|
||||
"agentMode": "eino_single",
|
||||
"autoCancelledPendingExecutionIds": autoCancelledPendingExecutionIDs,
|
||||
"mcpExecutionIds": cumulativeMCPExecutionIDs,
|
||||
"conversationId": conversationID,
|
||||
"messageId": assistantMessageID,
|
||||
"agentMode": "eino_single",
|
||||
}))
|
||||
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
||||
}
|
||||
@@ -474,7 +464,6 @@ func (h *AgentHandler) EinoSingleAgentLoop(c *gin.Context) {
|
||||
var emptyResponseContinueAttempt int
|
||||
var finalizationAutoContinueAttempt int
|
||||
var decision agentfinalizer.Decision
|
||||
var autoCancelledPendingExecutionIDs []string
|
||||
for {
|
||||
result, runErr = multiagent.RunEinoSingleChatModelAgent(
|
||||
taskCtx,
|
||||
@@ -504,10 +493,6 @@ func (h *AgentHandler) EinoSingleAgentLoop(c *gin.Context) {
|
||||
continue
|
||||
}
|
||||
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) {
|
||||
continue
|
||||
}
|
||||
@@ -524,19 +509,18 @@ func (h *AgentHandler) EinoSingleAgentLoop(c *gin.Context) {
|
||||
responseText = finalizationBlockedMessage(decision)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"response": responseText,
|
||||
"conversationId": prep.ConversationID,
|
||||
"mcpExecutionIds": result.MCPExecutionIDs,
|
||||
"assistantMessageId": prep.AssistantMessageID,
|
||||
"agentMode": "eino_single",
|
||||
"finalized": decision.Finalized,
|
||||
"finalizable": decision.Finalizable,
|
||||
"status": decision.Status,
|
||||
"completionReason": decision.CompletionReason,
|
||||
"evidenceVerified": decision.EvidenceVerified,
|
||||
"evidenceRefs": decision.EvidenceRefs,
|
||||
"pendingExecutionIds": decision.PendingExecutionIDs,
|
||||
"missingChecks": decision.MissingChecks,
|
||||
"autoCancelledPendingExecutionIds": autoCancelledPendingExecutionIDs,
|
||||
"response": responseText,
|
||||
"conversationId": prep.ConversationID,
|
||||
"mcpExecutionIds": result.MCPExecutionIDs,
|
||||
"assistantMessageId": prep.AssistantMessageID,
|
||||
"agentMode": "eino_single",
|
||||
"finalized": decision.Finalized,
|
||||
"finalizable": decision.Finalizable,
|
||||
"status": decision.Status,
|
||||
"completionReason": decision.CompletionReason,
|
||||
"evidenceVerified": decision.EvidenceVerified,
|
||||
"evidenceRefs": decision.EvidenceRefs,
|
||||
"pendingExecutionIds": decision.PendingExecutionIDs,
|
||||
"missingChecks": decision.MissingChecks,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,21 +2,16 @@ package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/agent"
|
||||
"cyberstrike-ai/internal/agentfinalizer"
|
||||
"cyberstrike-ai/internal/mcp"
|
||||
"cyberstrike-ai/internal/multiagent"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const finalizationAutoContinueMaxAttempts = 2
|
||||
const finalizationPendingToolCancelWait = 2 * time.Second
|
||||
const finalizationPendingToolCancelPoll = 50 * time.Millisecond
|
||||
const finalizationPendingToolCancelNote = "Agent 迭代已结束,最终回复前自动终止未完成的工具执行"
|
||||
|
||||
func shouldAutoContinueAfterFinalization(d agentfinalizer.Decision, attempt int) bool {
|
||||
if d.Finalizable || d.Finalized {
|
||||
@@ -80,105 +75,3 @@ func finalizationAutoContinueBackoff(attempt int) time.Duration {
|
||||
}
|
||||
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,18 +1,9 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentpkg "cyberstrike-ai/internal/agent"
|
||||
"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) {
|
||||
@@ -66,66 +57,3 @@ func TestRequestRequiresExecutionEvidenceUsesExplicitPolicyOnly(t *testing.T) {
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -289,18 +289,6 @@ func normalizeHitlMode(mode string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeHitlDefaultMode(mode string) string {
|
||||
v := strings.ToLower(strings.TrimSpace(mode))
|
||||
switch v {
|
||||
case "feedback", "followup":
|
||||
return "approval"
|
||||
case "approval", "review_edit":
|
||||
return v
|
||||
default:
|
||||
return "off"
|
||||
}
|
||||
}
|
||||
|
||||
func (m *HITLManager) ActivateConversation(conversationID string, req *HITLRequest) {
|
||||
if req == nil || !req.Enabled {
|
||||
m.DeactivateConversation(conversationID)
|
||||
@@ -641,7 +629,7 @@ func (h *AgentHandler) loadHITLConversationConfig(conversationID string) (*HITLR
|
||||
return nil, err
|
||||
}
|
||||
if !has {
|
||||
return h.hitlEffectiveDefaultRequest(), nil
|
||||
cfg.Reviewer = h.hitlEffectiveDefaultReviewer()
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
@@ -1006,9 +994,7 @@ func (h *AgentHandler) GetHITLConversationConfig(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"conversationId": conversationID,
|
||||
"hitl": cfg,
|
||||
"defaultMode": h.hitlEffectiveDefaultMode(),
|
||||
"defaultReviewer": h.hitlEffectiveDefaultReviewer(),
|
||||
"defaultTimeoutSeconds": h.hitlEffectiveDefaultTimeoutSeconds(),
|
||||
"hitlGlobalToolWhitelist": h.hitlConfigGlobalToolWhitelist(),
|
||||
})
|
||||
}
|
||||
@@ -1065,64 +1051,11 @@ type setHitlDefaultReviewerReq struct {
|
||||
Reviewer string `json:"reviewer"`
|
||||
}
|
||||
|
||||
type setHitlDefaultConfigReq struct {
|
||||
Mode string `json:"mode"`
|
||||
Reviewer string `json:"reviewer"`
|
||||
TimeoutSeconds int `json:"timeoutSeconds"`
|
||||
}
|
||||
|
||||
func (h *AgentHandler) hitlDefaultConfigResponse() gin.H {
|
||||
return gin.H{
|
||||
"defaultMode": h.hitlEffectiveDefaultMode(),
|
||||
"defaultReviewer": h.hitlEffectiveDefaultReviewer(),
|
||||
"defaultTimeoutSeconds": h.hitlEffectiveDefaultTimeoutSeconds(),
|
||||
"hitlGlobalToolWhitelist": h.hitlConfigGlobalToolWhitelist(),
|
||||
}
|
||||
}
|
||||
|
||||
// GetHITLDefaultConfig 返回 config.yaml 中的全局默认人机协同配置。
|
||||
func (h *AgentHandler) GetHITLDefaultConfig(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, h.hitlDefaultConfigResponse())
|
||||
}
|
||||
|
||||
// UpdateHITLDefaultConfig 将全局默认人机协同配置写入 config.yaml。
|
||||
func (h *AgentHandler) UpdateHITLDefaultConfig(c *gin.Context) {
|
||||
if h.hitlDefaultReviewerSaver == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "HITL 配置持久化不可用"})
|
||||
return
|
||||
}
|
||||
var req setHitlDefaultConfigReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
mode := normalizeHitlDefaultMode(req.Mode)
|
||||
reviewer := normalizeHitlReviewer(req.Reviewer)
|
||||
timeoutSeconds := req.TimeoutSeconds
|
||||
if timeoutSeconds < 0 {
|
||||
timeoutSeconds = 0
|
||||
}
|
||||
if err := h.hitlDefaultReviewerSaver.UpdateHitlDefaultConfig(mode, reviewer, timeoutSeconds); err != nil {
|
||||
h.logger.Warn("写入 HITL 默认配置到 config.yaml 失败", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if h.config != nil {
|
||||
h.config.Hitl.DefaultMode = mode
|
||||
h.config.Hitl.DefaultReviewer = reviewer
|
||||
h.config.Hitl.DefaultTimeoutSeconds = &timeoutSeconds
|
||||
}
|
||||
if h.audit != nil {
|
||||
h.audit.RecordOK(c, "hitl", "default_config_update", "HITL 全局默认配置更新", "hitl_config", "default", nil)
|
||||
}
|
||||
out := h.hitlDefaultConfigResponse()
|
||||
out["ok"] = true
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
// GetHITLDefaultReviewer 返回 config.yaml 中的全局默认审批方。
|
||||
func (h *AgentHandler) GetHITLDefaultReviewer(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, h.hitlDefaultConfigResponse())
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"defaultReviewer": h.hitlEffectiveDefaultReviewer(),
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateHITLDefaultReviewer 将全局默认审批方写入 config.yaml(未选会话时切换审批方)。
|
||||
@@ -1148,9 +1081,10 @@ func (h *AgentHandler) UpdateHITLDefaultReviewer(c *gin.Context) {
|
||||
if h.audit != nil {
|
||||
h.audit.RecordOK(c, "hitl", "default_reviewer_update", "HITL 全局默认审批方更新", "hitl_config", "default_reviewer", nil)
|
||||
}
|
||||
out := h.hitlDefaultConfigResponse()
|
||||
out["ok"] = true
|
||||
c.JSON(http.StatusOK, out)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"ok": true,
|
||||
"defaultReviewer": reviewer,
|
||||
})
|
||||
}
|
||||
|
||||
// SetHITLGlobalToolWhitelist 整表替换 config.yaml 中的全局免审批工具白名单。
|
||||
|
||||
@@ -205,7 +205,6 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
||||
}
|
||||
agentMode := "eino_" + effectiveOrch
|
||||
var decision agentfinalizer.Decision
|
||||
var autoCancelledPendingExecutionIDs []string
|
||||
|
||||
for {
|
||||
segmentMainIterationMax := 0
|
||||
@@ -283,10 +282,6 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
||||
continue
|
||||
}
|
||||
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) {
|
||||
mainIterationOffset += segmentMainIterationMax
|
||||
timeoutCancel()
|
||||
@@ -403,10 +398,6 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
||||
|
||||
if decision.CompletionReason == "" {
|
||||
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)
|
||||
|
||||
@@ -424,11 +415,10 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
||||
h.tasks.UpdateTaskStatus(conversationID, taskStatus)
|
||||
}
|
||||
sendEvent("response", responseText, finalizationResponsePayload(decision, map[string]interface{}{
|
||||
"mcpExecutionIds": cumulativeMCPExecutionIDs,
|
||||
"conversationId": conversationID,
|
||||
"messageId": assistantMessageID,
|
||||
"agentMode": agentMode,
|
||||
"autoCancelledPendingExecutionIds": autoCancelledPendingExecutionIDs,
|
||||
"mcpExecutionIds": cumulativeMCPExecutionIDs,
|
||||
"conversationId": conversationID,
|
||||
"messageId": assistantMessageID,
|
||||
"agentMode": agentMode,
|
||||
}))
|
||||
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
||||
}
|
||||
@@ -488,7 +478,6 @@ func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
|
||||
}
|
||||
agentMode := "eino_" + effectiveOrch
|
||||
var decision agentfinalizer.Decision
|
||||
var autoCancelledPendingExecutionIDs []string
|
||||
for {
|
||||
result, runErr = multiagent.RunDeepAgent(
|
||||
taskCtx,
|
||||
@@ -525,10 +514,6 @@ func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
|
||||
continue
|
||||
}
|
||||
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) {
|
||||
continue
|
||||
}
|
||||
@@ -548,19 +533,18 @@ func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
|
||||
responseText = finalizationBlockedMessage(decision)
|
||||
}
|
||||
c.JSON(http.StatusOK, ChatResponse{
|
||||
Response: responseText,
|
||||
MCPExecutionIDs: result.MCPExecutionIDs,
|
||||
ConversationID: prep.ConversationID,
|
||||
Time: time.Now(),
|
||||
Finalizable: decision.Finalizable,
|
||||
Finalized: decision.Finalized,
|
||||
Status: decision.Status,
|
||||
CompletionReason: decision.CompletionReason,
|
||||
EvidenceVerified: decision.EvidenceVerified,
|
||||
EvidenceRefs: decision.EvidenceRefs,
|
||||
PendingExecutionIDs: decision.PendingExecutionIDs,
|
||||
MissingChecks: decision.MissingChecks,
|
||||
AutoCancelledPendingExecutionIDs: autoCancelledPendingExecutionIDs,
|
||||
Response: responseText,
|
||||
MCPExecutionIDs: result.MCPExecutionIDs,
|
||||
ConversationID: prep.ConversationID,
|
||||
Time: time.Now(),
|
||||
Finalizable: decision.Finalizable,
|
||||
Finalized: decision.Finalized,
|
||||
Status: decision.Status,
|
||||
CompletionReason: decision.CompletionReason,
|
||||
EvidenceVerified: decision.EvidenceVerified,
|
||||
EvidenceRefs: decision.EvidenceRefs,
|
||||
PendingExecutionIDs: decision.PendingExecutionIDs,
|
||||
MissingChecks: decision.MissingChecks,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
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{}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"cyberstrike-ai/internal/config"
|
||||
"cyberstrike-ai/internal/database"
|
||||
|
||||
einoopenai "github.com/cloudwego/eino-ext/components/model/openai"
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"github.com/cloudwego/eino/adk/middlewares/summarization"
|
||||
"github.com/cloudwego/eino/components/model"
|
||||
@@ -108,7 +109,9 @@ func newEinoAgenticSummarizationMiddleware(
|
||||
retryPolicy := einoTransientRunRetryPolicyFromMW(mwCfg)
|
||||
retryMax := retryPolicy.maxAttempts
|
||||
var summaryOverflowRetries int
|
||||
summaryModelOpts := newEinoSummarizationModelOptions(outputReserve, modelName, "agentic", &appCfg.OpenAI, logger)
|
||||
summaryModelOpts := []model.Option{
|
||||
einoopenai.WithMaxCompletionTokens(outputReserve),
|
||||
}
|
||||
|
||||
mw, err := summarization.NewTyped[*schema.AgenticMessage](ctx, &summarization.TypedConfig[*schema.AgenticMessage]{
|
||||
Model: summaryModel,
|
||||
|
||||
@@ -55,70 +55,6 @@ 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 {
|
||||
m := schema.ToolMessage(content, callID)
|
||||
m.ToolName = "exit"
|
||||
|
||||
@@ -3,8 +3,6 @@ package multiagent
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
)
|
||||
@@ -84,109 +82,12 @@ func (h *einoRunErrorHandler) emitError(err error, kind string) {
|
||||
if h == nil || h.progress == nil || err == nil {
|
||||
return
|
||||
}
|
||||
userErr := einoUserFacingRunError(err)
|
||||
data := map[string]interface{}{
|
||||
"conversationId": h.conversationID,
|
||||
"source": "eino",
|
||||
"error": err.Error(),
|
||||
}
|
||||
if kind != "" {
|
||||
data["errorKind"] = kind
|
||||
} else if userErr.kind != "" {
|
||||
data["errorKind"] = userErr.kind
|
||||
}
|
||||
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
|
||||
h.progress("error", err.Error(), data)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package multiagent
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
@@ -62,99 +61,6 @@ 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) {
|
||||
var events []string
|
||||
var errorKind interface{}
|
||||
|
||||
@@ -57,16 +57,6 @@ func (a *einoRunMessageAccumulator) Messages() []adk.Message {
|
||||
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 {
|
||||
if a == nil {
|
||||
return 0
|
||||
|
||||
@@ -27,10 +27,6 @@ func TestEinoRunMessageAccumulatorTracksBaseAndAppends(t *testing.T) {
|
||||
if len(msgs) != 2 || msgs[1].Role != schema.Assistant || msgs[1].Content != "hello" {
|
||||
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) {
|
||||
|
||||
@@ -45,7 +45,7 @@ func (b *einoRunResultBuilder) BuildFinal() *RunResult {
|
||||
func (b *einoRunResultBuilder) build(partial bool) *RunResult {
|
||||
var runMsgs []adk.Message
|
||||
if b.cfg.RunMessages != nil {
|
||||
runMsgs = b.cfg.RunMessages.NewMessages()
|
||||
runMsgs = b.cfg.RunMessages.Messages()
|
||||
}
|
||||
var lastAssistant string
|
||||
var lastPlanExecuteExecutor string
|
||||
@@ -107,9 +107,6 @@ func buildEinoRunResultFromAccumulated(
|
||||
if cleaned == "" {
|
||||
if fb := strings.TrimSpace(einoExtractFallbackAssistantFromMsgs(runAccumulatedMsgs)); fb != "" {
|
||||
cleaned = fb
|
||||
if orchMode == "plan_execute" {
|
||||
cleaned = UnwrapPlanExecuteUserText(cleaned)
|
||||
}
|
||||
}
|
||||
}
|
||||
cleaned = dedupeRepeatedParagraphs(cleaned, 80)
|
||||
@@ -149,38 +146,32 @@ func markModelFacingTraceForPersistence(msgs []adk.Message) []adk.Message {
|
||||
return out
|
||||
}
|
||||
|
||||
// einoExtractFallbackAssistantFromMsgs 在「主通道未产出助手正文」时,从 Eino ADK
|
||||
// 原生消息轨迹中回填用户可见回复。这里保持克制:只采纳倒序最近的可交付终态,
|
||||
// 避免把工具调用前的过渡语或子任务过程误升为最终回复。
|
||||
// einoExtractFallbackAssistantFromMsgs 在「主通道未产出助手正文」时,从 Eino ADK 轨迹中回填用户可见回复。
|
||||
// 典型场景:监督者仅调用 exit(final_result 落在 Tool 消息中),或工具结果已写入历史但 lastAssistant 未更新。
|
||||
//
|
||||
// 可交付终态:
|
||||
// - exit 工具输出;
|
||||
// - assistant 调用 exit 时 arguments.final_result;
|
||||
// - 没有后续普通工具结果截断的纯 assistant 正文。
|
||||
// 优先级:最后一次 exit 工具输出 → 最后一条含 exit 的助手 tool_calls 参数中的 final_result。
|
||||
func einoExtractFallbackAssistantFromMsgs(msgs []adk.Message) string {
|
||||
for i := len(msgs) - 1; i >= 0; i-- {
|
||||
m := msgs[i]
|
||||
if m == nil {
|
||||
if m == nil || m.Role != schema.Tool {
|
||||
continue
|
||||
}
|
||||
switch m.Role {
|
||||
case schema.Tool:
|
||||
if strings.EqualFold(strings.TrimSpace(m.ToolName), adk.ToolInfoExit.Name) {
|
||||
content := strings.TrimSpace(m.Content)
|
||||
if content != "" && !strings.HasPrefix(content, einomcp.ToolErrorPrefix) {
|
||||
return content
|
||||
}
|
||||
}
|
||||
return ""
|
||||
case schema.Assistant:
|
||||
if s := einoExtractExitFinalFromAssistantToolCalls(m); s != "" {
|
||||
return s
|
||||
}
|
||||
if len(m.ToolCalls) == 0 {
|
||||
if content := strings.TrimSpace(m.Content); content != "" {
|
||||
return content
|
||||
}
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(m.ToolName), adk.ToolInfoExit.Name) {
|
||||
continue
|
||||
}
|
||||
content := strings.TrimSpace(m.Content)
|
||||
if content == "" || strings.HasPrefix(content, einomcp.ToolErrorPrefix) {
|
||||
continue
|
||||
}
|
||||
return content
|
||||
}
|
||||
for i := len(msgs) - 1; i >= 0; i-- {
|
||||
m := msgs[i]
|
||||
if m == nil || m.Role != schema.Assistant {
|
||||
continue
|
||||
}
|
||||
if s := einoExtractExitFinalFromAssistantToolCalls(m); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
|
||||
@@ -55,24 +55,6 @@ 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) {
|
||||
runMessages := newEinoRunMessageAccumulator(nil)
|
||||
runMessages.Append(schema.AssistantMessage(`{"response":"planner text"}`, nil))
|
||||
@@ -91,18 +73,3 @@ func TestEinoRunResultBuilderPlanExecutePrefersExecutorOutput(t *testing.T) {
|
||||
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,9 +371,5 @@ func (s *einoRunRuntimeSession) emitUsageSummary(reason string) bool {
|
||||
if s == nil || s.usage == nil {
|
||||
return false
|
||||
}
|
||||
modelName := ""
|
||||
if s.args != nil {
|
||||
modelName = s.args.ModelName
|
||||
}
|
||||
return s.usage.EmitOnce(s.conversationID, s.orchMode, reason, modelName, s.progress, s.logger)
|
||||
return s.usage.EmitOnce(s.conversationID, s.orchMode, reason, s.progress, s.logger)
|
||||
}
|
||||
|
||||
@@ -61,7 +61,6 @@ func (a *einoRunUsageAccumulator) EmitOnce(
|
||||
conversationID string,
|
||||
orchestration string,
|
||||
reason string,
|
||||
modelName string,
|
||||
progress func(eventType, message string, data interface{}),
|
||||
logger *zap.Logger,
|
||||
) bool {
|
||||
@@ -82,7 +81,6 @@ func (a *einoRunUsageAccumulator) EmitOnce(
|
||||
"source": "eino",
|
||||
"orchestration": orchestration,
|
||||
"reason": reason,
|
||||
"model": modelName,
|
||||
"modelCalls": s.ModelCalls,
|
||||
"promptTokens": s.PromptTokens,
|
||||
"completionTokens": s.CompletionTokens,
|
||||
@@ -98,7 +96,6 @@ func (a *einoRunUsageAccumulator) EmitOnce(
|
||||
zap.String("conversationId", conversationID),
|
||||
zap.String("orchestration", orchestration),
|
||||
zap.String("reason", reason),
|
||||
zap.String("model", modelName),
|
||||
zap.Int("modelCalls", s.ModelCalls),
|
||||
zap.Int("promptTokens", s.PromptTokens),
|
||||
zap.Int("completionTokens", s.CompletionTokens),
|
||||
|
||||
@@ -49,16 +49,16 @@ func TestEinoRunUsageAccumulatorEmitOnce(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
if !acc.EmitOnce("conv-1", "deep", "final", "gpt-test", progress, nil) {
|
||||
if !acc.EmitOnce("conv-1", "deep", "final", progress, nil) {
|
||||
t.Fatal("first emit should return true")
|
||||
}
|
||||
if acc.EmitOnce("conv-1", "deep", "partial", "gpt-test", progress, nil) {
|
||||
if acc.EmitOnce("conv-1", "deep", "partial", progress, nil) {
|
||||
t.Fatal("second emit should return false")
|
||||
}
|
||||
if len(events) != 1 {
|
||||
t.Fatalf("events = %#v, want one usage summary", events)
|
||||
}
|
||||
if events[0]["conversationId"] != "conv-1" || events[0]["orchestration"] != "deep" || events[0]["reason"] != "final" || events[0]["model"] != "gpt-test" || events[0]["totalTokens"] != 3 {
|
||||
if events[0]["conversationId"] != "conv-1" || events[0]["orchestration"] != "deep" || events[0]["reason"] != "final" || events[0]["totalTokens"] != 3 {
|
||||
t.Fatalf("event = %#v", events[0])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,18 +203,15 @@ func RunEinoSingleChatModelAgent(
|
||||
}
|
||||
|
||||
return runEinoADKAgentLoop(ctx, &einoADKRunLoopArgs{
|
||||
OrchMode: "eino_single",
|
||||
OrchestratorName: einoSingleAgentName,
|
||||
ConversationID: conversationID,
|
||||
Progress: progress,
|
||||
Logger: logger,
|
||||
SnapshotMCPIDs: snapshotMCPIDs,
|
||||
StreamsMainAssistant: streamsMainAssistant,
|
||||
EinoRoleTag: einoRoleTag,
|
||||
// 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: "",
|
||||
OrchMode: "eino_single",
|
||||
OrchestratorName: einoSingleAgentName,
|
||||
ConversationID: conversationID,
|
||||
Progress: progress,
|
||||
Logger: logger,
|
||||
SnapshotMCPIDs: snapshotMCPIDs,
|
||||
StreamsMainAssistant: streamsMainAssistant,
|
||||
EinoRoleTag: einoRoleTag,
|
||||
CheckpointDir: ma.EinoMiddleware.CheckpointDir,
|
||||
RunRetryMaxAttempts: RunRetryMaxAttemptsFromConfig(&ma.EinoMiddleware),
|
||||
RunRetryMaxBackoffSec: int(einoRunRetryMaxBackoffFromConfig(&ma.EinoMiddleware).Seconds()),
|
||||
McpIDsMu: &mcpIDsMu,
|
||||
|
||||
@@ -164,7 +164,24 @@ func newEinoSummarizationMiddleware(
|
||||
retryMax := retryPolicy.maxAttempts
|
||||
var summaryOverflowRetries int
|
||||
|
||||
summaryModelOpts := newEinoSummarizationModelOptions(outputReserve, modelName, "classic", &appCfg.OpenAI, logger)
|
||||
// ModelOptions apply only to summarization Generate (same ChatModel instance as the agent).
|
||||
// Strip thinking/reasoning on this call path; mark requests for empty-choices diagnostics.
|
||||
summaryModelOpts := []model.Option{
|
||||
einoopenai.WithMaxCompletionTokens(outputReserve),
|
||||
einoopenai.WithExtraHeader(map[string]string{
|
||||
copenai.SummarizationRequestHeader: "1",
|
||||
}),
|
||||
einoopenai.WithRequestPayloadModifier(func(_ context.Context, in []*schema.Message, rawBody []byte) ([]byte, error) {
|
||||
if logger != nil {
|
||||
logger.Info("eino summarization generate request",
|
||||
zap.Int("input_messages", len(in)),
|
||||
zap.Int("payload_bytes", len(rawBody)),
|
||||
zap.String("model", modelName),
|
||||
)
|
||||
}
|
||||
return stripReasoningFromSummarizationPayload(rawBody)
|
||||
}),
|
||||
}
|
||||
|
||||
mw, err := summarization.New(ctx, &summarization.Config{
|
||||
Model: summaryModel,
|
||||
@@ -291,34 +308,6 @@ func newEinoSummarizationMiddleware(
|
||||
return mw, nil
|
||||
}
|
||||
|
||||
// newEinoSummarizationModelOptions applies only to summarization Generate calls
|
||||
// on the shared main model. Summary generation should be plain-text and cheap:
|
||||
// strip provider reasoning/thinking controls so DeepSeek/OpenAI-compatible
|
||||
// endpoints do not spend the reserved output budget on invisible reasoning.
|
||||
func newEinoSummarizationModelOptions(outputReserve int, modelName, kind string, oa *config.OpenAIConfig, logger *zap.Logger) []model.Option {
|
||||
label := "eino summarization generate request"
|
||||
if strings.TrimSpace(kind) != "" && kind != "classic" {
|
||||
label = "eino " + kind + " summarization generate request"
|
||||
}
|
||||
return []model.Option{
|
||||
model.WithMaxTokens(outputReserve),
|
||||
einoopenai.WithMaxCompletionTokens(outputReserve),
|
||||
einoopenai.WithExtraHeader(map[string]string{
|
||||
copenai.SummarizationRequestHeader: "1",
|
||||
}),
|
||||
einoopenai.WithRequestPayloadModifier(func(_ context.Context, in []*schema.Message, rawBody []byte) ([]byte, error) {
|
||||
if logger != nil {
|
||||
logger.Info(label,
|
||||
zap.Int("input_messages", len(in)),
|
||||
zap.Int("payload_bytes", len(rawBody)),
|
||||
zap.String("model", modelName),
|
||||
)
|
||||
}
|
||||
return stripReasoningFromSummarizationPayload(rawBody, oa)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// summarizationInputBudgetOpts controls spill/truncation behavior when a round alone exceeds budget.
|
||||
type summarizationInputBudgetOpts struct {
|
||||
toolMaxBytes int
|
||||
|
||||
@@ -1,33 +1,12 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
copenai "cyberstrike-ai/internal/openai"
|
||||
)
|
||||
|
||||
// stripReasoningFromSummarizationPayload removes thinking / reasoning fields from a
|
||||
// chat-completions JSON body. Applied only to summarization Generate calls via
|
||||
// model.ModelOptions on the shared ChatModel — main-agent requests are unchanged.
|
||||
func stripReasoningFromSummarizationPayload(rawBody []byte, oa *config.OpenAIConfig) ([]byte, error) {
|
||||
if shouldDisableDeepSeekThinkingForSummarization(oa) {
|
||||
return copenai.DisableThinkingForChatCompletionBody(rawBody)
|
||||
}
|
||||
func stripReasoningFromSummarizationPayload(rawBody []byte) ([]byte, error) {
|
||||
return copenai.StripReasoningFromChatCompletionBody(rawBody)
|
||||
}
|
||||
|
||||
func shouldDisableDeepSeekThinkingForSummarization(oa *config.OpenAIConfig) bool {
|
||||
if oa == nil {
|
||||
return false
|
||||
}
|
||||
profile := strings.ToLower(strings.TrimSpace(oa.Reasoning.ProfileEffective()))
|
||||
switch profile {
|
||||
case "deepseek", "deepseek_compat":
|
||||
return true
|
||||
case "", "auto":
|
||||
return oa.IsDeepSeekEndpointOrModel()
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,15 +3,11 @@ package multiagent
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
|
||||
"github.com/cloudwego/eino/components/model"
|
||||
)
|
||||
|
||||
func TestStripReasoningFromSummarizationPayload(t *testing.T) {
|
||||
in := []byte(`{"model":"deepseek-chat","messages":[],"thinking":{"type":"enabled"},"reasoning_effort":"high"}`)
|
||||
out, err := stripReasoningFromSummarizationPayload(in, nil)
|
||||
out, err := stripReasoningFromSummarizationPayload(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -24,7 +20,7 @@ func TestStripReasoningFromSummarizationPayload(t *testing.T) {
|
||||
}
|
||||
|
||||
plain := []byte(`{"model":"gpt-4o","messages":[]}`)
|
||||
out2, err := stripReasoningFromSummarizationPayload(plain, nil)
|
||||
out2, err := stripReasoningFromSummarizationPayload(plain)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -32,53 +28,3 @@ func TestStripReasoningFromSummarizationPayload(t *testing.T) {
|
||||
t.Fatalf("expected unchanged payload, got %s", out2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripReasoningFromSummarizationPayloadDisablesDeepSeekThinking(t *testing.T) {
|
||||
in := []byte(`{"model":"deepseek-v4-flash","messages":[],"thinking":{"type":"enabled"},"reasoning_effort":"high"}`)
|
||||
oa := &config.OpenAIConfig{
|
||||
BaseURL: "https://api.deepseek.com/v1",
|
||||
Model: "deepseek-v4-flash",
|
||||
}
|
||||
out, err := stripReasoningFromSummarizationPayload(in, oa)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(out)
|
||||
if strings.Contains(s, "reasoning_effort") {
|
||||
t.Fatalf("expected reasoning_effort stripped, got %s", s)
|
||||
}
|
||||
if !strings.Contains(s, `"thinking":{"type":"disabled"}`) {
|
||||
t.Fatalf("expected DeepSeek thinking disabled, got %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripReasoningFromSummarizationPayloadHonorsOpenAICompatProfile(t *testing.T) {
|
||||
in := []byte(`{"model":"deepseek-v4-flash","messages":[],"thinking":{"type":"enabled"},"reasoning_effort":"high"}`)
|
||||
oa := &config.OpenAIConfig{
|
||||
BaseURL: "https://api.deepseek.com/v1",
|
||||
Model: "deepseek-v4-flash",
|
||||
Reasoning: config.OpenAIReasoningConfig{
|
||||
Profile: "openai_compat",
|
||||
},
|
||||
}
|
||||
out, err := stripReasoningFromSummarizationPayload(in, oa)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(out)
|
||||
if strings.Contains(s, "thinking") || strings.Contains(s, "reasoning_effort") {
|
||||
t.Fatalf("expected OpenAI-compatible profile to strip reasoning fields, got %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoSummarizationModelOptionsSetCommonMaxTokens(t *testing.T) {
|
||||
const outputReserve = 4096
|
||||
opts := newEinoSummarizationModelOptions(outputReserve, "minimax-m3", "agentic", nil, nil)
|
||||
common := model.GetCommonOptions(nil, opts...)
|
||||
if common == nil || common.MaxTokens == nil {
|
||||
t.Fatal("expected summarization options to set common max_tokens")
|
||||
}
|
||||
if *common.MaxTokens != outputReserve {
|
||||
t.Fatalf("max_tokens = %d, want %d", *common.MaxTokens, outputReserve)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,9 +52,6 @@ func isEinoTransientRunError(err error) bool {
|
||||
if msg == "" {
|
||||
return false
|
||||
}
|
||||
if isEinoEmptySummaryContentErrorText(msg) {
|
||||
return true
|
||||
}
|
||||
if status := httpStatusFromErrorText(msg); status > 0 {
|
||||
return isRetryableHTTPStatus(status)
|
||||
}
|
||||
@@ -97,11 +94,6 @@ func isEinoTransientRunError(err error) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func isEinoEmptySummaryContentErrorText(msg string) bool {
|
||||
return strings.Contains(msg, "summary content is empty") ||
|
||||
strings.Contains(msg, "agentic summarization returned empty summary")
|
||||
}
|
||||
|
||||
func isRetryableHTTPStatus(status int) bool {
|
||||
switch status {
|
||||
case 408, 409, 425, 429:
|
||||
|
||||
@@ -36,7 +36,6 @@ func TestIsEinoTransientRunError(t *testing.T) {
|
||||
{"http2 goaway", errors.New("failed to receive stream chunk: error, http2: server sent GOAWAY and closed the connection; LastStreamID=791, ErrCode=NO_ERROR"), true},
|
||||
{"unexpected internal stream chunk", errors.New("failed to receive stream chunk: error, The service encountered an unexpected internal error. Request id: 0217851391106464f01ec66621d0980a42fd45436ed75957a6a0a"), true},
|
||||
{"unexpected eof", errors.New("unexpected EOF"), true},
|
||||
{"empty summarization output", errors.New("[NodeRunError] summary content is empty\nnode path: [node_1, ChatModel]"), true},
|
||||
{"503", errors.New("upstream returned 503"), true},
|
||||
{"iteration limit", errors.New("max iteration reached"), false},
|
||||
{"canceled", context.Canceled, false},
|
||||
|
||||
@@ -614,18 +614,15 @@ func RunDeepAgent(
|
||||
}
|
||||
|
||||
return runEinoADKAgentLoop(ctx, &einoADKRunLoopArgs{
|
||||
OrchMode: orchMode,
|
||||
OrchestratorName: orchestratorName,
|
||||
ConversationID: conversationID,
|
||||
Progress: progress,
|
||||
Logger: logger,
|
||||
SnapshotMCPIDs: snapshotMCPIDs,
|
||||
StreamsMainAssistant: streamsMainAssistant,
|
||||
EinoRoleTag: einoRoleTag,
|
||||
// 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: "",
|
||||
OrchMode: orchMode,
|
||||
OrchestratorName: orchestratorName,
|
||||
ConversationID: conversationID,
|
||||
Progress: progress,
|
||||
Logger: logger,
|
||||
SnapshotMCPIDs: snapshotMCPIDs,
|
||||
StreamsMainAssistant: streamsMainAssistant,
|
||||
EinoRoleTag: einoRoleTag,
|
||||
CheckpointDir: ma.EinoMiddleware.CheckpointDir,
|
||||
RunRetryMaxAttempts: RunRetryMaxAttemptsFromConfig(&ma.EinoMiddleware),
|
||||
RunRetryMaxBackoffSec: int(einoRunRetryMaxBackoffFromConfig(&ma.EinoMiddleware).Seconds()),
|
||||
McpIDsMu: &mcpIDsMu,
|
||||
|
||||
@@ -32,23 +32,6 @@ func StripReasoningFromChatCompletionBody(rawBody []byte) ([]byte, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DisableThinkingForChatCompletionBody removes generic reasoning controls and
|
||||
// explicitly disables DeepSeek-style thinking. Use only for providers where
|
||||
// omitting the field would leave thinking enabled by default.
|
||||
func DisableThinkingForChatCompletionBody(rawBody []byte) ([]byte, error) {
|
||||
var payload map[string]any
|
||||
if err := sonic.Unmarshal(rawBody, &payload); err != nil {
|
||||
return rawBody, nil
|
||||
}
|
||||
stripReasoningFields(payload)
|
||||
payload["thinking"] = map[string]any{"type": "disabled"}
|
||||
out, err := sonic.Marshal(payload)
|
||||
if err != nil {
|
||||
return rawBody, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// StripReasoningIfForcedToolChoice removes thinking / reasoning fields when the
|
||||
// request sets tool_choice to "required" or an object. Several providers reject
|
||||
// that combination (e.g. DashScope: "tool_choice does not support being set to
|
||||
|
||||
@@ -205,7 +205,7 @@ func TestReasoningToolChoiceCompatRoundTripperDeepSeek(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReasoningToolChoiceCompatRoundTripperOpenAIProfileWinsOverDeepSeekEndpoint(t *testing.T) {
|
||||
func TestReasoningToolChoiceCompatRoundTripperDeepSeekEndpointWinsOverProfile(t *testing.T) {
|
||||
var gotBody string
|
||||
rt := &reasoningToolChoiceCompatRoundTripper{
|
||||
cfg: &config.OpenAIConfig{
|
||||
@@ -235,11 +235,11 @@ func TestReasoningToolChoiceCompatRoundTripperOpenAIProfileWinsOverDeepSeekEndpo
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(gotBody, "tool_choice") {
|
||||
t.Fatalf("expected tool_choice preserved for explicit openai_compat profile, got %s", gotBody)
|
||||
if strings.Contains(gotBody, "tool_choice") {
|
||||
t.Fatalf("expected DeepSeek tool_choice stripped despite openai_compat profile, got %s", gotBody)
|
||||
}
|
||||
if !strings.Contains(gotBody, "tools") {
|
||||
t.Fatalf("expected tools preserved for explicit openai_compat profile, got %s", gotBody)
|
||||
t.Fatalf("expected tools preserved for DeepSeek, got %s", gotBody)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,9 @@ func isDeepSeekToolChoiceCompatProfile(cfg *config.OpenAIConfig) bool {
|
||||
if cfg == nil {
|
||||
return false
|
||||
}
|
||||
if cfg.IsDeepSeekEndpointOrModel() {
|
||||
return true
|
||||
}
|
||||
profile := strings.ToLower(strings.TrimSpace(cfg.Reasoning.ProfileEffective()))
|
||||
if profile == "deepseek" || profile == "deepseek_compat" {
|
||||
return true
|
||||
@@ -62,5 +65,5 @@ func isDeepSeekToolChoiceCompatProfile(cfg *config.OpenAIConfig) bool {
|
||||
if profile != "" && profile != "auto" {
|
||||
return false
|
||||
}
|
||||
return cfg.IsDeepSeekEndpointOrModel()
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ func ApplyPlanExecutePlannerModelConfig(cfg *einoopenai.ChatModelConfig, oa *con
|
||||
}
|
||||
mergeExtraRequestFields(cfg, oa.Reasoning.ExtraRequestFields)
|
||||
clearReasoningFromChatModelConfig(cfg)
|
||||
if resolveWireProfile(oa, &oa.Reasoning) == wireDeepseek {
|
||||
if resolveWireProfile(oa, &oa.Reasoning) == wireDeepseek || oa.IsDeepSeekEndpointOrModel() {
|
||||
// DeepSeek enables thinking by default, so omission would not actually
|
||||
// disable it for the planner's forced tool-choice requests.
|
||||
applyThinkingDisabled(cfg)
|
||||
@@ -88,9 +88,9 @@ func ApplyToEinoChatModelConfig(cfg *einoopenai.ChatModelConfig, oa *config.Open
|
||||
clearReasoningFromChatModelConfig(cfg)
|
||||
// Strict OpenAI endpoints reject unknown `thinking` fields, whereas the
|
||||
// DeepSeek API enables thinking by default and requires an explicit
|
||||
// thinking.type=disabled switch. The configured profile is authoritative;
|
||||
// auto-detection only happens inside resolveWireProfile for profile=auto.
|
||||
if resolveWireProfile(oa, sr) == wireDeepseek {
|
||||
// thinking.type=disabled switch. Detect the actual DeepSeek target even
|
||||
// when the configured reasoning profile was left as openai_compat.
|
||||
if resolveWireProfile(oa, sr) == wireDeepseek || oa.IsDeepSeekEndpointOrModel() {
|
||||
applyThinkingDisabled(cfg)
|
||||
}
|
||||
return
|
||||
@@ -132,7 +132,7 @@ func AgenticOpenAIExtraFields(oa *config.OpenAIConfig, client *ClientIntent) map
|
||||
fields := cloneExtraRequestFields(sr.ExtraRequestFields)
|
||||
if mode == "off" {
|
||||
clearReasoningExtraFields(fields)
|
||||
if resolveWireProfile(oa, sr) == wireDeepseek {
|
||||
if resolveWireProfile(oa, sr) == wireDeepseek || oa.IsDeepSeekEndpointOrModel() {
|
||||
if fields == nil {
|
||||
fields = make(map[string]any)
|
||||
}
|
||||
@@ -194,7 +194,7 @@ func AgenticOpenAIPlannerExtraFields(oa *config.OpenAIConfig) map[string]any {
|
||||
}
|
||||
fields := cloneExtraRequestFields(oa.Reasoning.ExtraRequestFields)
|
||||
clearReasoningExtraFields(fields)
|
||||
if resolveWireProfile(oa, &oa.Reasoning) == wireDeepseek {
|
||||
if resolveWireProfile(oa, &oa.Reasoning) == wireDeepseek || oa.IsDeepSeekEndpointOrModel() {
|
||||
if fields == nil {
|
||||
fields = make(map[string]any)
|
||||
}
|
||||
|
||||
+19
-110
@@ -140,7 +140,7 @@ func TestAgenticOpenAIPlannerExtraFields_deepseekDisablesThinking(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgenticOpenAIPlannerExtraFields_openAIProfileWinsOverDeepseekEndpoint(t *testing.T) {
|
||||
func TestAgenticOpenAIPlannerExtraFields_deepseekEndpointWinsOverOpenAIProfile(t *testing.T) {
|
||||
oa := &config.OpenAIConfig{
|
||||
BaseURL: "https://api.deepseek.com/v1",
|
||||
Model: "deepseek-v4-flash",
|
||||
@@ -155,10 +155,12 @@ func TestAgenticOpenAIPlannerExtraFields_openAIProfileWinsOverDeepseekEndpoint(t
|
||||
},
|
||||
}
|
||||
got := AgenticOpenAIPlannerExtraFields(oa)
|
||||
for _, key := range reasoningPayloadKeysForTest {
|
||||
if _, ok := got[key]; ok {
|
||||
t.Fatalf("planner fields unexpectedly contain %q: %#v", key, got)
|
||||
}
|
||||
if _, ok := got["reasoning_effort"]; ok {
|
||||
t.Fatalf("planner should strip reasoning_effort: %#v", 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 {
|
||||
t.Fatalf("vendor option not preserved: %#v", got)
|
||||
@@ -187,7 +189,7 @@ func TestApplyPlanExecutePlannerModelConfig_stripsReasoningWhenGlobalOn(t *testi
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPlanExecutePlannerModelConfig_openAIProfileWinsOverDeepseekEndpoint(t *testing.T) {
|
||||
func TestApplyPlanExecutePlannerModelConfig_deepseekEndpointWinsOverOpenAIProfile(t *testing.T) {
|
||||
cfg := &einoopenai.ChatModelConfig{ExtraFields: map[string]any{
|
||||
"thinking": map[string]any{"type": "enabled"},
|
||||
"reasoning_effort": "high",
|
||||
@@ -203,7 +205,16 @@ func TestApplyPlanExecutePlannerModelConfig_openAIProfileWinsOverDeepseekEndpoin
|
||||
},
|
||||
}
|
||||
ApplyPlanExecutePlannerModelConfig(cfg, oa)
|
||||
assertNoReasoningFields(t, cfg)
|
||||
if cfg.ReasoningEffort != "" {
|
||||
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 {
|
||||
t.Fatalf("expected unrelated extra field preserved, got %#v", cfg.ExtraFields)
|
||||
}
|
||||
@@ -235,89 +246,6 @@ 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) {
|
||||
cfg := &einoopenai.ChatModelConfig{}
|
||||
oa := &config.OpenAIConfig{Reasoning: config.OpenAIReasoningConfig{
|
||||
@@ -328,7 +256,7 @@ func TestApplyReasoningOff_clientOverrideOmit(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestApplyReasoningOff_deepseekExplicitlyDisablesDefaultThinking(t *testing.T) {
|
||||
for _, profile := range []string{"deepseek_compat", "auto"} {
|
||||
for _, profile := range []string{"deepseek_compat", "auto", "openai_compat"} {
|
||||
t.Run(profile, func(t *testing.T) {
|
||||
cfg := &einoopenai.ChatModelConfig{ExtraFields: map[string]any{
|
||||
"reasoning_effort": "high",
|
||||
@@ -359,25 +287,6 @@ 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) {
|
||||
var requestBody map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -118,8 +118,6 @@ func permissionForRequest(method, fullPath string) string {
|
||||
return "hitl:write"
|
||||
case strings.HasPrefix(path, "/agent-loop"), strings.HasPrefix(path, "/batch-tasks"):
|
||||
return crudPermission(method, "tasks")
|
||||
case path == "/usage/tokens":
|
||||
return "dashboard:read"
|
||||
case strings.HasPrefix(path, "/conversations"), strings.HasPrefix(path, "/messages"), strings.HasPrefix(path, "/process-details"):
|
||||
return crudPermission(method, "chat")
|
||||
case strings.HasPrefix(path, "/groups"):
|
||||
@@ -217,7 +215,7 @@ func resourceAllowed(c *gin.Context, db *database.DB) bool {
|
||||
return session.Scope == database.RBACScopeAll
|
||||
case strings.HasPrefix(path, "/c2/profiles") && c.Request.Method != http.MethodGet:
|
||||
return session.Scope == database.RBACScopeAll
|
||||
case (strings.HasPrefix(path, "/hitl/tool-whitelist") || strings.HasPrefix(path, "/hitl/default-config") || strings.HasPrefix(path, "/hitl/default-reviewer") || strings.HasPrefix(path, "/hitl/audit-strategy")) && c.Request.Method != http.MethodGet:
|
||||
case (strings.HasPrefix(path, "/hitl/tool-whitelist") || strings.HasPrefix(path, "/hitl/default-reviewer") || strings.HasPrefix(path, "/hitl/audit-strategy")) && c.Request.Method != http.MethodGet:
|
||||
return session.Scope == database.RBACScopeAll
|
||||
case isMutationMethod(c.Request.Method) && isProcessGlobalMutationPath(path):
|
||||
// These definitions/configurations are shared by every user and do not
|
||||
|
||||
@@ -119,12 +119,6 @@ func TestRBACResourcePickerRequiresWritePermission(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRBACMiddlewareMapsTokenUsageStatsToDashboardRead(t *testing.T) {
|
||||
if got := permissionForRequest(http.MethodGet, "/api/usage/tokens"); got != "dashboard:read" {
|
||||
t.Fatalf("token usage permission = %q, want dashboard:read", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPInvocationPermissionIsSeparateFromMCPAdministration(t *testing.T) {
|
||||
if got := permissionForRequest(http.MethodPost, "/api/mcp"); got != "mcp:execute" {
|
||||
t.Fatalf("MCP invocation permission = %q, want mcp:execute", got)
|
||||
|
||||
+64
-140
@@ -858,7 +858,7 @@ html[data-theme="dark"] .vulnerability-alert-switch input:disabled + .vulnerabil
|
||||
}
|
||||
|
||||
.conversation-sidebar {
|
||||
width: 320px;
|
||||
width: 280px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #fafbfc 100%);
|
||||
border-right: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
@@ -4107,50 +4107,62 @@ html[data-theme="dark"] .sidebar-content:hover::-webkit-scrollbar-thumb:hover {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 消息复制按钮 - 与时间戳同一行 */
|
||||
/* 消息复制按钮 - 位于消息气泡右下角 */
|
||||
.message-copy-btn {
|
||||
position: static;
|
||||
display: inline-flex;
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
right: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
color: var(--text-secondary, #888);
|
||||
gap: 6px;
|
||||
padding: 8px 14px;
|
||||
background: #ffffff;
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-radius: 20px;
|
||||
color: #666;
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
opacity: 0.72;
|
||||
flex-shrink: 0;
|
||||
transition: opacity 0.2s ease, color 0.2s ease, background 0.2s ease, border-color 0.2s ease;
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
z-index: 10;
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.message-bubble:hover .message-copy-btn {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.message-copy-btn:hover {
|
||||
color: var(--accent-color, #0066ff);
|
||||
background: rgba(0, 102, 255, 0.07);
|
||||
border-color: rgba(0, 102, 255, 0.14);
|
||||
opacity: 1;
|
||||
background: rgba(255, 255, 255, 1);
|
||||
border-color: rgba(0, 102, 255, 0.2);
|
||||
color: #0066ff;
|
||||
box-shadow: 0 4px 12px rgba(0, 102, 255, 0.15), 0 2px 4px rgba(0, 0, 0, 0.08);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.message-copy-btn:active {
|
||||
background: rgba(0, 102, 255, 0.11);
|
||||
transform: translateY(0) scale(0.98);
|
||||
box-shadow: 0 2px 6px rgba(0, 102, 255, 0.12), 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.message-copy-btn svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.message-copy-btn:focus-visible {
|
||||
opacity: 1;
|
||||
outline: 2px solid var(--accent-color, #0066ff);
|
||||
outline-offset: 1px;
|
||||
.message-copy-btn:hover svg {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.message-copy-btn span {
|
||||
display: none;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
/* 时间戳 + 删除本轮(与气泡分离,和「展开详情」同一视觉层级) */
|
||||
@@ -24290,15 +24302,11 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible {
|
||||
/* 第一行:核心 KPI(视觉层次 + 色块强调) */
|
||||
.dashboard-kpi-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.dashboard-kpi-row { grid-template-columns: repeat(3, 1fr); }
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.dashboard-kpi-row { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
@@ -24458,7 +24466,6 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible {
|
||||
.dashboard-kpi-card:nth-child(2) { background: linear-gradient(145deg, #fff 0%, #fef2f2 100%); }
|
||||
.dashboard-kpi-card:nth-child(3) { background: linear-gradient(145deg, #fff 0%, #f0fdf4 100%); }
|
||||
.dashboard-kpi-card:nth-child(4) { background: linear-gradient(145deg, #fff 0%, #f0fdfa 100%); }
|
||||
.dashboard-kpi-card:nth-child(5) { background: linear-gradient(145deg, #fff 0%, #f8fafc 100%); }
|
||||
|
||||
.dashboard-kpi-card:hover {
|
||||
transform: translateY(-3px);
|
||||
@@ -24487,7 +24494,6 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible {
|
||||
.dashboard-kpi-icon-vuln { background: rgba(239, 68, 68, 0.1); color: #ef4444; }
|
||||
.dashboard-kpi-icon-calls { background: rgba(34, 197, 94, 0.1); color: #22c55e; }
|
||||
.dashboard-kpi-icon-rate { background: rgba(20, 184, 166, 0.1); color: #14b8a6; }
|
||||
.dashboard-kpi-icon-tokens { background: rgba(99, 102, 241, 0.1); color: #6366f1; }
|
||||
|
||||
.dashboard-kpi-value {
|
||||
font-size: 1.875rem;
|
||||
@@ -28728,54 +28734,12 @@ html[data-theme="dark"] .vulnerability-details code.vuln-detail-field-value {
|
||||
}
|
||||
|
||||
.role-selector-icon {
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.agent-mode-logo {
|
||||
--agent-logo-a: #858d98;
|
||||
--agent-logo-b: #858d98;
|
||||
display: inline-flex;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: var(--agent-logo-a);
|
||||
box-shadow: none;
|
||||
overflow: visible;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.agent-mode-logo--deep {
|
||||
--agent-logo-a: #858d98;
|
||||
--agent-logo-b: #858d98;
|
||||
}
|
||||
|
||||
.agent-mode-logo--plan {
|
||||
--agent-logo-a: #858d98;
|
||||
--agent-logo-b: #858d98;
|
||||
}
|
||||
|
||||
.agent-mode-logo--supervisor {
|
||||
--agent-logo-a: #858d98;
|
||||
--agent-logo-b: #858d98;
|
||||
}
|
||||
|
||||
.agent-mode-logo--default {
|
||||
--agent-logo-a: #858d98;
|
||||
--agent-logo-b: #858d98;
|
||||
}
|
||||
|
||||
.agent-mode-logo__svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.9;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.role-selector-text {
|
||||
@@ -29085,16 +29049,6 @@ html[data-theme="dark"] .vulnerability-details code.vuln-detail-field-value {
|
||||
border-color: rgba(138, 43, 226, 0.3);
|
||||
box-shadow: 0 2px 6px rgba(138, 43, 226, 0.2);
|
||||
}
|
||||
|
||||
.role-selection-item-main .role-selection-item-icon-main.agent-mode-logo,
|
||||
.role-selection-item-main:hover .role-selection-item-icon-main.agent-mode-logo,
|
||||
.role-selection-item-main.selected .role-selection-item-icon-main.agent-mode-logo {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
.role-selection-item-content-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -36355,8 +36309,7 @@ html[data-theme="dark"] .conversation-reasoning-card .hitl-reviewer-toggle-btn.i
|
||||
html[data-theme="dark"] .dashboard-kpi-card:nth-child(1),
|
||||
html[data-theme="dark"] .dashboard-kpi-card:nth-child(2),
|
||||
html[data-theme="dark"] .dashboard-kpi-card:nth-child(3),
|
||||
html[data-theme="dark"] .dashboard-kpi-card:nth-child(4),
|
||||
html[data-theme="dark"] .dashboard-kpi-card:nth-child(5) {
|
||||
html[data-theme="dark"] .dashboard-kpi-card:nth-child(4) {
|
||||
background: linear-gradient(145deg, #111827 0%, #172033 100%);
|
||||
}
|
||||
|
||||
@@ -36844,17 +36797,21 @@ html[data-theme="dark"] .webshell-ai-msg.assistant.webshell-ai-candidate-output
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .message-copy-btn {
|
||||
background: #1f2937;
|
||||
border-color: #334155;
|
||||
color: var(--text-secondary);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .message-copy-btn:hover {
|
||||
background: rgba(96, 165, 250, 0.12);
|
||||
background: #263244;
|
||||
border-color: rgba(96, 165, 250, 0.45);
|
||||
color: var(--accent-hover);
|
||||
box-shadow: 0 4px 12px rgba(96, 165, 250, 0.18);
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .message-copy-btn:active {
|
||||
background: rgba(96, 165, 250, 0.18);
|
||||
box-shadow: 0 2px 6px rgba(96, 165, 250, 0.14);
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .message.user .message-bubble {
|
||||
@@ -37407,16 +37364,6 @@ html[data-theme="dark"] .role-selection-item-icon-main {
|
||||
border-color: rgba(148, 163, 184, 0.12) !important;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .role-selection-item-icon-main.agent-mode-logo,
|
||||
html[data-theme="dark"] .project-conversation-preview-mode-icon.agent-mode-logo,
|
||||
html[data-theme="dark"] .role-selector-icon.agent-mode-logo {
|
||||
--agent-logo-a: #94a3b8;
|
||||
--agent-logo-b: #94a3b8;
|
||||
border-color: transparent !important;
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .sidebar-list-pagination,
|
||||
html[data-theme="dark"] .sidebar-list-pagination-inner,
|
||||
html[data-theme="dark"] .conversation-sidebar-pagination,
|
||||
@@ -45048,7 +44995,7 @@ html[data-theme="dark"] .project-folder-preview-edit:focus-visible {
|
||||
.project-conversation-preview {
|
||||
position: fixed;
|
||||
z-index: 1200;
|
||||
width: min(340px, calc(100vw - 32px));
|
||||
width: min(300px, calc(100vw - 32px));
|
||||
padding: 12px 14px 11px;
|
||||
border: 1px solid rgba(30, 41, 59, 0.15);
|
||||
border-radius: 14px;
|
||||
@@ -45064,12 +45011,11 @@ html[data-theme="dark"] .project-folder-preview-edit:focus-visible {
|
||||
}
|
||||
|
||||
.project-conversation-preview-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 3px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.project-conversation-preview-title {
|
||||
@@ -45079,11 +45025,8 @@ html[data-theme="dark"] .project-folder-preview-edit:focus-visible {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 650;
|
||||
line-height: 1.35;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow-wrap: anywhere;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.project-conversation-preview-age {
|
||||
@@ -45115,13 +45058,6 @@ html[data-theme="dark"] .project-folder-preview-edit:focus-visible {
|
||||
color: #858d98;
|
||||
}
|
||||
|
||||
.project-conversation-preview-mode-icon.agent-mode-logo {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 0;
|
||||
color: #858d98;
|
||||
}
|
||||
|
||||
.project-conversation-preview-project,
|
||||
.project-conversation-preview-mode {
|
||||
min-width: 0;
|
||||
@@ -46410,6 +46346,15 @@ html[data-theme="dark"] .chat-composer-surface > .chat-session-settings-popover
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.message.assistant-turn-with-process .message-copy-btn {
|
||||
right: 0;
|
||||
bottom: -34px;
|
||||
padding: 5px 9px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.message.assistant-turn-with-process .mcp-call-section {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
@@ -46459,9 +46404,8 @@ html[data-theme="dark"] .chat-composer-surface > .chat-session-settings-popover
|
||||
.turn-process-leading {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 9px;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.turn-process-status-dot {
|
||||
@@ -46478,26 +46422,6 @@ html[data-theme="dark"] .chat-composer-surface > .chat-session-settings-popover
|
||||
animation: codex-turn-pulse 1.55s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.turn-process-token-chip {
|
||||
display: inline;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
line-height: inherit;
|
||||
opacity: 0.72;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.turn-process-token-chip::before {
|
||||
content: "· ";
|
||||
color: inherit;
|
||||
font-weight: inherit;
|
||||
}
|
||||
|
||||
@keyframes codex-turn-pulse {
|
||||
0%, 100% { opacity: 0.55; transform: scale(0.88); }
|
||||
50% { opacity: 1; transform: scale(1); }
|
||||
|
||||
@@ -127,9 +127,6 @@
|
||||
"vulnTotal": "Total vulnerabilities",
|
||||
"toolCalls": "Tool invocations",
|
||||
"successRate": "Tool success rate",
|
||||
"tokenUsage": "Token usage",
|
||||
"tokenUsageSub": "Last 7 days {{calls}} calls · Today {{today}}",
|
||||
"noTokenUsageYet": "No usage yet",
|
||||
"clickToViewTasks": "Click to view tasks",
|
||||
"clickToViewChat": "Click to view conversations",
|
||||
"clickToViewVuln": "Click to view vulnerabilities",
|
||||
@@ -636,8 +633,6 @@
|
||||
"turnDurationMinutes": "{{minutes}} min {{seconds}} sec",
|
||||
"turnDurationHours": "{{hours}} hr {{minutes}} min",
|
||||
"turnProcessAria": "{{state}}; expand or collapse execution details",
|
||||
"turnTokenUsageLabel": "{{tokens}} tokens",
|
||||
"turnTokenUsageTitle": "Token usage: {{total}} (input {{prompt}}, output {{completion}}, cached {{cached}}, reasoning {{reasoning}}, {{calls}} calls)",
|
||||
"turnNumber": "Turn {{number}}",
|
||||
"turnPending": "Processing…",
|
||||
"expandDetailLazyHint": "Expand details (loads iteration details on click)",
|
||||
@@ -819,7 +814,6 @@
|
||||
"hitlWhitelistHint": "Separate with commas or new lines; shown merged with the global allowlist in config.",
|
||||
"hitlApply": "Apply",
|
||||
"hitlApplyOkSync": "HITL settings saved and synced to the server.",
|
||||
"hitlApplyOkDefaultConfig": "Default HITL settings saved to config.yaml and activated.",
|
||||
"hitlApplyOkWhitelistYaml": "Tool whitelist merged into config.yaml and active. Session settings are saved automatically.",
|
||||
"hitlApplyOkLocal": "Saved in this browser.",
|
||||
"hitlApplyFail": "Failed to sync to server",
|
||||
|
||||
@@ -127,9 +127,6 @@
|
||||
"vulnTotal": "漏洞总数",
|
||||
"toolCalls": "工具调用次数",
|
||||
"successRate": "工具执行成功率",
|
||||
"tokenUsage": "Token 用量",
|
||||
"tokenUsageSub": "近 7 天 {{calls}} 次调用 · 今日 {{today}}",
|
||||
"noTokenUsageYet": "暂无用量",
|
||||
"clickToViewTasks": "点击查看任务管理",
|
||||
"clickToViewChat": "点击查看对话",
|
||||
"clickToViewVuln": "点击查看漏洞管理",
|
||||
@@ -624,8 +621,6 @@
|
||||
"turnDurationMinutes": "{{minutes}} 分钟 {{seconds}} 秒",
|
||||
"turnDurationHours": "{{hours}} 小时 {{minutes}} 分钟",
|
||||
"turnProcessAria": "{{state}},展开或收起执行过程",
|
||||
"turnTokenUsageLabel": "{{tokens}} tokens",
|
||||
"turnTokenUsageTitle": "Token 用量:{{total}}(输入 {{prompt}},输出 {{completion}},缓存 {{cached}},推理 {{reasoning}},调用 {{calls}} 次)",
|
||||
"turnNumber": "第 {{number}} 轮",
|
||||
"turnPending": "正在处理…",
|
||||
"expandDetailLazyHint": "展开详情(点击后加载迭代详情)",
|
||||
@@ -807,7 +802,6 @@
|
||||
"hitlWhitelistHint": "白名单内工具免审批;每行一个或逗号分隔,与 config 全局白名单合并。",
|
||||
"hitlApply": "应用",
|
||||
"hitlApplyOkSync": "人机协同配置已保存并同步到服务器。",
|
||||
"hitlApplyOkDefaultConfig": "人机协同默认配置已写入 config.yaml 并生效。",
|
||||
"hitlApplyOkWhitelistYaml": "免审批工具已合并进 config.yaml 并生效。会话配置会自动保存。",
|
||||
"hitlApplyOkLocal": "已保存到本浏览器。",
|
||||
"hitlApplyFail": "同步到服务器失败",
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
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\)/);
|
||||
});
|
||||
+105
-286
@@ -154,6 +154,9 @@ const chatSystemModelCache = new Map();
|
||||
|
||||
// 人机协同(HITL)会话级配置
|
||||
const HITL_STORAGE_PREFIX = 'cyberstrike-chat-hitl';
|
||||
const HITL_DRAFT_KEY = 'cyberstrike-chat-hitl-draft';
|
||||
/** 跨会话记忆:用户最近一次在侧栏选择的 HITL 偏好(与 hitl.js 中 readHitlGlobalLast 使用同一 key) */
|
||||
const HITL_GLOBAL_LAST_KEY = `${HITL_STORAGE_PREFIX}:__last__`;
|
||||
const HITL_MODE_OFF = 'off';
|
||||
const HITL_MODE_APPROVAL = 'approval';
|
||||
const HITL_MODE_REVIEW_EDIT = 'review_edit';
|
||||
@@ -429,17 +432,14 @@ function normalizeHitlTimeoutForChat(value, fallback) {
|
||||
}
|
||||
|
||||
function defaultHitlConfig() {
|
||||
const serverDefault = (typeof window !== 'undefined' && window.csaiHitlDefaultConfig && typeof window.csaiHitlDefaultConfig === 'object')
|
||||
? window.csaiHitlDefaultConfig
|
||||
: {};
|
||||
const serverReviewer = serverDefault.reviewer || ((typeof window !== 'undefined' && window.csaiHitlDefaultReviewer)
|
||||
const serverReviewer = (typeof window !== 'undefined' && window.csaiHitlDefaultReviewer)
|
||||
? window.csaiHitlDefaultReviewer
|
||||
: 'human');
|
||||
: 'human';
|
||||
return {
|
||||
mode: normalizeHitlMode(serverDefault.mode || HITL_MODE_OFF),
|
||||
mode: HITL_MODE_OFF,
|
||||
reviewer: normalizeHitlReviewer(serverReviewer),
|
||||
sensitiveTools: DEFAULT_HITL_SESSION_TOOL_WHITELIST,
|
||||
timeoutSeconds: normalizeHitlTimeoutForChat(serverDefault.timeoutSeconds, DEFAULT_HITL_TIMEOUT_SECONDS),
|
||||
timeoutSeconds: DEFAULT_HITL_TIMEOUT_SECONDS,
|
||||
updatedAt: ''
|
||||
};
|
||||
}
|
||||
@@ -520,11 +520,70 @@ function getHitlModeLabel(mode) {
|
||||
}
|
||||
}
|
||||
|
||||
function getHitlLastGlobalConfig() {
|
||||
const fallback = defaultHitlConfig();
|
||||
try {
|
||||
const raw = localStorage.getItem(HITL_GLOBAL_LAST_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== 'object') return null;
|
||||
return {
|
||||
mode: normalizeHitlMode(parsed.mode),
|
||||
reviewer: normalizeHitlReviewer(parsed.reviewer),
|
||||
sensitiveTools: typeof parsed.sensitiveTools === 'string' ? parsed.sensitiveTools : fallback.sensitiveTools,
|
||||
timeoutSeconds: normalizeHitlTimeoutForChat(parsed.timeoutSeconds, fallback.timeoutSeconds),
|
||||
updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : ''
|
||||
};
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveHitlLastGlobalConfig(payload) {
|
||||
if (!payload || typeof payload !== 'object') return;
|
||||
try {
|
||||
localStorage.setItem(HITL_GLOBAL_LAST_KEY, JSON.stringify(payload));
|
||||
} catch (e) {
|
||||
console.warn('saveHitlLastGlobalConfig failed', e);
|
||||
}
|
||||
}
|
||||
|
||||
function getHitlConfigForConversation(conversationId) {
|
||||
const fallback = defaultHitlConfig();
|
||||
const cid = conversationId ? String(conversationId).trim() : '';
|
||||
if (!cid) {
|
||||
return fallback;
|
||||
const globalLast = getHitlLastGlobalConfig();
|
||||
let draftCfg = null;
|
||||
try {
|
||||
const raw = localStorage.getItem(HITL_DRAFT_KEY);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
draftCfg = {
|
||||
mode: normalizeHitlMode(parsed.mode),
|
||||
reviewer: normalizeHitlReviewer(parsed.reviewer),
|
||||
sensitiveTools: typeof parsed.sensitiveTools === 'string' ? parsed.sensitiveTools : fallback.sensitiveTools,
|
||||
timeoutSeconds: normalizeHitlTimeoutForChat(parsed.timeoutSeconds, fallback.timeoutSeconds),
|
||||
updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : ''
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
draftCfg = null;
|
||||
}
|
||||
const g = globalLast ? {
|
||||
mode: normalizeHitlMode(globalLast.mode),
|
||||
reviewer: normalizeHitlReviewer(globalLast.reviewer),
|
||||
sensitiveTools: typeof globalLast.sensitiveTools === 'string' ? globalLast.sensitiveTools : fallback.sensitiveTools,
|
||||
timeoutSeconds: normalizeHitlTimeoutForChat(globalLast.timeoutSeconds, fallback.timeoutSeconds),
|
||||
updatedAt: typeof globalLast.updatedAt === 'string' ? globalLast.updatedAt : ''
|
||||
} : null;
|
||||
if (!draftCfg && !g) return fallback;
|
||||
if (!draftCfg) return g;
|
||||
if (!g) return draftCfg;
|
||||
const tg = Date.parse(g.updatedAt) || 0;
|
||||
const td = Date.parse(draftCfg.updatedAt) || 0;
|
||||
return tg > td ? g : draftCfg;
|
||||
}
|
||||
const key = getHitlStorageKeyByConversation(cid);
|
||||
try {
|
||||
@@ -568,8 +627,6 @@ async function onHitlReviewerChanged(reviewer) {
|
||||
try {
|
||||
if (cid && typeof window.saveHitlConversationConfig === 'function') {
|
||||
await window.saveHitlConversationConfig(cid, cfg);
|
||||
} else if (typeof window.putHitlDefaultConfig === 'function') {
|
||||
await window.putHitlDefaultConfig(cfg);
|
||||
} else if (typeof window.putHitlDefaultReviewer === 'function') {
|
||||
await window.putHitlDefaultReviewer(cfg.reviewer);
|
||||
}
|
||||
@@ -595,10 +652,7 @@ function bindHitlReviewerToggleListeners() {
|
||||
}
|
||||
|
||||
function saveHitlConfigForConversation(conversationId, cfg, opts) {
|
||||
void opts;
|
||||
if (!conversationId) {
|
||||
return;
|
||||
}
|
||||
const syncGlobalLast = !!(opts && opts.syncGlobalLast);
|
||||
const payload = {
|
||||
mode: normalizeHitlMode(cfg && cfg.mode),
|
||||
reviewer: normalizeHitlReviewer(cfg && cfg.reviewer),
|
||||
@@ -606,9 +660,12 @@ function saveHitlConfigForConversation(conversationId, cfg, opts) {
|
||||
timeoutSeconds: normalizeHitlTimeoutForChat(cfg && cfg.timeoutSeconds, DEFAULT_HITL_TIMEOUT_SECONDS),
|
||||
updatedAt: typeof (cfg && cfg.updatedAt) === 'string' ? cfg.updatedAt : ''
|
||||
};
|
||||
const key = getHitlStorageKeyByConversation(conversationId);
|
||||
const key = conversationId ? getHitlStorageKeyByConversation(conversationId) : HITL_DRAFT_KEY;
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(payload));
|
||||
if (syncGlobalLast) {
|
||||
saveHitlLastGlobalConfig(payload);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('saveHitlConfigForConversation failed', e);
|
||||
}
|
||||
@@ -695,9 +752,8 @@ async function waitForHitlConfigReady(conversationId) {
|
||||
await hitlConfigSyncPromise;
|
||||
return;
|
||||
}
|
||||
const defaultReady = window.csaiHitlDefaultConfigReady || window.csaiHitlDefaultReviewerReady;
|
||||
if (!cid && defaultReady && typeof defaultReady.then === 'function') {
|
||||
await defaultReady.catch(function () {});
|
||||
if (!cid && window.csaiHitlDefaultReviewerReady && typeof window.csaiHitlDefaultReviewerReady.then === 'function') {
|
||||
await window.csaiHitlDefaultReviewerReady.catch(function () {});
|
||||
if (!currentConversationId) refreshHitlConfigByCurrentConversation();
|
||||
}
|
||||
}
|
||||
@@ -762,10 +818,6 @@ async function applyHitlSidebarConfig() {
|
||||
await window.saveHitlConversationConfig(cid, cfg);
|
||||
const ok = typeof window.t === 'function' ? window.t('chat.hitlApplyOkSync') : '人机协同配置已保存并同步到服务器。';
|
||||
showHitlApplyFeedback(ok, false);
|
||||
} else if (typeof window.putHitlDefaultConfig === 'function') {
|
||||
await window.putHitlDefaultConfig(cfg);
|
||||
const okDefault = typeof window.t === 'function' ? window.t('chat.hitlApplyOkDefaultConfig') : '人机协同默认配置已写入 config.yaml 并生效。';
|
||||
showHitlApplyFeedback(okDefault, false);
|
||||
} else if (yamlMerged) {
|
||||
const okYaml = typeof window.t === 'function' ? window.t('chat.hitlApplyOkWhitelistYaml') : '免审批工具已合并进 config.yaml 并生效。会话配置会自动保存。';
|
||||
showHitlApplyFeedback(okYaml, false);
|
||||
@@ -861,11 +913,6 @@ function applyConversationAgentMode(conversationId, conversation) {
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.csaiHitlGlobalToolWhitelist = window.csaiHitlGlobalToolWhitelist || [];
|
||||
window.csaiHitlDefaultConfig = window.csaiHitlDefaultConfig || {
|
||||
mode: HITL_MODE_OFF,
|
||||
reviewer: 'human',
|
||||
timeoutSeconds: DEFAULT_HITL_TIMEOUT_SECONDS
|
||||
};
|
||||
window.csaiHitlDefaultReviewer = window.csaiHitlDefaultReviewer || 'human';
|
||||
window.csaiChatAgentMode = {
|
||||
EINO_MODES: CHAT_AGENT_EINO_MODES,
|
||||
@@ -887,6 +934,7 @@ if (typeof window !== 'undefined') {
|
||||
window.setHitlReviewerUI = setHitlReviewerUI;
|
||||
window.onHitlReviewerChanged = onHitlReviewerChanged;
|
||||
window.bindHitlReviewerToggleListeners = bindHitlReviewerToggleListeners;
|
||||
window.getHitlLastGlobalConfig = getHitlLastGlobalConfig;
|
||||
window.hitlMergeToolsForDisplay = hitlMergeToolsForDisplay;
|
||||
window.hitlStripGlobalToolsFromFormString = hitlStripGlobalToolsFromFormString;
|
||||
window.hitlToolsSplitToArray = hitlToolsSplitToArray;
|
||||
@@ -953,30 +1001,23 @@ function getAgentModeLabelForValue(mode) {
|
||||
}
|
||||
}
|
||||
|
||||
function getAgentModeIconClassForValue(mode) {
|
||||
function getAgentModeIconForValue(mode) {
|
||||
switch (mode) {
|
||||
case CHAT_AGENT_MODE_EINO_SINGLE: return 'eino';
|
||||
case 'deep': return 'deep';
|
||||
case 'plan_execute': return 'plan';
|
||||
case 'supervisor': return 'supervisor';
|
||||
default: return 'default';
|
||||
case CHAT_AGENT_MODE_EINO_SINGLE: return '⚡';
|
||||
case 'deep': return '🧩';
|
||||
case 'plan_execute': return '📋';
|
||||
case 'supervisor': return '🎯';
|
||||
default: return '🤖';
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
const hid = document.getElementById('agent-mode-select');
|
||||
const label = document.getElementById('agent-mode-text');
|
||||
const icon = document.getElementById('agent-mode-icon');
|
||||
if (hid) hid.value = value;
|
||||
if (label) label.textContent = getAgentModeLabelForValue(value);
|
||||
if (icon) {
|
||||
icon.className = 'role-selector-icon agent-mode-logo agent-mode-logo--' + getAgentModeIconClassForValue(value);
|
||||
icon.innerHTML = renderAgentModeLogoMarkup();
|
||||
}
|
||||
if (icon) icon.textContent = getAgentModeIconForValue(value);
|
||||
document.querySelectorAll('.agent-mode-option').forEach(function (el) {
|
||||
const v = el.getAttribute('data-value');
|
||||
el.classList.toggle('selected', v === value);
|
||||
@@ -3466,60 +3507,9 @@ function refreshSystemReadyMessageBubbles() {
|
||||
bubble.innerHTML = formattedContent;
|
||||
if (typeof wrapTablesInBubble === 'function') wrapTablesInBubble(bubble);
|
||||
messageDiv.dataset.originalContent = text;
|
||||
appendMessageCopyButton(messageDiv);
|
||||
});
|
||||
}
|
||||
|
||||
function ensureMessageMetaFooter(content) {
|
||||
if (!content) return null;
|
||||
let footer = content.querySelector('.message-meta-footer');
|
||||
if (footer) return footer;
|
||||
const timeDiv = content.querySelector('.message-time');
|
||||
footer = document.createElement('div');
|
||||
footer.className = 'message-meta-footer';
|
||||
if (timeDiv && timeDiv.parentNode === content) {
|
||||
timeDiv.parentNode.insertBefore(footer, timeDiv);
|
||||
footer.appendChild(timeDiv);
|
||||
} else {
|
||||
content.appendChild(footer);
|
||||
}
|
||||
return footer;
|
||||
}
|
||||
|
||||
function appendMessageCopyButton(messageDiv) {
|
||||
if (!messageDiv) return null;
|
||||
if (!messageDiv.classList || (!messageDiv.classList.contains('assistant') && !messageDiv.classList.contains('user'))) {
|
||||
return null;
|
||||
}
|
||||
const content = messageDiv.querySelector('.message-content');
|
||||
const footer = ensureMessageMetaFooter(content);
|
||||
if (!footer) return null;
|
||||
|
||||
messageDiv.querySelectorAll('.message-bubble .message-copy-btn').forEach((btn) => btn.remove());
|
||||
let copyBtn = footer.querySelector('.message-copy-btn');
|
||||
if (copyBtn) return copyBtn;
|
||||
|
||||
copyBtn = document.createElement('button');
|
||||
copyBtn.type = 'button';
|
||||
copyBtn.className = 'message-copy-btn';
|
||||
copyBtn.innerHTML = '<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 时,语言切换会刷新该条文案)
|
||||
function addMessage(role, content, mcpExecutionIds = null, progressId = null, createdAt = null, options = null) {
|
||||
const messagesDiv = document.getElementById('chat-messages');
|
||||
@@ -3591,10 +3581,23 @@ function addMessage(role, content, mcpExecutionIds = null, progressId = null, cr
|
||||
contentWrapper.appendChild(bubble);
|
||||
|
||||
// 保存原始内容到消息元素,用于复制功能
|
||||
if (role === 'assistant' || role === 'user') {
|
||||
if (role === 'assistant') {
|
||||
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');
|
||||
timeDiv.className = 'message-time';
|
||||
@@ -3623,16 +3626,8 @@ function addMessage(role, content, mcpExecutionIds = null, progressId = null, cr
|
||||
try {
|
||||
timeDiv.dataset.messageTime = messageTime.toISOString();
|
||||
} catch (e) { /* ignore */ }
|
||||
const metaFooter = document.createElement('div');
|
||||
metaFooter.className = 'message-meta-footer';
|
||||
metaFooter.appendChild(timeDiv);
|
||||
contentWrapper.appendChild(metaFooter);
|
||||
contentWrapper.appendChild(timeDiv);
|
||||
messageDiv.appendChild(contentWrapper);
|
||||
|
||||
// 为用户和助手消息添加复制按钮(复制整条消息内容)
|
||||
if (role === 'assistant' || role === 'user') {
|
||||
appendMessageCopyButton(messageDiv);
|
||||
}
|
||||
|
||||
// 有 MCP 执行记录且非流式占位消息时展示调用按钮;带 progressId 的流式占位不挂此条(与进度卡片一致,结束时 integrate 再创建)
|
||||
if (role === 'assistant' && (mcpExecutionIds && Array.isArray(mcpExecutionIds) && mcpExecutionIds.length > 0) && !progressId) {
|
||||
@@ -4137,10 +4132,6 @@ function renderProcessDetails(messageId, processDetails, options) {
|
||||
detailsContainer.dataset.lazyNotLoaded = '0';
|
||||
detailsContainer.dataset.loaded = '1';
|
||||
}
|
||||
const turnUsageFromDetails = extractAssistantTurnTokenUsage(processDetails);
|
||||
if (turnUsageFromDetails) {
|
||||
setAssistantTurnTokenUsage(messageElement, turnUsageFromDetails);
|
||||
}
|
||||
processDetails = mergeMessageReasoningContentIntoProcessDetails(processDetails, reasoningFromMessage);
|
||||
processDetails = filterNoiseProcessDetails(processDetails);
|
||||
processDetails = dedupeConsecutiveProcessDetailRows(processDetails);
|
||||
@@ -4437,7 +4428,7 @@ function renderProcessDetails(messageId, processDetails, options) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (eventType === 'tool_call' && detail.id && toolStatusByProcessDetailId.has(String(detail.id))) {
|
||||
if (!timelineOpts.toolStatus && eventType === 'tool_call' && detail.id && toolStatusByProcessDetailId.has(String(detail.id))) {
|
||||
timelineOpts.toolStatus = toolStatusByProcessDetailId.get(String(detail.id));
|
||||
}
|
||||
const itemId = addTimelineItem(timeline, eventType, timelineOpts);
|
||||
@@ -4896,134 +4887,6 @@ function formatAssistantTurnDuration(durationMs) {
|
||||
: seconds + ' 秒';
|
||||
}
|
||||
|
||||
function assistantTurnUsageNumber(value) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) && n > 0 ? Math.round(n) : 0;
|
||||
}
|
||||
|
||||
function normalizeAssistantTurnTokenUsage(data) {
|
||||
const source = data && typeof data === 'object' ? data : {};
|
||||
const usage = {
|
||||
modelCalls: assistantTurnUsageNumber(source.modelCalls),
|
||||
promptTokens: assistantTurnUsageNumber(source.promptTokens),
|
||||
completionTokens: assistantTurnUsageNumber(source.completionTokens),
|
||||
totalTokens: assistantTurnUsageNumber(source.totalTokens),
|
||||
cachedTokens: assistantTurnUsageNumber(source.cachedTokens),
|
||||
reasoningTokens: assistantTurnUsageNumber(source.reasoningTokens),
|
||||
model: source.model != null ? String(source.model).trim() : ''
|
||||
};
|
||||
if (usage.totalTokens <= 0 && (usage.promptTokens > 0 || usage.completionTokens > 0)) {
|
||||
usage.totalTokens = usage.promptTokens + usage.completionTokens;
|
||||
}
|
||||
return usage.totalTokens > 0 ? usage : null;
|
||||
}
|
||||
|
||||
function mergeAssistantTurnTokenUsage(target, usage) {
|
||||
if (!usage) return target || null;
|
||||
const out = target || {
|
||||
modelCalls: 0,
|
||||
promptTokens: 0,
|
||||
completionTokens: 0,
|
||||
totalTokens: 0,
|
||||
cachedTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
model: ''
|
||||
};
|
||||
out.modelCalls += usage.modelCalls || 0;
|
||||
out.promptTokens += usage.promptTokens || 0;
|
||||
out.completionTokens += usage.completionTokens || 0;
|
||||
out.totalTokens += usage.totalTokens || 0;
|
||||
out.cachedTokens += usage.cachedTokens || 0;
|
||||
out.reasoningTokens += usage.reasoningTokens || 0;
|
||||
if (!out.model && usage.model) out.model = usage.model;
|
||||
return out.totalTokens > 0 ? out : null;
|
||||
}
|
||||
|
||||
function extractAssistantTurnTokenUsage(processDetails) {
|
||||
if (!Array.isArray(processDetails)) return null;
|
||||
let total = null;
|
||||
processDetails.forEach((detail) => {
|
||||
if (!detail || String(detail.eventType || '').trim() !== 'eino_usage_summary') return;
|
||||
const usage = normalizeAssistantTurnTokenUsage(detail.data);
|
||||
total = mergeAssistantTurnTokenUsage(total, usage);
|
||||
});
|
||||
return total;
|
||||
}
|
||||
|
||||
function setAssistantTurnTokenUsage(messageElementOrId, usage) {
|
||||
const messageElement = typeof messageElementOrId === 'string'
|
||||
? document.getElementById(messageElementOrId)
|
||||
: messageElementOrId;
|
||||
if (!messageElement || !messageElement.dataset) return;
|
||||
const normalized = normalizeAssistantTurnTokenUsage(usage);
|
||||
if (!normalized) {
|
||||
delete messageElement.dataset.turnModelCalls;
|
||||
delete messageElement.dataset.turnPromptTokens;
|
||||
delete messageElement.dataset.turnCompletionTokens;
|
||||
delete messageElement.dataset.turnTotalTokens;
|
||||
delete messageElement.dataset.turnCachedTokens;
|
||||
delete messageElement.dataset.turnReasoningTokens;
|
||||
delete messageElement.dataset.turnModel;
|
||||
syncAssistantTurnSummary(messageElement);
|
||||
return;
|
||||
}
|
||||
messageElement.dataset.turnModelCalls = String(normalized.modelCalls || 0);
|
||||
messageElement.dataset.turnPromptTokens = String(normalized.promptTokens || 0);
|
||||
messageElement.dataset.turnCompletionTokens = String(normalized.completionTokens || 0);
|
||||
messageElement.dataset.turnTotalTokens = String(normalized.totalTokens || 0);
|
||||
messageElement.dataset.turnCachedTokens = String(normalized.cachedTokens || 0);
|
||||
messageElement.dataset.turnReasoningTokens = String(normalized.reasoningTokens || 0);
|
||||
if (normalized.model) {
|
||||
messageElement.dataset.turnModel = normalized.model;
|
||||
} else {
|
||||
delete messageElement.dataset.turnModel;
|
||||
}
|
||||
syncAssistantTurnSummary(messageElement);
|
||||
}
|
||||
|
||||
function getAssistantTurnTokenUsage(messageElement) {
|
||||
if (!messageElement || !messageElement.dataset) return null;
|
||||
return normalizeAssistantTurnTokenUsage({
|
||||
modelCalls: messageElement.dataset.turnModelCalls,
|
||||
promptTokens: messageElement.dataset.turnPromptTokens,
|
||||
completionTokens: messageElement.dataset.turnCompletionTokens,
|
||||
totalTokens: messageElement.dataset.turnTotalTokens,
|
||||
cachedTokens: messageElement.dataset.turnCachedTokens,
|
||||
reasoningTokens: messageElement.dataset.turnReasoningTokens,
|
||||
model: messageElement.dataset.turnModel
|
||||
});
|
||||
}
|
||||
|
||||
function formatAssistantTurnTokenCount(value) {
|
||||
const n = assistantTurnUsageNumber(value);
|
||||
if (n >= 1000000) return (n / 1000000).toFixed(n >= 10000000 ? 0 : 1).replace(/\.0$/, '') + 'M';
|
||||
if (n >= 1000) return (n / 1000).toFixed(n >= 100000 ? 0 : 1).replace(/\.0$/, '') + 'K';
|
||||
return String(n);
|
||||
}
|
||||
|
||||
function formatAssistantTurnTokenUsageLabel(usage) {
|
||||
const tokens = formatAssistantTurnTokenCount(usage && usage.totalTokens);
|
||||
return typeof window.t === 'function'
|
||||
? window.t('chat.turnTokenUsageLabel', { tokens: tokens })
|
||||
: tokens + ' tokens';
|
||||
}
|
||||
|
||||
function formatAssistantTurnTokenUsageTitle(usage) {
|
||||
const safeUsage = usage || {};
|
||||
const values = {
|
||||
total: formatAssistantTurnTokenCount(safeUsage.totalTokens),
|
||||
prompt: formatAssistantTurnTokenCount(safeUsage.promptTokens),
|
||||
completion: formatAssistantTurnTokenCount(safeUsage.completionTokens),
|
||||
cached: formatAssistantTurnTokenCount(safeUsage.cachedTokens),
|
||||
reasoning: formatAssistantTurnTokenCount(safeUsage.reasoningTokens),
|
||||
calls: formatAssistantTurnTokenCount(safeUsage.modelCalls),
|
||||
model: safeUsage.model || ''
|
||||
};
|
||||
return typeof window.t === 'function'
|
||||
? window.t('chat.turnTokenUsageTitle', values)
|
||||
: 'Token usage: ' + values.total + ' (input ' + values.prompt + ', output ' + values.completion + ')';
|
||||
}
|
||||
|
||||
function assistantTurnTimestamp(value) {
|
||||
if (value == null || value === '') return NaN;
|
||||
const n = new Date(value).getTime();
|
||||
@@ -5122,12 +4985,6 @@ function syncAssistantTurnSummary(messageElementOrId) {
|
||||
: 0;
|
||||
}
|
||||
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;
|
||||
if (status === 'running') {
|
||||
text = typeof window.t === 'function' ? window.t('chat.turnElapsedRunning', { duration: duration }) : '已处理 ' + duration;
|
||||
@@ -5144,7 +5001,6 @@ function syncAssistantTurnSummary(messageElementOrId) {
|
||||
<span class="turn-process-leading">
|
||||
<span class="turn-process-status-dot${status === 'running' ? ' is-running' : ''}" aria-hidden="true"></span>
|
||||
<span class="turn-process-summary-text">${escapeHtml(text)}</span>
|
||||
${tokenUsageHtml}
|
||||
</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>
|
||||
`;
|
||||
@@ -5156,10 +5012,8 @@ function syncAssistantTurnSummary(messageElementOrId) {
|
||||
}
|
||||
|
||||
window.setAssistantTurnTiming = setAssistantTurnTiming;
|
||||
window.setAssistantTurnTokenUsage = setAssistantTurnTokenUsage;
|
||||
window.syncAssistantTurnSummary = syncAssistantTurnSummary;
|
||||
window.formatAssistantTurnDuration = formatAssistantTurnDuration;
|
||||
window.extractAssistantTurnTokenUsage = extractAssistantTurnTokenUsage;
|
||||
|
||||
/** 渗透测试区:工具栏(展开详情 | N次工具执行)+ 独立工具列表 + 迭代时间线 */
|
||||
function ensureMcpCallSectionChrome(messageElement, messageId) {
|
||||
@@ -5916,6 +5770,13 @@ async function startNewConversation(options = {}) {
|
||||
chatInput.value = '';
|
||||
adjustTextareaHeight(chatInput);
|
||||
}
|
||||
// 把当前侧栏人机协同选项写入草稿与「最近应用」记忆,避免刷新时被旧草稿里的「关闭」覆盖
|
||||
try {
|
||||
if (typeof readHitlConfigFromForm === 'function' && typeof saveHitlConfigForConversation === 'function') {
|
||||
const snap = readHitlConfigFromForm();
|
||||
saveHitlConfigForConversation('', snap, { syncGlobalLast: true });
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
refreshHitlConfigByCurrentConversation();
|
||||
}
|
||||
|
||||
@@ -6295,43 +6156,6 @@ async function prefetchLastAssistantProcessDetails() {
|
||||
}
|
||||
}
|
||||
|
||||
async function hydrateConversationTokenUsage(conversationId, expectedSeq, signal) {
|
||||
const id = String(conversationId || '').trim();
|
||||
if (!id || typeof apiFetch !== 'function' || typeof window.setAssistantTurnTokenUsage !== 'function') return;
|
||||
if (signal && signal.aborted) return;
|
||||
const params = new URLSearchParams();
|
||||
params.set('since', '1970-01-01');
|
||||
params.set('limit', '500');
|
||||
const res = await apiFetch(
|
||||
'/api/conversations/' + encodeURIComponent(id) + '/token-usage?' + params.toString(),
|
||||
signal ? { signal: signal } : undefined
|
||||
);
|
||||
const payload = await res.json().catch(() => ({}));
|
||||
if (!res.ok || (signal && signal.aborted)) return;
|
||||
if (expectedSeq != null && expectedSeq !== loadConversationRequestSeq) return;
|
||||
if (currentConversationId !== id) return;
|
||||
const rows = Array.isArray(payload && payload.recent) ? payload.recent : [];
|
||||
if (rows.length === 0) return;
|
||||
const byMessage = new Map();
|
||||
rows.forEach((row) => {
|
||||
const messageId = row && row.messageId != null ? String(row.messageId).trim() : '';
|
||||
if (!messageId) return;
|
||||
const usage = normalizeAssistantTurnTokenUsage(row);
|
||||
if (!usage) return;
|
||||
byMessage.set(messageId, mergeAssistantTurnTokenUsage(byMessage.get(messageId) || null, usage));
|
||||
});
|
||||
if (byMessage.size === 0) return;
|
||||
document.querySelectorAll('#chat-messages .message.assistant[data-backend-message-id]').forEach((messageElement) => {
|
||||
const backendMessageId = messageElement && messageElement.dataset
|
||||
? String(messageElement.dataset.backendMessageId || '').trim()
|
||||
: '';
|
||||
const usage = backendMessageId ? byMessage.get(backendMessageId) : null;
|
||||
if (usage) {
|
||||
window.setAssistantTurnTokenUsage(messageElement, usage);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function loadConversation(conversationId) {
|
||||
conversationId = String(conversationId || '').trim();
|
||||
if (!conversationId) return;
|
||||
@@ -6634,11 +6458,6 @@ async function loadConversation(conversationId) {
|
||||
if (seq !== loadConversationRequestSeq) {
|
||||
return;
|
||||
}
|
||||
hydrateConversationTokenUsage(conversationId, seq, conversationLoadController.signal).catch((e) => {
|
||||
if (!e || e.name !== 'AbortError') {
|
||||
console.warn('hydrateConversationTokenUsage failed', e);
|
||||
}
|
||||
});
|
||||
if (currentConversationId === conversationId && typeof window.restoreHitlInlineForConversation === 'function') {
|
||||
await window.restoreHitlInlineForConversation(conversationId);
|
||||
}
|
||||
|
||||
@@ -66,12 +66,10 @@ async function refreshDashboard() {
|
||||
setDashboardOverviewPlaceholder('…');
|
||||
setEl('dashboard-kpi-tools-calls', '…');
|
||||
setEl('dashboard-kpi-success-rate', '…');
|
||||
setEl('dashboard-kpi-token-usage', '…');
|
||||
setKpiSubText('dashboard-kpi-tasks-sub-text', '…');
|
||||
setKpiSubText('dashboard-kpi-vuln-sub-text', '…');
|
||||
setKpiSubText('dashboard-kpi-tools-sub-text', '…');
|
||||
setKpiSubText('dashboard-kpi-rate-sub-text', '…');
|
||||
setKpiSubText('dashboard-kpi-token-sub-text', '…');
|
||||
hideEl('dashboard-kpi-vuln-critical-badge');
|
||||
hideEl('dashboard-alert-banner');
|
||||
setRecentVulnsLoading();
|
||||
@@ -129,7 +127,7 @@ async function refreshDashboard() {
|
||||
hitlPendingRes, notificationsRes, externalMcpStatsRes,
|
||||
webshellRes,
|
||||
c2ListenersRes, c2SessionsRes, c2TasksRes,
|
||||
projectSummaryRes, severityFilteredStatsRes, tokenUsageRes
|
||||
projectSummaryRes, severityFilteredStatsRes
|
||||
] = await Promise.all([
|
||||
fetchJson('/api/agent-loop/tasks'),
|
||||
fetchJson('/api/vulnerabilities/stats'),
|
||||
@@ -161,8 +159,7 @@ async function refreshDashboard() {
|
||||
fetchJson(dashboardProjectScopedUrl('/api/c2/sessions?limit=500')),
|
||||
fetchJson(dashboardProjectScopedUrl('/api/c2/tasks?page=1&page_size=1')),
|
||||
fetchJson('/api/projects/dashboard-summary?fact_limit=10'),
|
||||
selectedSeverityStatus ? fetchJson('/api/vulnerabilities/stats?status=' + encodeURIComponent(selectedSeverityStatus)) : Promise.resolve(null),
|
||||
fetchJson(dashboardProjectScopedUrl('/api/usage/tokens?days=7&limit=5'))
|
||||
selectedSeverityStatus ? fetchJson('/api/vulnerabilities/stats?status=' + encodeURIComponent(selectedSeverityStatus)) : Promise.resolve(null)
|
||||
]);
|
||||
|
||||
// 如果在 await 期间 controller 已被 abort,说明又有新刷新启动了,丢弃本次结果
|
||||
@@ -333,8 +330,6 @@ async function refreshDashboard() {
|
||||
renderDashboardToolsBar(null);
|
||||
}
|
||||
|
||||
renderDashboardTokenUsage(tokenUsageRes);
|
||||
|
||||
// 「能力总览 → MCP 工具」用配置总数(包含未被调用过的工具);专项接口失败时回落到 monitor 的 names.length
|
||||
if (toolsConfigRes && typeof toolsConfigRes.total === 'number') {
|
||||
setEl('dashboard-resource-tools', formatNumber(toolsConfigRes.total));
|
||||
@@ -440,12 +435,10 @@ async function refreshDashboard() {
|
||||
setDashboardOverviewPlaceholder('-');
|
||||
setEl('dashboard-kpi-success-rate', '-');
|
||||
setEl('dashboard-kpi-tools-calls', '-');
|
||||
setEl('dashboard-kpi-token-usage', '-');
|
||||
setKpiSubText('dashboard-kpi-tasks-sub-text', '-');
|
||||
setKpiSubText('dashboard-kpi-vuln-sub-text', '-');
|
||||
setKpiSubText('dashboard-kpi-tools-sub-text', '-');
|
||||
setKpiSubText('dashboard-kpi-rate-sub-text', '-');
|
||||
setKpiSubText('dashboard-kpi-token-sub-text', '-');
|
||||
['tools', 'skills', 'knowledge', 'roles', 'agents'].forEach(function (k) {
|
||||
setEl('dashboard-resource-' + k, '-');
|
||||
});
|
||||
@@ -707,41 +700,6 @@ function setKpiRateBadge(id, rate, failedCount) {
|
||||
}
|
||||
}
|
||||
|
||||
function renderDashboardTokenUsage(res) {
|
||||
const summary = res && res.summary ? res.summary : null;
|
||||
if (!summary) {
|
||||
setEl('dashboard-kpi-token-usage', '-');
|
||||
setKpiSubText('dashboard-kpi-token-sub-text', '-');
|
||||
return;
|
||||
}
|
||||
const total = Number(summary.totalTokens || 0);
|
||||
const calls = Number(summary.modelCalls || 0);
|
||||
const today = res && res.today ? Number(res.today.totalTokens || 0) : 0;
|
||||
if (!Number.isFinite(total) || total <= 0) {
|
||||
setEl('dashboard-kpi-token-usage', '0');
|
||||
setKpiSubText('dashboard-kpi-token-sub-text', dt('dashboard.noTokenUsageYet', null, '暂无用量'));
|
||||
return;
|
||||
}
|
||||
setEl('dashboard-kpi-token-usage', formatTokenUsageCompact(total));
|
||||
setKpiSubText('dashboard-kpi-token-sub-text',
|
||||
dt('dashboard.tokenUsageSub', {
|
||||
today: formatTokenUsageCompact(today),
|
||||
calls: Number.isFinite(calls) ? calls : 0
|
||||
}, '近 7 天 ' + (Number.isFinite(calls) ? calls : 0) + ' 次调用 · 今日 ' + formatTokenUsageCompact(today)));
|
||||
}
|
||||
|
||||
function formatTokenUsageCompact(num) {
|
||||
const n = Number(num || 0);
|
||||
if (!Number.isFinite(n) || n <= 0) return '0';
|
||||
if (n >= 1000000) {
|
||||
return (n / 1000000).toFixed(n >= 10000000 ? 0 : 1).replace(/\.0$/, '') + 'M';
|
||||
}
|
||||
if (n >= 1000) {
|
||||
return (n / 1000).toFixed(n >= 10000 ? 0 : 1).replace(/\.0$/, '') + 'K';
|
||||
}
|
||||
return String(Math.trunc(n));
|
||||
}
|
||||
|
||||
// sessionStorage:告警条「×」忽略记录 + 最近一次**实际展示过**的 reason 片段(不含 level),
|
||||
// 用于在「问题从多变少」(如审完 HITL 后只剩严重漏洞)时,避免误用更早对「仅子集」的忽略。
|
||||
var DASH_SESSION_ALERT_DISMISSED = 'dashboard.dismissedAlert';
|
||||
|
||||
+19
-67
@@ -248,89 +248,47 @@ async function fetchHitlConversationConfig(conversationId) {
|
||||
if (!data || !data.hitl) return null;
|
||||
return {
|
||||
hitl: data.hitl,
|
||||
defaultMode: hitlModeNormalize(data.defaultMode || 'off'),
|
||||
defaultReviewer: hitlReviewerNormalize(data.defaultReviewer || 'human'),
|
||||
defaultTimeoutSeconds: normalizeHitlTimeoutSeconds(data.defaultTimeoutSeconds, 300),
|
||||
hitlGlobalToolWhitelist: Array.isArray(data.hitlGlobalToolWhitelist) ? data.hitlGlobalToolWhitelist : []
|
||||
};
|
||||
}
|
||||
|
||||
function applyHitlDefaultReviewerFromServer(reviewer) {
|
||||
return applyHitlDefaultConfigFromServer({ defaultReviewer: reviewer });
|
||||
}
|
||||
|
||||
function applyHitlDefaultConfigFromServer(data) {
|
||||
const src = data && typeof data === 'object' ? data : {};
|
||||
const mode = hitlModeNormalize(src.defaultMode || src.mode || 'off');
|
||||
const reviewer = hitlReviewerNormalize(src.defaultReviewer || src.reviewer || 'human');
|
||||
const timeoutSeconds = normalizeHitlTimeoutSeconds(
|
||||
src.defaultTimeoutSeconds != null ? src.defaultTimeoutSeconds : src.timeoutSeconds,
|
||||
300
|
||||
);
|
||||
const out = {
|
||||
mode: mode,
|
||||
reviewer: reviewer,
|
||||
timeoutSeconds: timeoutSeconds
|
||||
};
|
||||
const v = hitlReviewerNormalize(reviewer);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.csaiHitlDefaultConfig = out;
|
||||
window.csaiHitlDefaultReviewer = reviewer;
|
||||
if (Array.isArray(src.hitlGlobalToolWhitelist)) {
|
||||
window.csaiHitlGlobalToolWhitelist = src.hitlGlobalToolWhitelist;
|
||||
}
|
||||
window.csaiHitlDefaultReviewer = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function fetchHitlDefaultConfig() {
|
||||
const resp = await hitlApiFetch('/api/hitl/default-config', { credentials: 'same-origin' });
|
||||
if (!resp.ok) {
|
||||
return applyHitlDefaultConfigFromServer({ defaultMode: 'off', defaultReviewer: 'human', defaultTimeoutSeconds: 300 });
|
||||
}
|
||||
const data = await resp.json();
|
||||
return applyHitlDefaultConfigFromServer(data);
|
||||
return v;
|
||||
}
|
||||
|
||||
async function fetchHitlDefaultReviewer() {
|
||||
const cfg = await fetchHitlDefaultConfig();
|
||||
return hitlReviewerNormalize(cfg && cfg.reviewer);
|
||||
const resp = await hitlApiFetch('/api/hitl/default-reviewer', { credentials: 'same-origin' });
|
||||
if (!resp.ok) {
|
||||
return applyHitlDefaultReviewerFromServer('human');
|
||||
}
|
||||
const data = await resp.json();
|
||||
return applyHitlDefaultReviewerFromServer(data && data.defaultReviewer);
|
||||
}
|
||||
|
||||
async function putHitlDefaultConfig(config) {
|
||||
const current = (typeof window !== 'undefined' && window.csaiHitlDefaultConfig && typeof window.csaiHitlDefaultConfig === 'object')
|
||||
? window.csaiHitlDefaultConfig
|
||||
: { mode: 'off', reviewer: 'human', timeoutSeconds: 300 };
|
||||
const cfg = config && typeof config === 'object' ? config : {};
|
||||
const payload = {
|
||||
mode: hitlModeNormalize(cfg.mode != null ? cfg.mode : current.mode),
|
||||
reviewer: hitlReviewerNormalize(cfg.reviewer != null ? cfg.reviewer : current.reviewer),
|
||||
timeoutSeconds: normalizeHitlTimeoutSeconds(
|
||||
cfg.timeoutSeconds != null ? cfg.timeoutSeconds : current.timeoutSeconds,
|
||||
300
|
||||
)
|
||||
};
|
||||
const resp = await hitlApiFetch('/api/hitl/default-config', {
|
||||
async function putHitlDefaultReviewer(reviewer) {
|
||||
const normalized = hitlReviewerNormalize(reviewer);
|
||||
const resp = await hitlApiFetch('/api/hitl/default-reviewer', {
|
||||
method: 'PUT',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
body: JSON.stringify({ reviewer: normalized })
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const msg = await readHitlApiError(resp);
|
||||
throw new Error(msg || ('HTTP ' + resp.status));
|
||||
}
|
||||
const data = await resp.json();
|
||||
return applyHitlDefaultConfigFromServer(data);
|
||||
}
|
||||
|
||||
async function putHitlDefaultReviewer(reviewer) {
|
||||
const cfg = await putHitlDefaultConfig({ reviewer: reviewer });
|
||||
return hitlReviewerNormalize(cfg && cfg.reviewer);
|
||||
return applyHitlDefaultReviewerFromServer(data && data.defaultReviewer);
|
||||
}
|
||||
|
||||
async function initHitlDefaultReviewerFromServer() {
|
||||
try {
|
||||
await fetchHitlDefaultConfig();
|
||||
await fetchHitlDefaultReviewer();
|
||||
if (!getCurrentConversationIdForHitl() && typeof window.refreshHitlConfigByCurrentConversation === 'function') {
|
||||
window.refreshHitlConfigByCurrentConversation();
|
||||
}
|
||||
@@ -577,13 +535,10 @@ async function syncHitlConfigFromServer(conversationId) {
|
||||
const pack = await fetchHitlConversationConfig(conversationId);
|
||||
if (!pack || !pack.hitl) return;
|
||||
const cfg = pack.hitl;
|
||||
if (pack.defaultReviewer) {
|
||||
applyHitlDefaultReviewerFromServer(pack.defaultReviewer);
|
||||
}
|
||||
const globalWL = pack.hitlGlobalToolWhitelist || [];
|
||||
applyHitlDefaultConfigFromServer({
|
||||
defaultMode: pack.defaultMode,
|
||||
defaultReviewer: pack.defaultReviewer,
|
||||
defaultTimeoutSeconds: pack.defaultTimeoutSeconds,
|
||||
hitlGlobalToolWhitelist: globalWL
|
||||
});
|
||||
if (typeof window !== 'undefined') {
|
||||
window.csaiHitlGlobalToolWhitelist = globalWL;
|
||||
}
|
||||
@@ -1865,8 +1820,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
if (typeof window.bindHitlReviewerToggleListeners === 'function') {
|
||||
window.bindHitlReviewerToggleListeners();
|
||||
}
|
||||
window.csaiHitlDefaultConfigReady = initHitlDefaultReviewerFromServer();
|
||||
window.csaiHitlDefaultReviewerReady = window.csaiHitlDefaultConfigReady;
|
||||
window.csaiHitlDefaultReviewerReady = initHitlDefaultReviewerFromServer();
|
||||
setTimeout(reconcileHitlUiState, 0);
|
||||
});
|
||||
|
||||
@@ -1882,8 +1836,6 @@ document.addEventListener('languagechange', function () {
|
||||
window.syncHitlConfigToServerByCurrentConversation = syncHitlConfigToServerByCurrentConversation;
|
||||
window.saveHitlConversationConfig = saveHitlConversationConfig;
|
||||
window.mergeHitlGlobalToolWhitelist = mergeHitlGlobalToolWhitelist;
|
||||
window.fetchHitlDefaultConfig = fetchHitlDefaultConfig;
|
||||
window.putHitlDefaultConfig = putHitlDefaultConfig;
|
||||
|
||||
// 由 chat.js 在 loadConversation 内 await 调用;挂到 window 供其它入口显式触发
|
||||
window.syncHitlConfigFromServer = syncHitlConfigFromServer;
|
||||
|
||||
+14
-110
@@ -1044,7 +1044,7 @@ function updateAssistantBubbleContent(assistantMessageId, content, renderMarkdow
|
||||
const bubble = assistantElement.querySelector('.message-bubble');
|
||||
if (!bubble) return;
|
||||
|
||||
// 清理旧版本可能残留在气泡内的复制按钮;新版按钮统一在时间行。
|
||||
// 保留复制按钮:addMessage 会把按钮 append 在 message-bubble 里
|
||||
const copyBtn = bubble.querySelector('.message-copy-btn');
|
||||
if (copyBtn) copyBtn.remove();
|
||||
|
||||
@@ -1066,9 +1066,7 @@ function updateAssistantBubbleContent(assistantMessageId, content, renderMarkdow
|
||||
if (typeof wrapTablesInBubble === 'function') {
|
||||
wrapTablesInBubble(bubble);
|
||||
}
|
||||
if (typeof window.appendMessageCopyButton === 'function') {
|
||||
window.appendMessageCopyButton(assistantElement);
|
||||
}
|
||||
if (copyBtn) bubble.appendChild(copyBtn);
|
||||
|
||||
if (typeof window.csMarkdownSanitize !== 'undefined') {
|
||||
window.csMarkdownSanitize.stripSuspiciousImages(bubble);
|
||||
@@ -3374,18 +3372,6 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
});
|
||||
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': {
|
||||
const auditData = Object.assign({}, event.data || {}, {
|
||||
reviewer: 'audit_agent',
|
||||
@@ -3998,7 +3984,6 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
const responseData = event.data || {};
|
||||
const mcpIds = mergeMcpExecutionIDLists(typeof getMcpIds === 'function' ? (getMcpIds() || []) : [], responseData.mcpExecutionIds || []);
|
||||
setMcpIds(mcpIds);
|
||||
markToolExecutionItemsCancelled(timeline, autoCancelledExecutionIdsFromData(responseData));
|
||||
|
||||
// 更新对话ID
|
||||
if (responseData.conversationId) {
|
||||
@@ -5901,9 +5886,6 @@ function getToolResultDisplayState(data, opts) {
|
||||
}
|
||||
return { kind: 'background_running', isError: false, success: false };
|
||||
}
|
||||
if (explicitStatus === 'cancelled' || explicitStatus === 'canceled') {
|
||||
return { kind: 'cancelled', isError: true, success: false };
|
||||
}
|
||||
const parts = [];
|
||||
if (opts.rawText != null) parts.push(String(opts.rawText));
|
||||
collectToolResultTextParts(data.result, parts, 0);
|
||||
@@ -5935,13 +5917,6 @@ function getBackgroundRunningToolLabel() {
|
||||
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) {
|
||||
opts = opts || {};
|
||||
const _t = function (k, o) {
|
||||
@@ -6183,10 +6158,7 @@ function mergeToolResultIntoCallItem(item, data, options) {
|
||||
}
|
||||
item.dataset.toolResultMerged = '1';
|
||||
item.dataset.toolSuccess = (!displayState.isError && !backgroundRunning) ? '1' : '0';
|
||||
item.dataset.toolDisplayStatus = toolDisplayStatusFromState(displayState);
|
||||
if (data.executionId != null && String(data.executionId).trim() !== '') {
|
||||
item.dataset.toolExecutionId = String(data.executionId).trim();
|
||||
}
|
||||
item.dataset.toolDisplayStatus = backgroundRunning ? 'background_running' : (displayState.isError ? 'failed' : 'completed');
|
||||
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'));
|
||||
applyToolCallStatus(item, item.dataset.toolDisplayStatus);
|
||||
@@ -6226,10 +6198,7 @@ function mergeToolResultIntoCallItem(item, data, options) {
|
||||
|
||||
item.dataset.toolResultMerged = '1';
|
||||
item.dataset.toolSuccess = (!displayState.isError && !backgroundRunning) ? '1' : '0';
|
||||
item.dataset.toolDisplayStatus = toolDisplayStatusFromState(displayState);
|
||||
if (data.executionId != null && String(data.executionId).trim() !== '') {
|
||||
item.dataset.toolExecutionId = String(data.executionId).trim();
|
||||
}
|
||||
item.dataset.toolDisplayStatus = backgroundRunning ? 'background_running' : (displayState.isError ? 'failed' : 'completed');
|
||||
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'));
|
||||
applyToolCallStatus(item, item.dataset.toolDisplayStatus);
|
||||
@@ -6392,9 +6361,6 @@ function getToolCallStatusPresentation(status) {
|
||||
if (normalized === 'failed') {
|
||||
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') {
|
||||
return { status: normalized, itemClass: 'tool-call-incomplete', badgeClass: 'tool-status-incomplete', label: translate('timeline.resultMissing', '结果记录缺失'), icon: '⚠️ ' };
|
||||
}
|
||||
@@ -6435,52 +6401,6 @@ function updateToolCallStatus(progressId, toolCallId, 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) {
|
||||
const output = (data && data.output) || {};
|
||||
@@ -6627,23 +6547,17 @@ function addTimelineItem(timeline, type, options) {
|
||||
const mergedDisplayState = merged ? getToolResultDisplayState(merged) : null;
|
||||
const mergedBackgroundRunning = mergedDisplayState && mergedDisplayState.kind === 'background_running';
|
||||
const terminalStatus = String(options.toolStatus || '').toLowerCase();
|
||||
const forcedStatus = (terminalStatus === 'completed' || terminalStatus === 'failed' || terminalStatus === 'cancelled' || terminalStatus === 'canceled')
|
||||
? (terminalStatus === 'canceled' ? 'cancelled' : terminalStatus)
|
||||
: '';
|
||||
if (merged) {
|
||||
item.dataset.toolResultMerged = '1';
|
||||
item.dataset.toolSuccess = forcedStatus ? (forcedStatus === 'completed' ? '1' : '0') : ((!mergedDisplayState.isError && !mergedBackgroundRunning) ? '1' : '0');
|
||||
item.dataset.toolDisplayStatus = forcedStatus || toolDisplayStatusFromState(mergedDisplayState);
|
||||
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'));
|
||||
item.dataset.toolSuccess = (!mergedDisplayState.isError && !mergedBackgroundRunning) ? '1' : '0';
|
||||
item.dataset.toolDisplayStatus = mergedBackgroundRunning ? 'background_running' : (mergedDisplayState.isError ? 'failed' : 'completed');
|
||||
item.classList.add(mergedBackgroundRunning ? 'tool-call-running' : (mergedDisplayState.isError ? 'tool-call-failed' : 'tool-call-completed'));
|
||||
if (d._mergedResultDetailId) {
|
||||
item.dataset.toolResultDetailId = String(d._mergedResultDetailId);
|
||||
}
|
||||
} else if (terminalStatus === 'completed' || terminalStatus === 'failed' || terminalStatus === 'cancelled' || terminalStatus === 'canceled') {
|
||||
} else if (terminalStatus === 'completed' || terminalStatus === 'failed') {
|
||||
item.dataset.toolSuccess = terminalStatus === 'completed' ? '1' : '0';
|
||||
item.dataset.toolDisplayStatus = terminalStatus === 'canceled' ? 'cancelled' : terminalStatus;
|
||||
item.dataset.toolDisplayStatus = terminalStatus;
|
||||
item.classList.add(terminalStatus === 'completed' ? 'tool-call-completed' : 'tool-call-failed');
|
||||
} else if (terminalStatus === 'result_missing') {
|
||||
item.dataset.toolDisplayStatus = 'result_missing';
|
||||
@@ -6674,10 +6588,7 @@ function addTimelineItem(timeline, type, options) {
|
||||
}
|
||||
item.dataset.toolName = (d.toolName != null && d.toolName !== '') ? String(d.toolName) : '';
|
||||
item.dataset.toolSuccess = (!displayState.isError && displayState.kind !== 'background_running') ? '1' : '0';
|
||||
item.dataset.toolDisplayStatus = toolDisplayStatusFromState(displayState);
|
||||
if (d.executionId != null && String(d.executionId).trim() !== '') {
|
||||
item.dataset.toolExecutionId = String(d.executionId).trim();
|
||||
}
|
||||
item.dataset.toolDisplayStatus = displayState.kind === 'background_running' ? 'background_running' : (displayState.isError ? 'failed' : 'completed');
|
||||
}
|
||||
if (type === 'eino_usage_summary' && options.data) {
|
||||
const d = options.data;
|
||||
@@ -6747,14 +6658,10 @@ function addTimelineItem(timeline, type, options) {
|
||||
const mergedDisplayState = merged ? getToolResultDisplayState(merged) : null;
|
||||
const mergedBackgroundRunning = mergedDisplayState && mergedDisplayState.kind === 'background_running';
|
||||
const terminalStatus = String(options.toolStatus || '').toLowerCase();
|
||||
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 hasTerminalStatus = terminalStatus === 'completed' || terminalStatus === 'failed';
|
||||
const hasHistoricalStatus = hasTerminalStatus || terminalStatus === 'result_missing';
|
||||
if (merged) {
|
||||
const statusForClass = forcedStatus || toolDisplayStatusFromState(mergedDisplayState);
|
||||
item.classList.add(statusForClass === 'background_running' ? 'tool-call-running' : (statusForClass === 'completed' ? 'tool-call-completed' : 'tool-call-failed'));
|
||||
item.classList.add(mergedBackgroundRunning ? 'tool-call-running' : (mergedDisplayState.isError ? 'tool-call-failed' : 'tool-call-completed'));
|
||||
} else if (hasTerminalStatus) {
|
||||
item.classList.add(terminalStatus === 'completed' ? 'tool-call-completed' : 'tool-call-failed');
|
||||
} else if (terminalStatus === 'result_missing') {
|
||||
@@ -6764,7 +6671,7 @@ function addTimelineItem(timeline, type, options) {
|
||||
}
|
||||
setToolCallDetailState(item, {
|
||||
args: args,
|
||||
resultData: (merged && forcedStatus) ? Object.assign({}, merged, { status: forcedStatus, success: forcedStatus === 'completed', isError: forcedStatus !== 'completed' }) : (merged || null),
|
||||
resultData: merged || null,
|
||||
pending: !merged && !hasHistoricalStatus && !options.skipPendingResult,
|
||||
processDetailId: options.processDetailId || '',
|
||||
resultDetailId: data._mergedResultDetailId || (merged && merged.processDetailId) || '',
|
||||
@@ -6816,10 +6723,7 @@ function addTimelineItem(timeline, type, options) {
|
||||
payloadDeferred: data._payloadDeferred === true,
|
||||
payloadLoaded: data._payloadDeferred !== true
|
||||
});
|
||||
item.dataset.toolDisplayStatus = toolDisplayStatusFromState(displayState);
|
||||
if (data.executionId != null && String(data.executionId).trim() !== '') {
|
||||
item.dataset.toolExecutionId = String(data.executionId).trim();
|
||||
}
|
||||
item.dataset.toolDisplayStatus = displayState.kind === 'background_running' ? 'background_running' : (displayState.isError ? 'failed' : 'completed');
|
||||
item.classList.add(displayState.kind === 'background_running' ? 'tool-call-running' : (displayState.isError ? 'tool-call-failed' : 'tool-call-completed'));
|
||||
} else if (type === 'cancelled') {
|
||||
const taskCancelledLabel = typeof window.t === 'function' ? window.t('chat.taskCancelled') : '任务已取消';
|
||||
|
||||
@@ -18,12 +18,6 @@ function functionSource(source, name, nextName) {
|
||||
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('无项目文件夹与普通项目共用悬浮和键盘聚焦预览', () => {
|
||||
const source = functionSource(projects, 'appendChatProjectFolderItem', 'appendChatProjectConversationItem');
|
||||
|
||||
@@ -128,27 +122,3 @@ test('对话悬浮预览显示本地年月日时分', () => {
|
||||
assert.match(zh, /"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,15 +3084,6 @@ 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() {
|
||||
let preview = document.getElementById('project-conversation-preview');
|
||||
if (preview) return preview;
|
||||
@@ -3111,7 +3102,7 @@ function ensureProjectConversationPreview() {
|
||||
<span class="project-conversation-preview-project"></span>
|
||||
</div>
|
||||
<div class="project-conversation-preview-meta">
|
||||
<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>
|
||||
<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"></span>
|
||||
<span class="project-conversation-preview-separator" aria-hidden="true">·</span>
|
||||
<span class="project-conversation-preview-status"></span>
|
||||
@@ -3174,10 +3165,6 @@ function showProjectConversationPreview(conversation, project, row) {
|
||||
ageEl.hidden = !ageEl.textContent;
|
||||
preview.querySelector('.project-conversation-preview-project').textContent = project?.name
|
||||
|| pickerMessage(tp, 'chat.conversationPreviewNoProject', '未绑定项目');
|
||||
const modeIcon = preview.querySelector('.project-conversation-preview-mode-icon');
|
||||
if (modeIcon) {
|
||||
modeIcon.className = 'project-conversation-preview-mode-icon agent-mode-logo agent-mode-logo--' + getProjectConversationModeIconClass(conversation);
|
||||
}
|
||||
preview.querySelector('.project-conversation-preview-mode').textContent = getProjectConversationModeLabel(conversation);
|
||||
statusEl.textContent = status;
|
||||
statusEl.className = 'project-conversation-preview-status'
|
||||
|
||||
@@ -66,7 +66,7 @@ function createHarness(nowMs) {
|
||||
clearInterval() {},
|
||||
};
|
||||
vm.runInNewContext(
|
||||
`${timingSource(chat)}; this.setAssistantTurnTiming = setAssistantTurnTiming; this.setAssistantTurnTokenUsage = setAssistantTurnTokenUsage; this.extractAssistantTurnTokenUsage = extractAssistantTurnTokenUsage;`,
|
||||
`${timingSource(chat)}; this.setAssistantTurnTiming = setAssistantTurnTiming;`,
|
||||
context
|
||||
);
|
||||
return context;
|
||||
@@ -103,45 +103,6 @@ test('已完成任务仍优先使用持久化耗时', () => {
|
||||
assert.match(message.label.innerHTML, /耗时 1 分钟 5 秒/);
|
||||
});
|
||||
|
||||
test('助手轮次摘要会显示持久化 token 用量', () => {
|
||||
const context = createHarness(Date.parse('2026-08-12T02:05:00.000Z'));
|
||||
const message = createMessage();
|
||||
|
||||
context.setAssistantTurnTiming(message, {
|
||||
startedAt: '2026-08-12T02:00:00.000Z',
|
||||
completedAt: '2026-08-12T02:00:03.000Z',
|
||||
durationMs: 3000,
|
||||
status: 'completed',
|
||||
});
|
||||
context.setAssistantTurnTokenUsage(message, {
|
||||
promptTokens: 1200,
|
||||
completionTokens: 34,
|
||||
cachedTokens: 200,
|
||||
reasoningTokens: 12,
|
||||
modelCalls: 1,
|
||||
model: 'deepseek-v3',
|
||||
});
|
||||
|
||||
assert.equal(message.dataset.turnTotalTokens, '1234');
|
||||
assert.match(message.label.innerHTML, /1\.2K tokens/);
|
||||
assert.match(message.label.innerHTML, /turn-process-token-chip/);
|
||||
});
|
||||
|
||||
test('助手轮次可从 Eino usage summary 过程详情提取 token 用量', () => {
|
||||
const context = createHarness(Date.parse('2026-08-12T02:05:00.000Z'));
|
||||
|
||||
const usage = context.extractAssistantTurnTokenUsage([
|
||||
{ eventType: 'progress', data: { totalTokens: 9999 } },
|
||||
{ eventType: 'eino_usage_summary', data: { promptTokens: 400, completionTokens: 100, modelCalls: 1 } },
|
||||
{ eventType: 'eino_usage_summary', data: { totalTokens: 25, modelCalls: 1 } },
|
||||
]);
|
||||
|
||||
assert.equal(usage.totalTokens, 525);
|
||||
assert.equal(usage.promptTokens, 400);
|
||||
assert.equal(usage.completionTokens, 100);
|
||||
assert.equal(usage.modelCalls, 2);
|
||||
});
|
||||
|
||||
test('已中断任务使用固定终态耗时且不再按当前时间增长', () => {
|
||||
const context = createHarness(Date.parse('2026-08-13T12:00:00.000Z'));
|
||||
const message = createMessage();
|
||||
|
||||
@@ -529,16 +529,6 @@
|
||||
<span class="dashboard-kpi-sub-text" id="dashboard-kpi-rate-sub-text" data-i18n="dashboard.healthyStatus">运行平稳</span>
|
||||
</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 class="dashboard-grid">
|
||||
@@ -1223,7 +1213,7 @@
|
||||
<div id="agent-mode-wrapper" class="agent-mode-wrapper" style="display: none;">
|
||||
<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="选择对话执行模式">
|
||||
<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-icon" class="role-selector-icon" aria-hidden="true">🤖</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">
|
||||
<path d="M6 9l6 6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
@@ -1240,7 +1230,7 @@
|
||||
</div>
|
||||
<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">
|
||||
<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-icon-main" aria-hidden="true">⚡</div>
|
||||
<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-description-main" data-i18n="chat.agentModeEinoSingleHint">CloudWeGo Eino ChatModelAgent + Runner,MCP 工具(/api/eino-agent)</div>
|
||||
@@ -1248,7 +1238,7 @@
|
||||
<div class="role-selection-checkmark-main agent-mode-check" data-agent-mode-check="eino_single">✓</div>
|
||||
</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">
|
||||
<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-icon-main" aria-hidden="true">🧩</div>
|
||||
<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-description-main" data-i18n="chat.agentModeDeepHint">Eino DeepAgent,适合复杂安全测试、多阶段 task 子代理委派与汇总</div>
|
||||
@@ -1256,7 +1246,7 @@
|
||||
<div class="role-selection-checkmark-main agent-mode-check" data-agent-mode-check="deep">✓</div>
|
||||
</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">
|
||||
<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-icon-main" aria-hidden="true">📋</div>
|
||||
<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-description-main" data-i18n="chat.agentModePlanExecuteHint">规划 → 执行 → 重规划(单执行器工具链)</div>
|
||||
@@ -1264,7 +1254,7 @@
|
||||
<div class="role-selection-checkmark-main agent-mode-check" data-agent-mode-check="plan_execute">✓</div>
|
||||
</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">
|
||||
<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-icon-main" aria-hidden="true">🎯</div>
|
||||
<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-description-main" data-i18n="chat.agentModeSupervisorHint">专家路由场景:监督者通过 transfer 动态分派多个专业子代理</div>
|
||||
|
||||
Reference in New Issue
Block a user