init
This commit is contained in:
1 parent
9793b522d9
commit
b7e0b93023
46 files changed
+3632
-899
No files matched your search
+12
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
本项目的重要变更记录在此文件中,格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。
|
||||
|
||||
## [Unreleased] - 2026-08-20
|
||||
## [0.3.2] - 2026-08-20
|
||||
|
||||
### Added
|
||||
|
||||
@@ -23,9 +23,13 @@
|
||||
- 任务详情页按真实 LLM 调用状态展示运行、空闲和暂停状态;来源任务的事实、漏洞、意图、资产引用和图谱节点统一标记来源并保持只读。
|
||||
- Agent 预览、任务报告和相关详情视图统一使用共享 Markdown 渲染组件。
|
||||
- LLM 模型配置采用卡片和抽屉交互,模型列表在抽屉内支持独立滚动。
|
||||
- 任务暂停不再拦截主 Agent 对话:主 Agent 编排会话独立于任务暂停,暂停中仍可继续发送新消息(暂停只终止当前进行中的那一轮)。
|
||||
- Worker 单次 run 的墙钟默认时长由 600 秒调整为 1200 秒。
|
||||
|
||||
### Fixed
|
||||
|
||||
- 移除主 Agent 控制台的乐观回显,改为纯服务端数据渲染,修复暂停或发送失败等场景下消息错乱、串入其他会话内容的问题。
|
||||
- 打开主 Agent 会话默认滚动到底并完整显示最后一条回复:最后一条回复的完整内容懒加载展开后自动贴底,不再被顶出屏幕。
|
||||
- 修复暂停任务后主 Agent 会话仍继续运行,以及编排 Agent 暂停任务时未同步停止主 Agent 的问题。
|
||||
- 修复任务完成、Planner、Worker 或主 Agent 实际运行时,任务状态徽标与操作按钮状态不同步的问题。
|
||||
- 修复 Worker 取消、任务删除和并发写入之间可能产生迟到黑板写入或残留文件的问题。
|
||||
@@ -34,4 +38,10 @@
|
||||
- 修复后端关闭时父 context 可能抢先覆盖具名 `shutdown` 原因的竞态,以及中文活动摘要按字节截断导致乱码的问题。
|
||||
- 修复带部分流式输出的取消事件丢失真实终止原因的问题,同时保留取消前已生成的内容。
|
||||
|
||||
[Unreleased]: https://github.com/Autumn-27/ARTEX/compare/v0.3.1...HEAD
|
||||
### 贡献者
|
||||
|
||||
- [@Autumn-27](https://github.com/Autumn-27)
|
||||
- [@neouks](https://github.com/neouks)
|
||||
|
||||
[Unreleased]: https://github.com/Autumn-27/ARTEX/compare/v0.3.2...HEAD
|
||||
[0.3.2]: https://github.com/Autumn-27/ARTEX/compare/v0.3.1...v0.3.2
|
||||
@@ -92,6 +92,7 @@ func (c *ChatAgent) Chat(ctx context.Context, agentKey, sessionID, message strin
|
||||
// visible skills/MCP and lets the DB tools table filter/override. DefaultTools
|
||||
// have no tools-table rows, so they always pass through.
|
||||
base := actool.DefaultTools()
|
||||
ctx = WithRunInfo(ctx, RunInfo{SessionID: sessionID})
|
||||
tools, def, cleanup := AugmentTools(ctx, agentKey, base)
|
||||
defer cleanup()
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@ func (m *MainAgent) Chat(ctx context.Context, taskID int64, as *db.AssetStore, t
|
||||
tsx.SetNotifyGoal(notifyGoal) // set_goals 新增目标 → 给 planner 记一条「人新增了目标:…」触发
|
||||
// 领域工具 + 基础默认工具集(Read/Write/Edit/MultiEdit/LS/Glob/Grep/Bash)
|
||||
base := append(tsx.MainAgentTools(), actool.DefaultTools()...)
|
||||
ctx = WithRunInfo(ctx, RunInfo{TaskID: taskID, ExplorationID: explorationID(ts)})
|
||||
tools, def, cleanup := AugmentTools(ctx, "mainagent", base)
|
||||
defer cleanup()
|
||||
// 本任务的工作目录 <workDir>/tasks/<taskID>,先建好。
|
||||
|
||||
@@ -273,6 +273,7 @@ func (p *Planner) Plan(ctx context.Context, taskID int64, as *db.AssetStore, ts
|
||||
}
|
||||
// 领域工具 + 基础默认工具集(Read/Write/Edit/MultiEdit/LS/Glob/Grep/Bash)
|
||||
base := append(tsx.PlannerTools(), actool.DefaultTools()...)
|
||||
ctx = WithRunInfo(ctx, RunInfo{TaskID: taskID, ExplorationID: explorationID(ts)})
|
||||
tools, def, cleanup := AugmentTools(ctx, "planner", base)
|
||||
defer cleanup()
|
||||
// 关键态势(刚完成的意图 + 预取的完整图)改放【本轮 user 输入】(见下方 input),system
|
||||
|
||||
+12
-14
@@ -41,9 +41,14 @@ type Config struct {
|
||||
// ContextWindowK is the model's context window in K tokens (user-configured),
|
||||
// used to size compaction thresholds. 0 = default; see CompactionWindow.
|
||||
ContextWindowK int
|
||||
// ReasoningEffort selects the thinking mode: "" = 默认(不发送思考参数);
|
||||
// "off" = 关闭(thinking.type=disabled); "low"/"medium"/"high"/"max" = 开启并设强度.
|
||||
// NewProvider derives the provider-specific request fields from it.
|
||||
// ThinkingType 独立控制思考「开关」字段(thinking.type):
|
||||
// "" = 不发送(默认,兼容不支持该字段的模型); "disabled" = 显式关闭;
|
||||
// "enabled" = 开启. 与 ReasoningEffort 完全解耦——有些接口没有 thinking 字段、
|
||||
// 只靠强度参数就能激活思考,故两者可各自单独设置.
|
||||
ThinkingType string
|
||||
// ReasoningEffort 独立控制思考「强度」字段:
|
||||
// "" = 不发送(默认); "low"/"medium"/"high"/"xhigh"/"max" = 对应强度.
|
||||
// OpenAI 映射为顶层 reasoning_effort;Anthropic 映射为 output_config.effort.
|
||||
ReasoningEffort string
|
||||
}
|
||||
|
||||
@@ -184,17 +189,10 @@ func (c Config) NewProvider() (llm.Provider, error) {
|
||||
Model: c.Model,
|
||||
HTTPClient: client,
|
||||
}
|
||||
// Derive the provider thinking params from the single UI selector:
|
||||
// "" → omit both (provider default); "off" → disable; else → enable + effort.
|
||||
switch c.ReasoningEffort {
|
||||
case "":
|
||||
// leave both empty → omitted from the request
|
||||
case "off":
|
||||
lc.ThinkingType = "disabled"
|
||||
default:
|
||||
lc.ThinkingType = "enabled"
|
||||
lc.ReasoningEffort = c.ReasoningEffort
|
||||
}
|
||||
// 思考开关与强度两个字段各自透传(空 = 该字段不发送)。二者解耦:
|
||||
// 可只发 thinking.type、只发 effort、都发、或都不发。
|
||||
lc.ThinkingType = c.ThinkingType
|
||||
lc.ReasoningEffort = c.ReasoningEffort
|
||||
if c.RatePerSecond > 0 || c.RatePerMinute > 0 {
|
||||
lc.RateLimit = &llm.RateLimit{PerSecond: c.RatePerSecond, PerMinute: c.RatePerMinute}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
)
|
||||
|
||||
// RunInfo identifies WHICH run a tool call belongs to. Tool assembly only receives
|
||||
// (ctx, agentKey) — the task/exploration ids live in the caller's arguments, not the
|
||||
// ctx — so anything wired at assembly time (currently the Skill ledger in
|
||||
// server/assembly.go) has no way to attribute a call to a task. Each run attaches
|
||||
// its own RunInfo before calling AugmentTools; the wiring closure reads it once and
|
||||
// captures it, so per-run attribution stays correct without threading parameters
|
||||
// through the tool layer. Same pattern as TaskClock (see taskclock.go).
|
||||
//
|
||||
// Zero value = attribution unknown; every consumer must treat it as optional.
|
||||
type RunInfo struct {
|
||||
TaskID int64 // task registry id; 0 for non-task runs (chat sessions)
|
||||
ExplorationID int64 // exploration id; 0 when unknown
|
||||
IntentID int64 // worker's intent node; 0 for planner/mainagent/chat
|
||||
SessionID string // chat conversation id; empty for task runs
|
||||
}
|
||||
|
||||
// explorationID reads a store's exploration id, tolerating a nil store (planner and
|
||||
// worker runs can be driven without one in tests).
|
||||
func explorationID(ts *db.ExplorationStore) int64 {
|
||||
if ts == nil {
|
||||
return 0
|
||||
}
|
||||
return ts.ID()
|
||||
}
|
||||
|
||||
type runInfoKey struct{}
|
||||
|
||||
// WithRunInfo attaches run attribution to ctx.
|
||||
func WithRunInfo(ctx context.Context, ri RunInfo) context.Context {
|
||||
return context.WithValue(ctx, runInfoKey{}, ri)
|
||||
}
|
||||
|
||||
// RunInfoFrom reads the RunInfo (zero value if none attached).
|
||||
func RunInfoFrom(ctx context.Context) RunInfo {
|
||||
if v, ok := ctx.Value(runInfoKey{}).(RunInfo); ok {
|
||||
return v
|
||||
}
|
||||
return RunInfo{}
|
||||
}
|
||||
@@ -382,7 +382,7 @@ func (t *ToolSet) addTaskScope() actool.CoreTool {
|
||||
var added []map[string]any
|
||||
errs := map[string]string{}
|
||||
for i, e := range items {
|
||||
ts, err := t.as.AddAgentScope(t.taskID, strings.TrimSpace(e.Kind), e.Value, a.Reason)
|
||||
ts, err := t.as.AddAgentScope(t.taskID, strings.TrimSpace(e.Kind), e.Value, a.Reason, "agent")
|
||||
if err != nil {
|
||||
errs[strconv.Itoa(i)] = err.Error()
|
||||
continue
|
||||
|
||||
@@ -265,6 +265,7 @@ func (w *Worker) Execute(ctx context.Context, name string, taskID int64, as *db.
|
||||
// phase, Bash is hidden via Settlement.DisabledTools (no local gating needed).
|
||||
base := append(tsx.WorkerTools(), w.extraTools...)
|
||||
base = append(base, actool.DefaultTools()...)
|
||||
ctx = WithRunInfo(ctx, RunInfo{TaskID: taskID, ExplorationID: explorationID(ts), IntentID: intent.ID})
|
||||
tools, def, cleanup := AugmentTools(ctx, "worker", base)
|
||||
defer cleanup()
|
||||
|
||||
@@ -360,6 +361,18 @@ func (w *Worker) Execute(ctx context.Context, name string, taskID int64, as *db.
|
||||
s := agentcore.NewSession(opts)
|
||||
defer s.Close() // release the session's background-task manager (temp dir + processes)
|
||||
|
||||
// Resume prior conversation if this intent was paused/blocked/exhausted and is
|
||||
// being re-run. The transcript ID is deterministic per intent, so if a prior
|
||||
// session exists the worker continues from where it left off instead of
|
||||
// restarting from scratch.
|
||||
if w.tx != nil {
|
||||
_ = s.Resume(opts.SessionID)
|
||||
if len(s.Messages()) > 0 {
|
||||
seedUnlockFromHistory(s.Messages(), def.UnlockSkill)
|
||||
input = "继续执行。"
|
||||
}
|
||||
}
|
||||
|
||||
// Budgets + settlement are owned by the SDK (MaxTurns/MaxDuration + Settlement):
|
||||
// on hit it runs a wrap-up turn and finishes with ReasonMaxTurns/ReasonTimeout.
|
||||
// We only add a HARD ctx backstop past the soft wall-clock budget, for the rare
|
||||
|
||||
@@ -209,6 +209,18 @@ func (d *DB) ListLLMRecords(model, session, task string, page, size int) ([]LLMR
|
||||
return out, total, rows.Err()
|
||||
}
|
||||
|
||||
// ModelTokenStat is one model's aggregated token usage for a task, summed from the
|
||||
// llm_usage metering ledger (see db/llm_usage.go). Calls is the number of LLM calls
|
||||
// that hit this model.
|
||||
type ModelTokenStat struct {
|
||||
Model string `json:"model"`
|
||||
Calls int `json:"calls"`
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
CacheReadTokens int `json:"cache_read_tokens"`
|
||||
CacheWriteTokens int `json:"cache_write_tokens"`
|
||||
}
|
||||
|
||||
// LLMTask is one distinct task with its LLM-record count.
|
||||
type LLMTask struct {
|
||||
TaskID string `json:"task_id"`
|
||||
|
||||
+15
-13
@@ -25,9 +25,11 @@ type LLMProfile struct {
|
||||
// ContextWindowK is the model's context window in K tokens, used to size
|
||||
// compaction thresholds. 0 = use a 200K default; capped at 1000 (1M).
|
||||
ContextWindowK int `json:"context_window_k"`
|
||||
// ReasoningEffort is the thinking-mode selector. "" = 默认(不发送思考参数);
|
||||
// "off" = 显式关闭(thinking.type=disabled); "low"/"medium"/"high"/"max" =
|
||||
// 开启思考并设强度. Mapped to the provider request in agent.Config.NewProvider.
|
||||
// ThinkingType 独立控制思考「开关」(thinking.type):"" = 不发送(默认);
|
||||
// "disabled" = 显式关闭; "enabled" = 开启. 与 ReasoningEffort 解耦.
|
||||
ThinkingType string `json:"thinking_type"`
|
||||
// ReasoningEffort 独立控制思考「强度」:"" = 不发送(默认);
|
||||
// "low"/"medium"/"high"/"xhigh"/"max" = 对应强度. 见 agent.Config.NewProvider.
|
||||
ReasoningEffort string `json:"reasoning_effort"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
// Priority orders the failover chain: higher goes first. The ACTIVE profile
|
||||
@@ -41,14 +43,14 @@ type LLMProfile struct {
|
||||
|
||||
// profileCols is the read column list (hint variant, no api key) shared by the
|
||||
// list query; profileColsKey is the same with api_key for the single-row loads.
|
||||
const profileCols = `id,name,format,COALESCE(base_url,''),COALESCE(proxy,''),model,COALESCE(api_key_hint,''),rate_per_second,rate_per_minute,context_window_k,COALESCE(reasoning_effort,''),is_default,priority,pool_exclude`
|
||||
const profileColsKey = `id,name,format,COALESCE(base_url,''),COALESCE(proxy,''),model,COALESCE(api_key,''),rate_per_second,rate_per_minute,context_window_k,COALESCE(reasoning_effort,''),is_default,priority,pool_exclude`
|
||||
const profileCols = `id,name,format,COALESCE(base_url,''),COALESCE(proxy,''),model,COALESCE(api_key_hint,''),rate_per_second,rate_per_minute,context_window_k,COALESCE(reasoning_effort,''),is_default,priority,pool_exclude,COALESCE(thinking_type,'')`
|
||||
const profileColsKey = `id,name,format,COALESCE(base_url,''),COALESCE(proxy,''),model,COALESCE(api_key,''),rate_per_second,rate_per_minute,context_window_k,COALESCE(reasoning_effort,''),is_default,priority,pool_exclude,COALESCE(thinking_type,'')`
|
||||
|
||||
// scanProfile reads one row in the profileCols / profileColsKey column order. The
|
||||
// 7th column lands in APIKeyHint or APIKey depending on which list the caller used.
|
||||
func scanProfile(sc interface{ Scan(...any) error }, into *string, p *LLMProfile) error {
|
||||
return sc.Scan(&p.ID, &p.Name, &p.Format, &p.BaseURL, &p.Proxy, &p.Model, into,
|
||||
&p.RatePerSecond, &p.RatePerMinute, &p.ContextWindowK, &p.ReasoningEffort, &p.IsDefault, &p.Priority, &p.PoolExclude)
|
||||
&p.RatePerSecond, &p.RatePerMinute, &p.ContextWindowK, &p.ReasoningEffort, &p.IsDefault, &p.Priority, &p.PoolExclude, &p.ThinkingType)
|
||||
}
|
||||
|
||||
func (d *DB) ListProfiles() ([]*LLMProfile, error) {
|
||||
@@ -121,18 +123,18 @@ func (d *DB) SaveProfile(p *LLMProfile) (int64, error) {
|
||||
}
|
||||
if p.ID == 0 {
|
||||
var id int64
|
||||
err := d.QueryRow(`INSERT INTO llm_profiles(name,format,base_url,proxy,model,api_key,api_key_hint,rate_per_second,rate_per_minute,context_window_k,reasoning_effort,priority,pool_exclude)
|
||||
VALUES ($1,$2,NULLIF($3,''),NULLIF($4,''),$5,NULLIF($6,''),NULLIF($7,''),$8,$9,$10,$11,$12,$13) RETURNING id`,
|
||||
p.Name, p.Format, p.BaseURL, p.Proxy, p.Model, p.APIKey, hint, p.RatePerSecond, p.RatePerMinute, p.ContextWindowK, p.ReasoningEffort, p.Priority, p.PoolExclude).Scan(&id)
|
||||
err := d.QueryRow(`INSERT INTO llm_profiles(name,format,base_url,proxy,model,api_key,api_key_hint,rate_per_second,rate_per_minute,context_window_k,reasoning_effort,priority,pool_exclude,thinking_type)
|
||||
VALUES ($1,$2,NULLIF($3,''),NULLIF($4,''),$5,NULLIF($6,''),NULLIF($7,''),$8,$9,$10,$11,$12,$13,$14) RETURNING id`,
|
||||
p.Name, p.Format, p.BaseURL, p.Proxy, p.Model, p.APIKey, hint, p.RatePerSecond, p.RatePerMinute, p.ContextWindowK, p.ReasoningEffort, p.Priority, p.PoolExclude, p.ThinkingType).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
if p.APIKey == "" {
|
||||
_, err := d.Exec(`UPDATE llm_profiles SET name=$1,format=$2,base_url=NULLIF($3,''),proxy=NULLIF($4,''),model=$5,rate_per_second=$6,rate_per_minute=$7,context_window_k=$8,reasoning_effort=$9,priority=$10,pool_exclude=$11 WHERE id=$12`,
|
||||
p.Name, p.Format, p.BaseURL, p.Proxy, p.Model, p.RatePerSecond, p.RatePerMinute, p.ContextWindowK, p.ReasoningEffort, p.Priority, p.PoolExclude, p.ID)
|
||||
_, err := d.Exec(`UPDATE llm_profiles SET name=$1,format=$2,base_url=NULLIF($3,''),proxy=NULLIF($4,''),model=$5,rate_per_second=$6,rate_per_minute=$7,context_window_k=$8,reasoning_effort=$9,priority=$10,pool_exclude=$11,thinking_type=$12 WHERE id=$13`,
|
||||
p.Name, p.Format, p.BaseURL, p.Proxy, p.Model, p.RatePerSecond, p.RatePerMinute, p.ContextWindowK, p.ReasoningEffort, p.Priority, p.PoolExclude, p.ThinkingType, p.ID)
|
||||
return p.ID, err
|
||||
}
|
||||
_, err := d.Exec(`UPDATE llm_profiles SET name=$1,format=$2,base_url=NULLIF($3,''),proxy=NULLIF($4,''),model=$5,api_key=$6,api_key_hint=$7,rate_per_second=$8,rate_per_minute=$9,context_window_k=$10,reasoning_effort=$11,priority=$12,pool_exclude=$13 WHERE id=$14`,
|
||||
p.Name, p.Format, p.BaseURL, p.Proxy, p.Model, p.APIKey, hint, p.RatePerSecond, p.RatePerMinute, p.ContextWindowK, p.ReasoningEffort, p.Priority, p.PoolExclude, p.ID)
|
||||
_, err := d.Exec(`UPDATE llm_profiles SET name=$1,format=$2,base_url=NULLIF($3,''),proxy=NULLIF($4,''),model=$5,api_key=$6,api_key_hint=$7,rate_per_second=$8,rate_per_minute=$9,context_window_k=$10,reasoning_effort=$11,priority=$12,pool_exclude=$13,thinking_type=$14 WHERE id=$15`,
|
||||
p.Name, p.Format, p.BaseURL, p.Proxy, p.Model, p.APIKey, hint, p.RatePerSecond, p.RatePerMinute, p.ContextWindowK, p.ReasoningEffort, p.Priority, p.PoolExclude, p.ThinkingType, p.ID)
|
||||
return p.ID, err
|
||||
}
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@ type builtinAgent struct {
|
||||
key, name, role, desc string
|
||||
vars []promptVar
|
||||
interactiveShell bool // 建行时的默认交互式 shell 开关;ON CONFLICT 不覆盖用户后续手动开关
|
||||
runSeconds *int // 建行时的单次 run 墙钟上限(秒);nil=用表默认(600),0=不限时
|
||||
runSeconds *int // 建行时的单次 run 墙钟上限(秒);nil=用种子默认(1200),0=不限时
|
||||
}
|
||||
|
||||
type promptVar struct{ name, desc, example, source string }
|
||||
@@ -144,7 +144,7 @@ func (d *DB) seedBuiltins() error {
|
||||
var agentID int64
|
||||
err := d.QueryRow(`
|
||||
INSERT INTO agents(key, name, description, role, builtin, enabled, interactive_shell, run_seconds)
|
||||
VALUES ($1, $2, NULLIF($3,''), $4, true, true, $5, COALESCE($6, 600))
|
||||
VALUES ($1, $2, NULLIF($3,''), $4, true, true, $5, COALESCE($6, 1200))
|
||||
ON CONFLICT (key) DO UPDATE SET name = EXCLUDED.name, description = EXCLUDED.description
|
||||
RETURNING id`, a.key, a.name, a.desc, a.role, a.interactiveShell, a.runSeconds).Scan(&agentID)
|
||||
if err != nil {
|
||||
|
||||
+5
-5
@@ -215,7 +215,7 @@ func (s *ExplorationStore) SetNodeState(id int64, state string) error {
|
||||
// ResetRunningIntents returns any intent left in 'running' back to 'open' so it
|
||||
// is re-claimed. Called on startup: a 'running' intent with no live worker (a
|
||||
// backend restart or crashed worker goroutine left it stuck) would otherwise spin
|
||||
// forever in the UI. Workers re-run an intent from scratch, so reopening is safe.
|
||||
// forever in the UI. Workers resume from their transcript, so reopening is safe.
|
||||
func (s *ExplorationStore) ResetRunningIntents() (int64, error) {
|
||||
res, err := s.db.Exec(`UPDATE exploration_nodes SET state='open', completed_at=NULL, blocked_reason=NULL
|
||||
WHERE exploration_id=$1 AND kind='intent' AND state='running'`, s.expID)
|
||||
@@ -227,9 +227,9 @@ WHERE exploration_id=$1 AND kind='intent' AND state='running'`, s.expID)
|
||||
}
|
||||
|
||||
// ReopenIntent flips ONE not-successfully-finished intent (blocked/exhausted/stopped)
|
||||
// back to 'open' so a worker re-claims and re-runs it from scratch (graph writes it
|
||||
// already produced stay). done/open/running are left untouched. Returns whether a row
|
||||
// changed (false = intent absent or not in a rerunnable state). Used by the "重跑" button.
|
||||
// back to 'open' so a worker re-claims it (graph writes it already produced stay).
|
||||
// The worker resumes from its prior LLM transcript instead of restarting from scratch.
|
||||
// done/open/running are left untouched. Returns whether a row changed. Used by "重跑".
|
||||
func (s *ExplorationStore) ReopenIntent(id int64) (bool, error) {
|
||||
res, err := s.db.Exec(`UPDATE exploration_nodes
|
||||
SET state='open', completed_at=NULL, blocked_reason=NULL
|
||||
@@ -244,7 +244,7 @@ WHERE id=$1 AND exploration_id=$2 AND kind='intent'
|
||||
|
||||
// ReopenBlockedIntents flips EVERY 'blocked' intent in this exploration back to 'open'
|
||||
// (batch rerun after e.g. an LLM/network outage that blocked many at once). Returns the
|
||||
// number reopened. Workers re-run each from scratch; kept graph writes remain.
|
||||
// number reopened. Workers resume from their transcripts; kept graph writes remain.
|
||||
func (s *ExplorationStore) ReopenBlockedIntents() (int64, error) {
|
||||
res, err := s.db.Exec(`UPDATE exploration_nodes SET state='open', completed_at=NULL, blocked_reason=NULL
|
||||
WHERE exploration_id=$1 AND kind='intent' AND state='blocked'`, s.expID)
|
||||
|
||||
@@ -356,13 +356,13 @@ func TestTaskAssetContextUsesDirectSourceScopeAndAnchors(t *testing.T) {
|
||||
_ = d.DeleteTask(grand.ID)
|
||||
_, _ = assets.DeleteByIDs([]int64{sourceTestedID, sourceUntestedID, sourceAnchoredOnlyID, grandID})
|
||||
})
|
||||
if _, err := assets.AddAgentScope(source.ID, "root_domain", sourceTestedDomain, "test"); err != nil {
|
||||
if _, err := assets.AddAgentScope(source.ID, "root_domain", sourceTestedDomain, "test", "agent"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := assets.AddAgentScope(source.ID, "root_domain", sourceUntestedDomain, "test"); err != nil {
|
||||
if _, err := assets.AddAgentScope(source.ID, "root_domain", sourceUntestedDomain, "test", "agent"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := assets.AddAgentScope(grand.ID, "root_domain", grandDomain, "test"); err != nil {
|
||||
if _, err := assets.AddAgentScope(grand.ID, "root_domain", grandDomain, "test", "agent"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := d.Exploration(source.ExplorationID).AddNode(KindFact, map[string]any{"summary": "tested source asset"}, 0, "confirmed", "worker", []int64{sourceTestedID}); err != nil {
|
||||
|
||||
@@ -210,6 +210,57 @@ func (d *DB) ListFindingsPage(f FindingFilter, page, pageSize int) ([]*DBFinding
|
||||
return out, total, err
|
||||
}
|
||||
|
||||
// ListFindingsForExport returns findings for the 发现 page 导出功能,携带完整
|
||||
// report 字段、不分页。ids 非空时按这批 finding id 精确导出(勾选导出),忽略
|
||||
// filter;ids 为空时按 filter 导出(导出当前筛选/全部)。结果按严重等级降序、
|
||||
// 再按时间倒序,与「导出汇总报告」的分组顺序一致。
|
||||
func (d *DB) ListFindingsForExport(f FindingFilter, ids []int64) ([]*DBFinding, error) {
|
||||
const order = `ORDER BY CASE f.severity WHEN 'critical' THEN 4 WHEN 'high' THEN 3 WHEN 'medium' THEN 2 WHEN 'low' THEN 1 ELSE 0 END DESC, f.created_at DESC`
|
||||
cols := findingSelectCols + `, COALESCE(f.report, '')`
|
||||
|
||||
var q string
|
||||
var args []any
|
||||
if len(ids) > 0 {
|
||||
ph := make([]string, len(ids))
|
||||
for i, id := range ids {
|
||||
ph[i] = fmt.Sprintf("$%d", i+1)
|
||||
args = append(args, id)
|
||||
}
|
||||
q = `SELECT ` + cols + `
|
||||
FROM findings f
|
||||
LEFT JOIN tasks t ON f.task_id = t.id
|
||||
WHERE f.id IN (` + strings.Join(ph, ",") + `)
|
||||
` + order
|
||||
} else {
|
||||
where, wargs := f.where()
|
||||
q = `SELECT ` + cols + `
|
||||
FROM findings f
|
||||
LEFT JOIN tasks t ON f.task_id = t.id` + where + `
|
||||
` + order
|
||||
args = wargs
|
||||
}
|
||||
|
||||
rows, err := d.Query(q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []*DBFinding
|
||||
for rows.Next() {
|
||||
f := &DBFinding{}
|
||||
var aidsJSON string
|
||||
if err := rows.Scan(&f.ID, &f.TaskID, &f.NodeID, &f.VulnClass, &f.Name, &f.Severity,
|
||||
&f.Summary, &f.Evidence, &f.Worker, &aidsJSON, &f.Status, &f.CreatedAt,
|
||||
&f.TaskDescription, &f.Report); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = json.Unmarshal([]byte(aidsJSON), &f.AssetIDs)
|
||||
out = append(out, f)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// FindingStats is the whole-table aggregate powering the 发现 page's stat cards
|
||||
// and vuln-class filter — computed server-side so it stays exact regardless of
|
||||
// pagination.
|
||||
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
package db
|
||||
|
||||
import "strconv"
|
||||
|
||||
// LLMUsage is one lightweight LLM-call metering row — the always-on usage ledger,
|
||||
// distinct from llm_records (which stores full request/response bodies and is a
|
||||
// gated debug feature). One row per completion call, written on both success and
|
||||
// error, so token accounting is complete even for interrupted/failed runs. Carries
|
||||
// only the dimensions needed to slice token spend (model / profile / task / agent),
|
||||
// never any prompt or response content.
|
||||
type LLMUsage struct {
|
||||
TaskID string `json:"task_id"` // task registry id (matches llm_records.task_id)
|
||||
ExplorationID int64 `json:"exploration_id"` // exploration id parsed from the session (0 = unknown/non-task)
|
||||
Worker string `json:"worker"` // agent lane: worker / planner / mainagent / goals
|
||||
Model string `json:"model"`
|
||||
ProfileName string `json:"profile_name"`
|
||||
LatencyMs int `json:"latency_ms"`
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
CacheRead int `json:"cache_read"`
|
||||
CacheWrite int `json:"cache_write"`
|
||||
Status string `json:"status"` // ok | error
|
||||
}
|
||||
|
||||
const llmUsageSchema = `
|
||||
CREATE TABLE IF NOT EXISTS llm_usage (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
ts TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
task_id TEXT,
|
||||
exploration_id BIGINT,
|
||||
worker TEXT,
|
||||
model TEXT,
|
||||
profile_name TEXT,
|
||||
latency_ms INTEGER,
|
||||
input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cache_read INTEGER NOT NULL DEFAULT 0,
|
||||
cache_write INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_llm_usage_task ON llm_usage(task_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_llm_usage_model ON llm_usage(task_id, model);
|
||||
CREATE INDEX IF NOT EXISTS idx_llm_usage_exp ON llm_usage(exploration_id);
|
||||
`
|
||||
|
||||
// EnsureLLMUsageTable creates the llm_usage metering table if it does not exist.
|
||||
func (d *DB) EnsureLLMUsageTable() error {
|
||||
_, err := d.Exec(llmUsageSchema)
|
||||
return err
|
||||
}
|
||||
|
||||
// InsertLLMUsage appends one metering row. Best-effort: callers log and continue on
|
||||
// error (a lost metering row must never break the LLM call).
|
||||
func (d *DB) InsertLLMUsage(u *LLMUsage) error {
|
||||
var expID any
|
||||
if u.ExplorationID > 0 {
|
||||
expID = u.ExplorationID
|
||||
}
|
||||
_, err := d.Exec(`
|
||||
INSERT INTO llm_usage(task_id, exploration_id, worker, model, profile_name, latency_ms, input_tokens, output_tokens, cache_read, cache_write, status)
|
||||
VALUES (NULLIF($1,''),$2,NULLIF($3,''),NULLIF($4,''),NULLIF($5,''),$6,$7,$8,$9,$10,$11)`,
|
||||
u.TaskID, expID, u.Worker, u.Model, u.ProfileName,
|
||||
u.LatencyMs, u.InputTokens, u.OutputTokens, u.CacheRead, u.CacheWrite, u.Status)
|
||||
return err
|
||||
}
|
||||
|
||||
// TokenByModel aggregates a task's LLM token usage grouped by model, most-used
|
||||
// first, from the always-on llm_usage ledger. taskID is the task registry id.
|
||||
// Accurate even with per-agent model bindings, pool rotation/failover, and
|
||||
// interrupted runs, since every call (success or error) is metered.
|
||||
func (d *DB) TokenByModel(taskID string) ([]ModelTokenStat, error) {
|
||||
rows, err := d.Query(`
|
||||
SELECT COALESCE(NULLIF(model,''),'(unknown)') AS model, COUNT(*) AS calls,
|
||||
COALESCE(SUM(input_tokens),0), COALESCE(SUM(output_tokens),0),
|
||||
COALESCE(SUM(cache_read),0), COALESCE(SUM(cache_write),0)
|
||||
FROM llm_usage
|
||||
WHERE COALESCE(task_id,'') = $1
|
||||
GROUP BY model
|
||||
ORDER BY SUM(input_tokens) + SUM(output_tokens) DESC, model`, taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []ModelTokenStat{}
|
||||
for rows.Next() {
|
||||
var m ModelTokenStat
|
||||
if err := rows.Scan(&m.Model, &m.Calls, &m.InputTokens, &m.OutputTokens,
|
||||
&m.CacheReadTokens, &m.CacheWriteTokens); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ProfileUsage aggregates the whole ledger's token spend for one LLM profile
|
||||
// (global, all tasks). Powers the dashboard's per-profile token card (new source).
|
||||
type ProfileUsage struct {
|
||||
ProfileName string `json:"profile_name"`
|
||||
Calls int `json:"calls"`
|
||||
Tasks int `json:"tasks"`
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
CacheReadTokens int `json:"cache_read_tokens"`
|
||||
CacheWriteTokens int `json:"cache_write_tokens"`
|
||||
}
|
||||
|
||||
// UsageByProfile returns global token spend grouped by profile name, most-used
|
||||
// first. profile_name may be empty for calls made on env/non-persisted configs.
|
||||
func (d *DB) UsageByProfile() ([]ProfileUsage, error) {
|
||||
rows, err := d.Query(`
|
||||
SELECT COALESCE(profile_name,'') AS profile_name, COUNT(*) AS calls,
|
||||
COUNT(DISTINCT task_id) AS tasks,
|
||||
COALESCE(SUM(input_tokens),0), COALESCE(SUM(output_tokens),0),
|
||||
COALESCE(SUM(cache_read),0), COALESCE(SUM(cache_write),0)
|
||||
FROM llm_usage
|
||||
GROUP BY profile_name
|
||||
ORDER BY SUM(input_tokens) + SUM(output_tokens) DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []ProfileUsage{}
|
||||
for rows.Next() {
|
||||
var p ProfileUsage
|
||||
if err := rows.Scan(&p.ProfileName, &p.Calls, &p.Tasks,
|
||||
&p.InputTokens, &p.OutputTokens, &p.CacheReadTokens, &p.CacheWriteTokens); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ProfileDayUsage is one (profile, UTC calendar day) token bucket for the daily
|
||||
// chart. Unlike the activity-based chart, ts is the real call time, so this is
|
||||
// actual per-day consumption rather than tokens bucketed by task creation date.
|
||||
type ProfileDayUsage struct {
|
||||
ProfileName string `json:"profile_name"`
|
||||
Date string `json:"date"` // YYYY-MM-DD (UTC)
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
CacheReadTokens int `json:"cache_read_tokens"`
|
||||
}
|
||||
|
||||
// UsageDaily returns per-(profile, day) token buckets for the past `days` days
|
||||
// (default 365 when days<=0), so the dashboard can slice by profile + range.
|
||||
func (d *DB) UsageDaily(days int) ([]ProfileDayUsage, error) {
|
||||
if days <= 0 {
|
||||
days = 365
|
||||
}
|
||||
rows, err := d.Query(`
|
||||
SELECT COALESCE(profile_name,'') AS profile_name,
|
||||
to_char(ts AT TIME ZONE 'UTC', 'YYYY-MM-DD') AS day,
|
||||
COALESCE(SUM(input_tokens),0), COALESCE(SUM(output_tokens),0), COALESCE(SUM(cache_read),0)
|
||||
FROM llm_usage
|
||||
WHERE ts >= now() - ($1 * interval '1 day')
|
||||
GROUP BY profile_name, day
|
||||
ORDER BY day`, days)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []ProfileDayUsage{}
|
||||
for rows.Next() {
|
||||
var p ProfileDayUsage
|
||||
if err := rows.Scan(&p.ProfileName, &p.Date, &p.InputTokens, &p.OutputTokens, &p.CacheReadTokens); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ParseExpID turns the exploration-id segment parsed from a session string into an
|
||||
// int64 (0 when empty/non-numeric, e.g. chat sessions keyed by conversation id).
|
||||
func ParseExpID(s string) int64 {
|
||||
n, err := strconv.ParseInt(s, 10, 64)
|
||||
if err != nil || n < 0 {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
+46
-1
@@ -235,7 +235,10 @@ CREATE TABLE IF NOT EXISTS llm_profiles (
|
||||
rate_per_second DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
rate_per_minute DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
context_window_k INTEGER NOT NULL DEFAULT 0,
|
||||
-- 思考参数拆成两个独立字段:thinking_type=思考开关(''/disabled/enabled),
|
||||
-- reasoning_effort=思考强度(''/low/medium/high/xhigh/max),互不牵连。
|
||||
reasoning_effort TEXT NOT NULL DEFAULT '',
|
||||
thinking_type TEXT NOT NULL DEFAULT '',
|
||||
is_default BOOLEAN NOT NULL DEFAULT false,
|
||||
-- 轮询(故障转移)参数,见 docs/LLM轮询设计.md:
|
||||
-- priority 顺位,越大越先被选中;激活配置(is_default)永远排链首,与本值无关。
|
||||
@@ -253,6 +256,26 @@ CREATE TRIGGER trg_llm_upd BEFORE UPDATE ON llm_profiles
|
||||
ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS priority INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS pool_exclude BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- 思考开关字段 thinking_type,从旧的单一 reasoning_effort 语义一次性拆分而来。
|
||||
-- schema.sql 每次启动都执行,故迁移必须只跑一次:仅当该列尚不存在时才回填,
|
||||
-- 否则每次启动都会把用户后来手动设的组合覆盖回去。旧 reasoning_effort 语义:
|
||||
-- 'off' → 显式关闭 → thinking_type='disabled',强度清空
|
||||
-- 'low/medium/high/max' → 开启+强度 → thinking_type='enabled',强度保留
|
||||
-- '' → 不发送 → 两者皆空(默认)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'llm_profiles' AND column_name = 'thinking_type'
|
||||
) THEN
|
||||
ALTER TABLE llm_profiles ADD COLUMN thinking_type TEXT NOT NULL DEFAULT '';
|
||||
UPDATE llm_profiles SET thinking_type = 'enabled'
|
||||
WHERE reasoning_effort IN ('low','medium','high','max');
|
||||
UPDATE llm_profiles SET thinking_type = 'disabled', reasoning_effort = ''
|
||||
WHERE reasoning_effort = 'off';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- LLM 轮询熔断状态:某个配置连续失败(余额不足/key 失效/限流)后进入冷却,冷却期内
|
||||
-- 轮询直接跳过它。内存态为准,这里落库只为重启后不丢冷却窗口——加载时只取尚未
|
||||
-- 到期的行(open_until > now),已到期的自然回到"正常",等下一次调用半开试探。
|
||||
@@ -391,7 +414,7 @@ CREATE TABLE IF NOT EXISTS agents (
|
||||
llm_profile_id BIGINT REFERENCES llm_profiles(id) ON DELETE SET NULL,
|
||||
current_prompt_id BIGINT,
|
||||
max_turns INTEGER NOT NULL DEFAULT 0,
|
||||
run_seconds INTEGER NOT NULL DEFAULT 600,
|
||||
run_seconds INTEGER NOT NULL DEFAULT 1200,
|
||||
web_search BOOLEAN NOT NULL DEFAULT false,
|
||||
interactive_shell BOOLEAN NOT NULL DEFAULT false,
|
||||
wrapup_prompt TEXT NOT NULL DEFAULT '',
|
||||
@@ -411,6 +434,8 @@ ALTER TABLE agents ADD COLUMN IF NOT EXISTS trigger_merge_mode TEXT NOT NUL
|
||||
ALTER TABLE agents ADD COLUMN IF NOT EXISTS trigger_max_parallel INTEGER NOT NULL DEFAULT 5;
|
||||
-- per-agent LLM 绑定(agent 级默认模型):列自初版即在上方 CREATE 中,此 ALTER 仅为极旧库兜底(幂等)。
|
||||
ALTER TABLE agents ADD COLUMN IF NOT EXISTS llm_profile_id BIGINT REFERENCES llm_profiles(id) ON DELETE SET NULL;
|
||||
-- run_seconds 单次 run 墙钟默认 600→1200:只改列默认(影响将来新插入的行),不动旧库存量行。
|
||||
ALTER TABLE agents ALTER COLUMN run_seconds SET DEFAULT 1200;
|
||||
CREATE INDEX IF NOT EXISTS idx_agents_llm_profile ON agents(llm_profile_id) WHERE llm_profile_id IS NOT NULL;
|
||||
DROP TRIGGER IF EXISTS trg_agents_upd ON agents;
|
||||
CREATE TRIGGER trg_agents_upd BEFORE UPDATE ON agents
|
||||
@@ -501,6 +526,26 @@ CREATE TABLE IF NOT EXISTS agent_skill_visibility (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_askv_skill ON agent_skill_visibility(skill_name);
|
||||
|
||||
-- Skill 调用账本(见 db/skill_usage.go)。一次 Skill() 调用一行,只记维度不记正文。
|
||||
-- 刻意不设外键:任务/会话删除后统计仍要保留(与 llm_usage 同理),skill 本身也只是
|
||||
-- 文件系统上的目录名,没有对应的表。
|
||||
CREATE TABLE IF NOT EXISTS skill_usage (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
ts TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
skill TEXT NOT NULL,
|
||||
agent_key TEXT,
|
||||
task_id BIGINT,
|
||||
exploration_id BIGINT,
|
||||
intent_id BIGINT,
|
||||
session_id TEXT,
|
||||
args_len INTEGER NOT NULL DEFAULT 0,
|
||||
-- false = 模型点名了一个不存在的 skill(未命中)。这类行同样保留:它反映"想用但没有"
|
||||
-- 的缺口,是补 skill 的依据。
|
||||
found BOOLEAN NOT NULL DEFAULT true
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_skill_usage_skill ON skill_usage(skill, ts DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_skill_usage_task ON skill_usage(task_id);
|
||||
|
||||
-- =====================================================================
|
||||
-- H. 内置工具目录
|
||||
-- =====================================================================
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SkillUsage is one Skill() invocation — the always-on skill call ledger. Written
|
||||
// from the Skill meta-tool's OnInvoke hook (server/assembly.go), one row per load.
|
||||
// Carries only dimensions (which skill, which agent, which task/session), never the
|
||||
// caller's args text — args_len is kept so an "empty vs substantial context" split
|
||||
// is still possible without storing prompt content, mirroring llm_usage.
|
||||
//
|
||||
// Rows deliberately outlive their task: skill_usage has no foreign keys, so deleting
|
||||
// a task keeps its skill statistics intact (same rationale as llm_usage).
|
||||
type SkillUsage struct {
|
||||
Skill string `json:"skill"` // skill directory name (matches agent_skill_visibility.skill_name)
|
||||
AgentKey string `json:"agent_key"` // worker / planner / mainagent / custom agent key
|
||||
TaskID int64 `json:"task_id"` // 0 for non-task runs (chat sessions)
|
||||
ExplorationID int64 `json:"exploration_id"` // 0 when unknown
|
||||
IntentID int64 `json:"intent_id"` // worker's intent node; 0 for planner/mainagent/chat
|
||||
SessionID string `json:"session_id"` // chat conversation id; empty for task runs
|
||||
ArgsLen int `json:"args_len"`
|
||||
Found bool `json:"found"` // false = the model named a skill that does not exist
|
||||
}
|
||||
|
||||
// InsertSkillUsage appends one ledger row. Best-effort: callers log and continue on
|
||||
// error (a lost metering row must never break a skill invocation).
|
||||
func (d *DB) InsertSkillUsage(u *SkillUsage) error {
|
||||
_, err := d.Exec(`
|
||||
INSERT INTO skill_usage(skill, agent_key, task_id, exploration_id, intent_id, session_id, args_len, found)
|
||||
VALUES ($1, NULLIF($2,''), $3, $4, $5, NULLIF($6,''), $7, $8)`,
|
||||
u.Skill, u.AgentKey, nullIfZero(u.TaskID), nullIfZero(u.ExplorationID),
|
||||
nullIfZero(u.IntentID), u.SessionID, u.ArgsLen, u.Found)
|
||||
return err
|
||||
}
|
||||
|
||||
func nullIfZero(v int64) any {
|
||||
if v > 0 {
|
||||
return v
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SkillStat is one skill's aggregate usage, for the skills page.
|
||||
type SkillStat struct {
|
||||
Skill string `json:"skill"`
|
||||
Calls int `json:"calls"`
|
||||
Tasks int `json:"tasks"` // distinct tasks that loaded it (chat runs excluded)
|
||||
Agents []string `json:"agents"` // agent keys that loaded it, most-used first
|
||||
LastUsed *time.Time `json:"last_used"` // nil when never called
|
||||
}
|
||||
|
||||
// SkillStats aggregates the whole ledger grouped by skill, most-used first. Skills
|
||||
// that were never invoked are absent — callers merge against the skill list on disk.
|
||||
// Only resolved calls count; misses are reported separately by MissingSkillStats.
|
||||
func (d *DB) SkillStats() ([]SkillStat, error) {
|
||||
// agent keys come back as one comma-joined string rather than text[]: the pgx
|
||||
// stdlib driver has no database/sql Scan target for arrays, and agent keys are
|
||||
// [a-z0-9_-] so a comma join is unambiguous.
|
||||
rows, err := d.Query(`
|
||||
SELECT skill, COUNT(*) AS calls,
|
||||
COUNT(DISTINCT task_id) AS tasks,
|
||||
COALESCE(STRING_AGG(DISTINCT agent_key, ','), '') AS agents,
|
||||
MAX(ts) AS last_used
|
||||
FROM skill_usage
|
||||
WHERE found
|
||||
GROUP BY skill
|
||||
ORDER BY COUNT(*) DESC, skill`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []SkillStat{}
|
||||
for rows.Next() {
|
||||
var s SkillStat
|
||||
var agents string
|
||||
var lastUsed sql.NullTime
|
||||
if err := rows.Scan(&s.Skill, &s.Calls, &s.Tasks, &agents, &lastUsed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.Agents = []string{}
|
||||
if agents != "" {
|
||||
s.Agents = strings.Split(agents, ",")
|
||||
}
|
||||
if lastUsed.Valid {
|
||||
t := lastUsed.Time
|
||||
s.LastUsed = &t
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// MissingSkillStats returns the skill names agents asked for that do not exist,
|
||||
// most-requested first — the "wished it existed" gap list. Names come from the model
|
||||
// so they are shown as-is (already length-capped at insert time).
|
||||
func (d *DB) MissingSkillStats(limit int) ([]SkillStat, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 20
|
||||
}
|
||||
rows, err := d.Query(`
|
||||
SELECT skill, COUNT(*) AS calls,
|
||||
COALESCE(STRING_AGG(DISTINCT agent_key, ','), '') AS agents,
|
||||
MAX(ts) AS last_used
|
||||
FROM skill_usage
|
||||
WHERE NOT found
|
||||
GROUP BY skill
|
||||
ORDER BY COUNT(*) DESC, skill
|
||||
LIMIT $1`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []SkillStat{}
|
||||
for rows.Next() {
|
||||
var s SkillStat
|
||||
var agents string
|
||||
var lastUsed sql.NullTime
|
||||
if err := rows.Scan(&s.Skill, &s.Calls, &agents, &lastUsed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.Agents = []string{}
|
||||
if agents != "" {
|
||||
s.Agents = strings.Split(agents, ",")
|
||||
}
|
||||
if lastUsed.Valid {
|
||||
t := lastUsed.Time
|
||||
s.LastUsed = &t
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// SkillCall is one row of a skill's recent-call list (detail panel).
|
||||
type SkillCall struct {
|
||||
TS time.Time `json:"ts"`
|
||||
AgentKey string `json:"agent_key"`
|
||||
TaskID int64 `json:"task_id"`
|
||||
SessionID string `json:"session_id"`
|
||||
ArgsLen int `json:"args_len"`
|
||||
}
|
||||
|
||||
// RecentSkillCalls returns the most recent invocations of one skill, newest first.
|
||||
func (d *DB) RecentSkillCalls(skill string, limit int) ([]SkillCall, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
rows, err := d.Query(`
|
||||
SELECT ts, COALESCE(agent_key,''), COALESCE(task_id,0), COALESCE(session_id,''), args_len
|
||||
FROM skill_usage
|
||||
WHERE skill = $1 AND found
|
||||
ORDER BY ts DESC
|
||||
LIMIT $2`, skill, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []SkillCall{}
|
||||
for rows.Next() {
|
||||
var c SkillCall
|
||||
if err := rows.Scan(&c.TS, &c.AgentKey, &c.TaskID, &c.SessionID, &c.ArgsLen); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// SkillCallsByTask counts a task's skill loads, most-used first. Powers a per-task
|
||||
// view of which procedures its agents actually reached for.
|
||||
func (d *DB) SkillCallsByTask(taskID int64) ([]SkillStat, error) {
|
||||
rows, err := d.Query(`
|
||||
SELECT skill, COUNT(*) AS calls, MAX(ts) AS last_used
|
||||
FROM skill_usage
|
||||
WHERE task_id = $1 AND found
|
||||
GROUP BY skill
|
||||
ORDER BY COUNT(*) DESC, skill`, taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []SkillStat{}
|
||||
for rows.Next() {
|
||||
var s SkillStat
|
||||
var lastUsed sql.NullTime
|
||||
if err := rows.Scan(&s.Skill, &s.Calls, &lastUsed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.Agents = []string{}
|
||||
if lastUsed.Valid {
|
||||
t := lastUsed.Time
|
||||
s.LastUsed = &t
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestSkillUsageLedger writes a few rows and checks the three aggregates the
|
||||
// skills page relies on: per-skill totals (resolved calls only), the miss list,
|
||||
// and the recent-call list. Rows are namespaced by a unique skill name so the
|
||||
// shared dev DB stays usable and the test cleans up after itself.
|
||||
func TestSkillUsageLedger(t *testing.T) {
|
||||
d, err := Open(testDSN(t))
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) — skipping", err)
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
const (
|
||||
skillA = "zz-test-skill-a"
|
||||
missing = "zz-test-skill-missing"
|
||||
)
|
||||
cleanup := func() {
|
||||
_, _ = d.Exec(`DELETE FROM skill_usage WHERE skill IN ($1,$2)`, skillA, missing)
|
||||
}
|
||||
cleanup()
|
||||
defer cleanup()
|
||||
|
||||
rows := []*SkillUsage{
|
||||
{Skill: skillA, AgentKey: "worker", TaskID: 991, ExplorationID: 5, IntentID: 7, ArgsLen: 12, Found: true},
|
||||
{Skill: skillA, AgentKey: "worker", TaskID: 991, ExplorationID: 5, ArgsLen: 0, Found: true},
|
||||
{Skill: skillA, AgentKey: "planner", TaskID: 992, ExplorationID: 6, Found: true},
|
||||
{Skill: skillA, AgentKey: "chatbot", SessionID: "conv-1", Found: true},
|
||||
{Skill: missing, AgentKey: "worker", TaskID: 991, Found: false},
|
||||
{Skill: missing, AgentKey: "worker", TaskID: 991, Found: false},
|
||||
}
|
||||
for _, r := range rows {
|
||||
if err := d.InsertSkillUsage(r); err != nil {
|
||||
t.Fatalf("insert %s: %v", r.Skill, err)
|
||||
}
|
||||
}
|
||||
|
||||
stats, err := d.SkillStats()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got *SkillStat
|
||||
for i := range stats {
|
||||
if stats[i].Skill == skillA {
|
||||
got = &stats[i]
|
||||
}
|
||||
if stats[i].Skill == missing {
|
||||
t.Fatalf("misses must not appear in SkillStats: %+v", stats[i])
|
||||
}
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatalf("skill %s absent from SkillStats", skillA)
|
||||
}
|
||||
if got.Calls != 4 {
|
||||
t.Errorf("calls: want 4, got %d", got.Calls)
|
||||
}
|
||||
// 991 + 992; the chat row has a NULL task_id and COUNT(DISTINCT) skips NULLs.
|
||||
if got.Tasks != 2 {
|
||||
t.Errorf("tasks: want 2, got %d", got.Tasks)
|
||||
}
|
||||
if len(got.Agents) != 3 {
|
||||
t.Errorf("agents: want 3 distinct, got %v", got.Agents)
|
||||
}
|
||||
if got.LastUsed == nil {
|
||||
t.Error("last_used must be set")
|
||||
}
|
||||
|
||||
miss, err := d.MissingSkillStats(10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var missCalls int
|
||||
for _, m := range miss {
|
||||
if m.Skill == missing {
|
||||
missCalls = m.Calls
|
||||
}
|
||||
}
|
||||
if missCalls != 2 {
|
||||
t.Errorf("missing calls: want 2, got %d", missCalls)
|
||||
}
|
||||
|
||||
calls, err := d.RecentSkillCalls(skillA, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(calls) != 4 {
|
||||
t.Fatalf("recent calls: want 4, got %d", len(calls))
|
||||
}
|
||||
// newest first
|
||||
for i := 1; i < len(calls); i++ {
|
||||
if calls[i].TS.After(calls[i-1].TS) {
|
||||
t.Errorf("recent calls not newest-first at %d", i)
|
||||
}
|
||||
}
|
||||
|
||||
byTask, err := d.SkillCallsByTask(991)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var taskCalls int
|
||||
for _, s := range byTask {
|
||||
if s.Skill == skillA {
|
||||
taskCalls = s.Calls
|
||||
}
|
||||
if s.Skill == missing {
|
||||
t.Errorf("misses must not appear in SkillCallsByTask: %+v", s)
|
||||
}
|
||||
}
|
||||
if taskCalls != 2 {
|
||||
t.Errorf("task 991 calls: want 2, got %d", taskCalls)
|
||||
}
|
||||
}
|
||||
+18
-3
@@ -113,11 +113,15 @@ func (s *AssetStore) AddAutoScope(taskID int64, assetType, domain, rawURL, ip st
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddAgentScope parses an agent-provided (kind,value) and records it (source='agent').
|
||||
// AddAgentScope parses a (kind,value) pair and records it as task scope.
|
||||
// source is "agent" when called from an LLM tool, "manual" from the UI.
|
||||
// company: value = company name or id (must already exist). root_domain/subdomain:
|
||||
// value = a domain. ip/cidr: value = an IP or CIDR (bare IP → /32,/128).
|
||||
func (s *AssetStore) AddAgentScope(taskID int64, kind, value, reason string) (TaskScope, error) {
|
||||
ts := TaskScope{TaskID: taskID, Kind: kind, Source: "agent", Reason: reason}
|
||||
func (s *AssetStore) AddAgentScope(taskID int64, kind, value, reason, source string) (TaskScope, error) {
|
||||
if source == "" {
|
||||
source = "agent"
|
||||
}
|
||||
ts := TaskScope{TaskID: taskID, Kind: kind, Source: source, Reason: reason}
|
||||
if taskID <= 0 {
|
||||
return ts, fmt.Errorf("需要 task_id")
|
||||
}
|
||||
@@ -184,6 +188,17 @@ func (s *AssetStore) AddAgentScope(taskID int64, kind, value, reason string) (Ta
|
||||
return ts, nil
|
||||
}
|
||||
|
||||
// DeleteTaskScope removes a single scope row by id, scoped to the given task.
|
||||
// Returns whether a row was actually deleted.
|
||||
func (s *AssetStore) DeleteTaskScope(taskID, scopeID int64) (bool, error) {
|
||||
res, err := s.db.Exec(`DELETE FROM task_scope WHERE id=$1 AND task_id=$2`, scopeID, taskID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// ListTaskScope returns all scope rows for a task.
|
||||
func (s *AssetStore) ListTaskScope(taskID int64) ([]TaskScope, error) {
|
||||
rows, err := s.db.Query(`
|
||||
|
||||
@@ -437,6 +437,8 @@ DELETE FROM assets a USING deletable d WHERE a.id=d.id`, id, expID)
|
||||
}
|
||||
result.LLMRecordsDeleted, _ = res.RowsAffected()
|
||||
}
|
||||
// llm_usage (the token metering ledger) is intentionally NOT deleted with the
|
||||
// task — it is kept as historical accounting even after the task is gone.
|
||||
if _, err := tx.Exec(`DELETE FROM tasks WHERE id=$1`, id); err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
+102
-32
@@ -11,9 +11,9 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
"github.com/Autumn-27/norma/llm"
|
||||
"github.com/Autumn-27/norma/transcript"
|
||||
"github.com/Autumn-27/artex/db"
|
||||
)
|
||||
|
||||
type taskIDContextKey struct{}
|
||||
@@ -41,16 +41,25 @@ func TaskIDFrom(ctx context.Context) string {
|
||||
type Recorder struct {
|
||||
inner llm.Provider
|
||||
pg *db.DB
|
||||
model string // model name (from config, not in CompletionRequest)
|
||||
prof string // LLM profile name (from llm_profiles)
|
||||
enabled func() bool // reports whether recording is currently on; nil = always record
|
||||
model string // model name (from config, not in CompletionRequest)
|
||||
prof string // LLM profile name (from llm_profiles)
|
||||
// thinkingType / reasoningEffort 是配置级思考参数(思考开关 / 思考强度)。它们在
|
||||
// norma 的 buildBody() 里从 provider 配置注入真正的 HTTP body,不出现在
|
||||
// CompletionRequest 上,故 Recorder 需在此单独带一份,序列化时写进录制。
|
||||
thinkingType string
|
||||
reasoningEffort string
|
||||
enabled func() bool // reports whether recording is currently on; nil = always record
|
||||
}
|
||||
|
||||
// Wrap returns a Provider that records calls to pg when enabled() reports true.
|
||||
// profName is the LLM profile name (e.g. "default"); may be empty. A nil enabled
|
||||
// predicate records unconditionally (backward-compatible default).
|
||||
func Wrap(inner llm.Provider, pg *db.DB, model, profName string, enabled func() bool) *Recorder {
|
||||
return &Recorder{inner: inner, pg: pg, model: model, prof: profName, enabled: enabled}
|
||||
// profName is the LLM profile name (e.g. "default"); may be empty. thinkingType /
|
||||
// reasoningEffort are the config-level thinking params actually sent to the API
|
||||
// (empty = not sent). A nil enabled predicate records unconditionally.
|
||||
func Wrap(inner llm.Provider, pg *db.DB, model, profName, thinkingType, reasoningEffort string, enabled func() bool) *Recorder {
|
||||
return &Recorder{
|
||||
inner: inner, pg: pg, model: model, prof: profName,
|
||||
thinkingType: thinkingType, reasoningEffort: reasoningEffort, enabled: enabled,
|
||||
}
|
||||
}
|
||||
|
||||
// parseSession extracts task id and worker role from session strings like
|
||||
@@ -84,14 +93,15 @@ func parseSession(s string) (taskID, worker string) {
|
||||
// harness via transcript.WithSessionID; reading it per-call is race-free even when
|
||||
// planner and multiple workers share one Recorder instance.
|
||||
func (r *Recorder) Stream(ctx context.Context, req llm.CompletionRequest) iter.Seq2[llm.StreamEvent, error] {
|
||||
// Recording off → passthrough with zero overhead: no request serialization,
|
||||
// no response accumulation, no DB insert.
|
||||
if r.enabled != nil && !r.enabled() {
|
||||
return r.inner.Stream(ctx, req)
|
||||
}
|
||||
// Body recording (the heavy debug trace: full request/response) is gated by the
|
||||
// llm_record setting. Lightweight usage metering always runs — it powers token
|
||||
// stats and must be complete even for interrupted/failed runs, so it is NOT
|
||||
// gated. Only body serialization + response accumulation are skipped when off.
|
||||
recordBodies := r.enabled == nil || r.enabled()
|
||||
start := time.Now()
|
||||
session := transcript.SessionIDFrom(ctx)
|
||||
parsedID, worker := parseSession(session)
|
||||
expID := db.ParseExpID(parsedID)
|
||||
taskID := TaskIDFrom(ctx)
|
||||
if taskID == "" {
|
||||
// Backward compatibility for non-task callers. For task calls, the task
|
||||
@@ -99,51 +109,101 @@ func (r *Recorder) Stream(ctx context.Context, req llm.CompletionRequest) iter.S
|
||||
taskID = parsedID
|
||||
}
|
||||
|
||||
// Serialize the request for storage.
|
||||
reqBody := serializeRequest(req)
|
||||
// Serialize the request only when storing bodies (this is the expensive part).
|
||||
reqBody := ""
|
||||
if recordBodies {
|
||||
reqBody = r.serializeRequest(req)
|
||||
}
|
||||
|
||||
return func(yield func(llm.StreamEvent, error) bool) {
|
||||
var (
|
||||
textBuf strings.Builder
|
||||
textBuf strings.Builder
|
||||
thinkingBuf strings.Builder
|
||||
usage llm.Usage
|
||||
stopReason string
|
||||
streamErr error
|
||||
usage llm.Usage
|
||||
stopReason string
|
||||
streamErr error
|
||||
)
|
||||
|
||||
finish := func(err error) {
|
||||
status := "ok"
|
||||
if err != nil {
|
||||
status = "error"
|
||||
}
|
||||
// Lightweight metering row — always written.
|
||||
r.recordUsage(taskID, expID, worker, usage, int(time.Since(start).Milliseconds()), status)
|
||||
// Heavy trace row — only when body recording is on.
|
||||
if recordBodies {
|
||||
r.record(req, session, taskID, worker, reqBody, start, textBuf.String(), thinkingBuf.String(), usage, stopReason, err)
|
||||
}
|
||||
}
|
||||
|
||||
for ev, err := range r.inner.Stream(ctx, req) {
|
||||
if err != nil {
|
||||
streamErr = err
|
||||
// Record the failed call, then propagate.
|
||||
r.record(req, session, taskID, worker, reqBody, start, textBuf.String(), thinkingBuf.String(), usage, "", err)
|
||||
finish(streamErr)
|
||||
if !yield(ev, err) {
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
// Accumulate response content.
|
||||
// Always track usage (cheap); accumulate text/thinking only for bodies.
|
||||
// Anthropic (and the other providers) split token usage across events:
|
||||
// message_start carries ONLY the input side (input + cache), message_delta
|
||||
// ONLY the output. They must be FOLDED with Add — overwriting on delta
|
||||
// would zero out the input already counted at start (mirrors the SDK's own
|
||||
// llm.Accumulator; see norma/llm/accumulate.go).
|
||||
switch ev.Type {
|
||||
case llm.SETextDelta:
|
||||
textBuf.WriteString(ev.Text)
|
||||
if recordBodies {
|
||||
textBuf.WriteString(ev.Text)
|
||||
}
|
||||
case llm.SEThinkingDelta:
|
||||
thinkingBuf.WriteString(ev.Text)
|
||||
if recordBodies {
|
||||
thinkingBuf.WriteString(ev.Text)
|
||||
}
|
||||
case llm.SEMessageStart:
|
||||
usage.Add(ev.Usage)
|
||||
case llm.SEMessageDelta:
|
||||
if ev.StopReason != "" {
|
||||
stopReason = ev.StopReason
|
||||
}
|
||||
usage = ev.Usage
|
||||
case llm.SEMessageStart:
|
||||
if ev.Usage.InputTokens > 0 {
|
||||
usage = ev.Usage
|
||||
}
|
||||
usage.Add(ev.Usage)
|
||||
}
|
||||
if !yield(ev, err) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Stream completed normally — record it.
|
||||
r.record(req, session, taskID, worker, reqBody, start, textBuf.String(), thinkingBuf.String(), usage, stopReason, streamErr)
|
||||
// Stream completed normally.
|
||||
finish(streamErr)
|
||||
}
|
||||
}
|
||||
|
||||
// recordUsage appends one lightweight metering row to llm_usage (no bodies). Skips
|
||||
// zero-token calls with no model, which carry nothing worth metering.
|
||||
func (r *Recorder) recordUsage(taskID string, expID int64, worker string, usage llm.Usage, latencyMs int, status string) {
|
||||
if r.pg == nil {
|
||||
return
|
||||
}
|
||||
if r.model == "" && usage.InputTokens == 0 && usage.OutputTokens == 0 &&
|
||||
usage.CacheReadTokens == 0 && usage.CacheWriteTokens == 0 {
|
||||
return
|
||||
}
|
||||
err := r.pg.InsertLLMUsage(&db.LLMUsage{
|
||||
TaskID: taskID,
|
||||
ExplorationID: expID,
|
||||
Worker: worker,
|
||||
Model: r.model,
|
||||
ProfileName: r.prof,
|
||||
LatencyMs: latencyMs,
|
||||
InputTokens: usage.InputTokens,
|
||||
OutputTokens: usage.OutputTokens,
|
||||
CacheRead: usage.CacheReadTokens,
|
||||
CacheWrite: usage.CacheWriteTokens,
|
||||
Status: status,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[llmusage] insert: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,12 +264,22 @@ func (r *Recorder) record(req llm.CompletionRequest, session, taskID, worker, re
|
||||
}
|
||||
|
||||
// serializeRequest builds a JSON representation of the completion request.
|
||||
func serializeRequest(req llm.CompletionRequest) string {
|
||||
func (r *Recorder) serializeRequest(req llm.CompletionRequest) string {
|
||||
m := map[string]any{
|
||||
"system": req.System,
|
||||
"messages": req.Messages,
|
||||
"max_tokens": req.MaxTokens,
|
||||
}
|
||||
// 记录本次调用实际发出的思考参数。type 采用「有效值」:每请求覆盖 req.Thinking
|
||||
// 优先于配置级 thinkingType(与 norma buildBody 的判定一致,如 compaction 摘要会
|
||||
// 强制 disabled);effort 无每请求覆盖,直接取配置值。两者皆空则不写 thinking 字段。
|
||||
effType := r.thinkingType
|
||||
if req.Thinking != "" {
|
||||
effType = req.Thinking
|
||||
}
|
||||
if effType != "" || r.reasoningEffort != "" {
|
||||
m["thinking"] = map[string]string{"type": effType, "effort": r.reasoningEffort}
|
||||
}
|
||||
if len(req.Tools) > 0 {
|
||||
// Store tool names only (full schemas are huge).
|
||||
names := make([]string, len(req.Tools))
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
package report
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"path"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
)
|
||||
|
||||
// 发现页「导出」用的渲染:把一批 findings 表行渲染成汇总 Markdown、单条 Markdown、
|
||||
// 或 CSV。JSON 由 server 层直接用 DTO 序列化,不在此处。
|
||||
|
||||
// sortFindingsForExport 按严重等级降序、再按时间倒序排,与汇总报告的分组一致。
|
||||
func sortFindingsForExport(fs []*db.DBFinding) {
|
||||
sort.SliceStable(fs, func(i, j int) bool {
|
||||
ri, rj := sevRank[fs[i].Severity], sevRank[fs[j].Severity]
|
||||
if ri != rj {
|
||||
return ri < rj // sevRank 越小越严重
|
||||
}
|
||||
return fs[i].CreatedAt.After(fs[j].CreatedAt)
|
||||
})
|
||||
}
|
||||
|
||||
// findingTitle 取漏洞可读标题:名称 → 类别 → 「未分类」。
|
||||
func findingTitle(f *db.DBFinding) string {
|
||||
return nz(f.Name, nz(f.VulnClass, "未分类"))
|
||||
}
|
||||
|
||||
// FindingsMarkdown 把一批 findings 整合成一份汇总报告(摘要 + 按严重等级分组,
|
||||
// 每条含类别/状态/所属任务/证据/详细报告)。
|
||||
func FindingsMarkdown(fs []*db.DBFinding, generatedAt time.Time) string {
|
||||
items := append([]*db.DBFinding(nil), fs...)
|
||||
sortFindingsForExport(items)
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("# 漏洞发现汇总报告\n\n")
|
||||
fmt.Fprintf(&b, "- **生成时间**:%s\n", generatedAt.Format("2006-01-02 15:04:05"))
|
||||
fmt.Fprintf(&b, "- **发现总数**:%d 个\n\n", len(items))
|
||||
|
||||
// 摘要:各严重等级计数。
|
||||
counts := map[string]int{}
|
||||
for _, f := range items {
|
||||
counts[f.Severity]++
|
||||
}
|
||||
b.WriteString("## 摘要\n\n")
|
||||
b.WriteString("| 严重等级 | 数量 |\n| --- | --- |\n")
|
||||
for _, s := range []struct{ key, label string }{
|
||||
{"critical", "严重"}, {"high", "高危"}, {"medium", "中危"}, {"low", "低危"},
|
||||
} {
|
||||
fmt.Fprintf(&b, "| %s | %d |\n", s.label, counts[s.key])
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
if len(items) == 0 {
|
||||
b.WriteString("_无匹配的漏洞。_\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
b.WriteString("## 漏洞明细\n\n")
|
||||
for i, f := range items {
|
||||
fmt.Fprintf(&b, "### %d. [%s] %s\n\n", i+1, strings.ToUpper(nz(f.Severity, "info")), findingTitle(f))
|
||||
if f.VulnClass != "" {
|
||||
fmt.Fprintf(&b, "- **类别**:%s\n", f.VulnClass)
|
||||
}
|
||||
fmt.Fprintf(&b, "- **状态**:%s\n", nz(f.Status, "pending"))
|
||||
if desc := strings.TrimSpace(f.TaskDescription); desc != "" {
|
||||
fmt.Fprintf(&b, "- **所属任务**:%s\n", desc)
|
||||
}
|
||||
fmt.Fprintf(&b, "- **发现时间**:%s\n\n", f.CreatedAt.Format("2006-01-02 15:04:05"))
|
||||
if s := strings.TrimSpace(f.Summary); s != "" {
|
||||
fmt.Fprintf(&b, "%s\n\n", s)
|
||||
}
|
||||
if e := strings.TrimSpace(f.Evidence); e != "" {
|
||||
fmt.Fprintf(&b, "**证据:**\n\n```\n%s\n```\n\n", e)
|
||||
}
|
||||
if rep := strings.TrimSpace(f.Report); rep != "" {
|
||||
b.WriteString("**详细报告:**\n\n")
|
||||
b.WriteString(rep)
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
b.WriteString("---\n\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// SingleFindingMarkdown 渲染单条漏洞为一份独立 Markdown(用于「一漏洞一文件」打包)。
|
||||
func SingleFindingMarkdown(f *db.DBFinding, generatedAt time.Time) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "# [%s] %s\n\n", strings.ToUpper(nz(f.Severity, "info")), findingTitle(f))
|
||||
if f.VulnClass != "" {
|
||||
fmt.Fprintf(&b, "- **类别**:%s\n", f.VulnClass)
|
||||
}
|
||||
fmt.Fprintf(&b, "- **严重等级**:%s\n", nz(f.Severity, "info"))
|
||||
fmt.Fprintf(&b, "- **状态**:%s\n", nz(f.Status, "pending"))
|
||||
if desc := strings.TrimSpace(f.TaskDescription); desc != "" {
|
||||
fmt.Fprintf(&b, "- **所属任务**:%s\n", desc)
|
||||
}
|
||||
fmt.Fprintf(&b, "- **发现时间**:%s\n", f.CreatedAt.Format("2006-01-02 15:04:05"))
|
||||
fmt.Fprintf(&b, "- **生成时间**:%s\n\n", generatedAt.Format("2006-01-02 15:04:05"))
|
||||
if s := strings.TrimSpace(f.Summary); s != "" {
|
||||
fmt.Fprintf(&b, "## 概述\n\n%s\n\n", s)
|
||||
}
|
||||
if e := strings.TrimSpace(f.Evidence); e != "" {
|
||||
fmt.Fprintf(&b, "## 证据\n\n```\n%s\n```\n\n", e)
|
||||
}
|
||||
if rep := strings.TrimSpace(f.Report); rep != "" {
|
||||
b.WriteString("## 详细报告\n\n")
|
||||
b.WriteString(rep)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
var unsafeFilenameChars = regexp.MustCompile(`[^\p{Han}\p{L}\p{N}._-]+`)
|
||||
|
||||
// FindingFilename 为「一漏洞一文件」生成安全的 .md 文件名,形如
|
||||
// `critical_SQL注入_#123.md`。去掉路径分隔符与控制字符,避免 zip 内非法路径。
|
||||
func FindingFilename(f *db.DBFinding) string {
|
||||
sev := nz(f.Severity, "info")
|
||||
title := findingTitle(f)
|
||||
name := fmt.Sprintf("%s_%s_#%d", sev, title, f.ID)
|
||||
name = unsafeFilenameChars.ReplaceAllString(name, "_")
|
||||
name = strings.Trim(name, "._")
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("finding_%d", f.ID)
|
||||
}
|
||||
// 防御性:再剥一层路径,杜绝 zip slip。
|
||||
name = path.Base(name)
|
||||
if len(name) > 120 {
|
||||
name = name[:120]
|
||||
}
|
||||
return name + ".md"
|
||||
}
|
||||
|
||||
// FindingsCSV 把一批 findings 渲染成 CSV(带 UTF-8 BOM,便于 Excel 正确识别中文)。
|
||||
// 不含大段 report/evidence 全文,只放摘要类字段;需要全文用 Markdown/JSON 导出。
|
||||
func FindingsCSV(fs []*db.DBFinding) []byte {
|
||||
items := append([]*db.DBFinding(nil), fs...)
|
||||
sortFindingsForExport(items)
|
||||
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString("\xEF\xBB\xBF") // UTF-8 BOM
|
||||
w := csv.NewWriter(&buf)
|
||||
_ = w.Write([]string{"ID", "名称", "类别", "严重等级", "状态", "所属任务", "发现时间", "概述"})
|
||||
for _, f := range items {
|
||||
_ = w.Write([]string{
|
||||
fmt.Sprintf("%d", f.ID),
|
||||
findingTitle(f),
|
||||
f.VulnClass,
|
||||
nz(f.Severity, "info"),
|
||||
nz(f.Status, "pending"),
|
||||
f.TaskDescription,
|
||||
f.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
strings.TrimSpace(f.Summary),
|
||||
})
|
||||
}
|
||||
w.Flush()
|
||||
return buf.Bytes()
|
||||
}
|
||||
+5
-1
@@ -137,7 +137,11 @@ func wireAgentAugment(pg *db.DB, skillDir string, hostTools func() ([]actool.Cor
|
||||
}
|
||||
return actool.RenderDeferredToolsBlock(reveal)
|
||||
}
|
||||
extra = append(extra, reg.Tool())
|
||||
// Attribution for the usage ledger: ToolAugment only gets (ctx, agentKey),
|
||||
// so the run's task/session ids ride in on the ctx (agent.RunInfo). Read it
|
||||
// once here — this closure is rebuilt per run, so the captured value always
|
||||
// belongs to this run.
|
||||
extra = append(extra, meterSkillTool(reg.Tool(), pg, reg, a.Key, agent.RunInfoFrom(ctx)))
|
||||
}
|
||||
|
||||
// host tools (traffic / orchestration / custom) — added to every agent's base;
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
)
|
||||
|
||||
// pgListCommands returns tool executions (any tool) from the activity table.
|
||||
@@ -45,6 +47,53 @@ func (s *Server) pgListLLMRecords(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 200, map[string]any{"records": records, "total": total})
|
||||
}
|
||||
|
||||
// pgTokenByModel returns a task's LLM token usage grouped by model, from the
|
||||
// always-on llm_usage metering ledger. Accurate even with per-agent model bindings,
|
||||
// pool rotation/failover, and interrupted runs (every call is metered on success or
|
||||
// error). No recording toggle required.
|
||||
func (s *Server) pgTokenByModel(w http.ResponseWriter, r *http.Request) {
|
||||
id := strings.TrimSpace(r.URL.Query().Get("task"))
|
||||
if id == "" {
|
||||
writeErr(w, 400, "missing task")
|
||||
return
|
||||
}
|
||||
pg := s.m.PG()
|
||||
if pg == nil {
|
||||
writeJSON(w, 200, map[string]any{"models": []db.ModelTokenStat{}})
|
||||
return
|
||||
}
|
||||
models, err := pg.TokenByModel(id)
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"models": models})
|
||||
}
|
||||
|
||||
// pgUsageStats returns global llm_usage aggregates for the dashboard's "new" token
|
||||
// view: per-profile totals (all-time) + per-(profile, day) buckets for the daily
|
||||
// chart. Sourced from the always-on metering ledger, so it is accurate across
|
||||
// per-agent bindings, pool rotation, and interrupted runs.
|
||||
func (s *Server) pgUsageStats(w http.ResponseWriter, r *http.Request) {
|
||||
pg := s.m.PG()
|
||||
if pg == nil {
|
||||
writeJSON(w, 200, map[string]any{"by_profile": []db.ProfileUsage{}, "daily": []db.ProfileDayUsage{}})
|
||||
return
|
||||
}
|
||||
days := atoiDefault(r.URL.Query().Get("days"), 365)
|
||||
byProfile, err := pg.UsageByProfile()
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
daily, err := pg.UsageDaily(days)
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"by_profile": byProfile, "daily": daily})
|
||||
}
|
||||
|
||||
// pgLLMTasks returns distinct recorded tasks with counts, for the page's task
|
||||
// picker (pick a task → filter the list, then delete its conversations).
|
||||
func (s *Server) pgLLMTasks(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -500,6 +500,7 @@ type LLMProfileDTO struct {
|
||||
RatePerSecond float64 `json:"rate_per_second"`
|
||||
RatePerMinute float64 `json:"rate_per_minute"`
|
||||
ContextWindowK int `json:"context_window_k"`
|
||||
ThinkingType string `json:"thinking_type"`
|
||||
ReasoningEffort string `json:"reasoning_effort"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
// 轮询(故障转移)参数:priority 越大越先被选中(激活配置恒为链首);
|
||||
@@ -520,6 +521,7 @@ func llmProfileDTO(p *db.LLMProfile) LLMProfileDTO {
|
||||
RatePerSecond: p.RatePerSecond,
|
||||
RatePerMinute: p.RatePerMinute,
|
||||
ContextWindowK: p.ContextWindowK,
|
||||
ThinkingType: p.ThinkingType,
|
||||
ReasoningEffort: p.ReasoningEffort,
|
||||
IsDefault: p.IsDefault,
|
||||
Priority: p.Priority,
|
||||
|
||||
+2
-1
@@ -926,7 +926,8 @@ func (e *Engine) runWorkerStep(ctx context.Context, t *Task, name string, worker
|
||||
return true
|
||||
}
|
||||
// if a pause cancelled this run mid-flight, return the intent to the frontier
|
||||
// so it is re-claimed (and re-run from scratch) on resume — not marked done.
|
||||
// so it is re-claimed on resume — the worker will resume the prior LLM
|
||||
// conversation from its transcript instead of restarting from scratch.
|
||||
if ectx.Err() != nil && e.IsPaused(t.ID) {
|
||||
_ = t.Store.SetIntentState(intent.ID, "open")
|
||||
return true
|
||||
|
||||
@@ -250,6 +250,9 @@ func NewManager(dir, proxyAddr string) (*Manager, error) {
|
||||
if err := pg.EnsureLLMRecordsTable(); err != nil {
|
||||
log.Printf("[llmrec] create table: %v", err)
|
||||
}
|
||||
if err := pg.EnsureLLMUsageTable(); err != nil {
|
||||
log.Printf("[llmusage] create table: %v", err)
|
||||
}
|
||||
m := &Manager{dir: dir, pg: pg, assets: pg.Assets(), tasks: map[string]*Task{}, interceptor: intercept.New(pg)}
|
||||
if proxyAddr != "" {
|
||||
tr, err := traffic.Open(filepath.Join(dir, "traffic"), proxyAddr)
|
||||
|
||||
+190
-13
@@ -1,6 +1,7 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
@@ -274,14 +275,14 @@ func (s *Server) agentMaxTurns(key string) int {
|
||||
}
|
||||
|
||||
// agentRunSeconds returns the configured wall-clock run budget (seconds) for an
|
||||
// agent key (0 = unlimited; 600 fallback when no DB or no row, matching schema).
|
||||
// agent key (0 = unlimited; 1200 fallback when no DB or no row, matching schema).
|
||||
func (s *Server) agentRunSeconds(key string) int {
|
||||
if s.m.pg == nil {
|
||||
return 600
|
||||
return 1200
|
||||
}
|
||||
a, err := s.m.pg.GetAgentByKey(key)
|
||||
if err != nil || a == nil {
|
||||
return 600
|
||||
return 1200
|
||||
}
|
||||
return a.RunSecs
|
||||
}
|
||||
@@ -295,6 +296,7 @@ func (s *Server) loadLLMConfig() (agent.Config, bool) {
|
||||
cfg := agent.ConfigFrom(p.Format, p.Model, p.BaseURL, p.APIKey, p.Proxy)
|
||||
cfg.RatePerSecond, cfg.RatePerMinute = p.RatePerSecond, p.RatePerMinute
|
||||
cfg.ContextWindowK = p.ContextWindowK
|
||||
cfg.ThinkingType = p.ThinkingType
|
||||
cfg.ReasoningEffort = p.ReasoningEffort
|
||||
if cfg.APIKey == "" {
|
||||
return cfg, false
|
||||
@@ -328,7 +330,7 @@ func (s *Server) saveLLMConfig(cfg agent.Config) error {
|
||||
newID, err := s.m.pg.SaveProfile(&db.LLMProfile{
|
||||
ID: id, Name: "default", Format: format, Model: cfg.Model, BaseURL: cfg.BaseURL, Proxy: cfg.Proxy,
|
||||
APIKey: cfg.APIKey, RatePerSecond: cfg.RatePerSecond, RatePerMinute: cfg.RatePerMinute,
|
||||
ContextWindowK: cfg.ContextWindowK, ReasoningEffort: cfg.ReasoningEffort, IsDefault: true,
|
||||
ContextWindowK: cfg.ContextWindowK, ThinkingType: cfg.ThinkingType, ReasoningEffort: cfg.ReasoningEffort, IsDefault: true,
|
||||
Priority: priority, PoolExclude: poolExclude,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -408,7 +410,7 @@ func (s *Server) applyLLM(cfg agent.Config) error {
|
||||
s.cfgMu.Lock()
|
||||
profName := s.llmProf
|
||||
s.cfgMu.Unlock()
|
||||
prov = llmrec.Wrap(prov, s.m.PG(), cfg.Model, profName, s.m.LLMRecordEnabled)
|
||||
prov = llmrec.Wrap(prov, s.m.PG(), cfg.Model, profName, cfg.ThinkingType, cfg.ReasoningEffort, s.m.LLMRecordEnabled)
|
||||
}
|
||||
// LLM 轮询(默认关):把激活配置包进故障转移链,当前配置不可用时自动切下一个。
|
||||
// 只影响「走全局激活配置」的这条路径——agent 绑定 / 任务 pin 的走 providerForProfile,
|
||||
@@ -458,6 +460,7 @@ func (s *Server) loadProfileConfig(id int64) (agent.Config, bool) {
|
||||
cfg := agent.ConfigFrom(p.Format, p.Model, p.BaseURL, p.APIKey, p.Proxy)
|
||||
cfg.RatePerSecond, cfg.RatePerMinute = p.RatePerSecond, p.RatePerMinute
|
||||
cfg.ContextWindowK = p.ContextWindowK
|
||||
cfg.ThinkingType = p.ThinkingType
|
||||
cfg.ReasoningEffort = p.ReasoningEffort
|
||||
if cfg.APIKey == "" {
|
||||
return cfg, false
|
||||
@@ -501,7 +504,7 @@ func (s *Server) providerForProfile(id int64) (llm.Provider, agent.Config, bool)
|
||||
}
|
||||
// Wrap with recorder, tagged with this profile's name.
|
||||
if p, _ := s.m.pg.ProfileByID(id); p != nil {
|
||||
prov = llmrec.Wrap(prov, s.m.PG(), cfg.Model, p.Name, s.m.LLMRecordEnabled)
|
||||
prov = llmrec.Wrap(prov, s.m.PG(), cfg.Model, p.Name, cfg.ThinkingType, cfg.ReasoningEffort, s.m.LLMRecordEnabled)
|
||||
}
|
||||
s.provCacheMu.Lock()
|
||||
if generation != s.provCacheGen {
|
||||
@@ -647,6 +650,8 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("GET /api/workspace/download", s.wsDownload)
|
||||
mux.HandleFunc("POST /api/workspace/upload", s.wsUpload)
|
||||
mux.HandleFunc("GET /api/tasks/{id}/scope", s.taskScopeList)
|
||||
mux.HandleFunc("POST /api/tasks/{id}/scope", s.taskScopeAdd)
|
||||
mux.HandleFunc("DELETE /api/tasks/{id}/scope/{sid}", s.taskScopeDelete)
|
||||
mux.HandleFunc("POST /api/tasks/{id}/control", s.control)
|
||||
mux.HandleFunc("PUT /api/tasks/{id}/llm", s.updateTaskLLMProfiles)
|
||||
mux.HandleFunc("POST /api/tasks/{id}/intents/{iid}/control", s.controlIntent)
|
||||
@@ -675,6 +680,7 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("GET /api/exploration/frontier", s.frontier)
|
||||
mux.HandleFunc("GET /api/exploration/findings", s.findings)
|
||||
mux.HandleFunc("GET /api/exploration/findings/stats", s.findingStats)
|
||||
mux.HandleFunc("GET /api/exploration/findings/export", s.findingsExport)
|
||||
mux.HandleFunc("GET /api/exploration/findings/{id}", s.getFinding)
|
||||
mux.HandleFunc("GET /api/exploration/findings/{id}/lineage", s.findingLineage)
|
||||
mux.HandleFunc("PATCH /api/exploration/findings/{id}", s.patchFinding)
|
||||
@@ -688,6 +694,7 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("GET /api/exploration/tokens", s.tokenStats)
|
||||
mux.HandleFunc("GET /api/tokens/daily", s.tokenDailyStats)
|
||||
mux.HandleFunc("GET /api/tokens/conversations", s.conversationTokens)
|
||||
mux.HandleFunc("GET /api/tokens/usage", s.pgUsageStats) // 全局 llm_usage 聚合(仪表盘新版视图)
|
||||
|
||||
mux.HandleFunc("GET /api/audit", s.getAudit)
|
||||
mux.HandleFunc("POST /api/gc", s.gc)
|
||||
@@ -700,6 +707,7 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("GET /api/llm/records", s.pgListLLMRecords)
|
||||
mux.HandleFunc("DELETE /api/llm/records", s.pgDeleteLLMRecords)
|
||||
mux.HandleFunc("GET /api/llm/records/tasks", s.pgLLMTasks)
|
||||
mux.HandleFunc("GET /api/llm/records/by-model", s.pgTokenByModel) // 按模型聚合本任务 token 用量
|
||||
mux.HandleFunc("GET /api/llm/records/{id}", s.pgGetLLMRecord)
|
||||
mux.HandleFunc("GET /api/settings", s.getSettings)
|
||||
mux.HandleFunc("PUT /api/settings", s.putSettings)
|
||||
@@ -770,6 +778,8 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("POST /api/skills", s.fsCreateSkill)
|
||||
mux.HandleFunc("POST /api/skills/upload", s.fsUploadSkill)
|
||||
mux.HandleFunc("DELETE /api/skills/{name}", s.fsDeleteSkill)
|
||||
mux.HandleFunc("GET /api/skills/missing", s.fsMissingSkills) // 未命中(想调但不存在)的 skill 名
|
||||
mux.HandleFunc("GET /api/skills/{name}/usage", s.fsSkillUsage) // 单个 skill 的最近调用
|
||||
mux.HandleFunc("PUT /api/skills/{name}/meta", s.fsUpdateSkillMeta)
|
||||
mux.HandleFunc("POST /api/skills/{name}/dirs", s.fsCreateDir)
|
||||
mux.HandleFunc("GET /api/skills/{name}/files", s.fsListFiles)
|
||||
@@ -994,7 +1004,9 @@ func (s *Server) control(w http.ResponseWriter, r *http.Request) {
|
||||
case "pause":
|
||||
s.engine.Pause(t.ID, agent.AbortPausedByUser)
|
||||
// Main Agent turns run outside Engine's planner/worker context so they need
|
||||
// their own cancellation. New turns are rejected while the task is paused.
|
||||
// their own cancellation: pausing terminates the CURRENTLY running turn. New
|
||||
// turns are still allowed while paused — the main-agent console is independent
|
||||
// of task pause (the chat handler no longer rejects on IsPaused).
|
||||
s.cancelTaskChat(t.ID, agent.AbortChatPausedWithTask)
|
||||
case "resume":
|
||||
s.engine.Run(s.ctx, t) // ensure loops are alive, then un-pause + nudge
|
||||
@@ -1167,6 +1179,7 @@ func (s *Server) getLLM(w http.ResponseWriter, r *http.Request) {
|
||||
"rate_per_second": s.llmCfg.RatePerSecond,
|
||||
"rate_per_minute": s.llmCfg.RatePerMinute,
|
||||
"context_window_k": s.llmCfg.ContextWindowK,
|
||||
"thinking_type": s.llmCfg.ThinkingType,
|
||||
"reasoning_effort": s.llmCfg.ReasoningEffort,
|
||||
})
|
||||
}
|
||||
@@ -1182,6 +1195,7 @@ func (s *Server) setLLM(w http.ResponseWriter, r *http.Request) {
|
||||
RatePerSecond float64 `json:"rate_per_second"`
|
||||
RatePerMinute float64 `json:"rate_per_minute"`
|
||||
ContextWindowK int `json:"context_window_k"`
|
||||
ThinkingType string `json:"thinking_type"`
|
||||
ReasoningEffort string `json:"reasoning_effort"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
@@ -1190,6 +1204,7 @@ func (s *Server) setLLM(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
cfg := agent.ConfigFrom(req.Provider, req.Model, req.BaseURL, req.APIKey, req.Proxy)
|
||||
cfg.RatePerSecond, cfg.RatePerMinute = req.RatePerSecond, req.RatePerMinute
|
||||
cfg.ThinkingType = req.ThinkingType
|
||||
cfg.ReasoningEffort = req.ReasoningEffort
|
||||
if k := req.ContextWindowK; k > 0 { // 0 = keep default (200K); cap at 1M
|
||||
if k > 1000 {
|
||||
@@ -1236,6 +1251,7 @@ func (s *Server) testLLM(w http.ResponseWriter, r *http.Request) {
|
||||
BaseURL string `json:"base_url"`
|
||||
Proxy string `json:"proxy"`
|
||||
APIKey string `json:"api_key"`
|
||||
ThinkingType string `json:"thinking_type"`
|
||||
ReasoningEffort string `json:"reasoning_effort"`
|
||||
ProfileID *int64 `json:"profile_id"` // 测已存 profile 时传入:api_key 为空则用它存的 key
|
||||
}
|
||||
@@ -1246,6 +1262,7 @@ func (s *Server) testLLM(w http.ResponseWriter, r *http.Request) {
|
||||
cfg := agent.ConfigFrom(req.Provider, req.Model, req.BaseURL, req.APIKey, req.Proxy)
|
||||
// mirror production: send the SAME thinking params so a provider that rejects the
|
||||
// reasoning_effort/thinking field fails the test too (no false "test ok, run 400").
|
||||
cfg.ThinkingType = req.ThinkingType
|
||||
cfg.ReasoningEffort = req.ReasoningEffort
|
||||
// API Key 解析优先级:表单输入 > 指定 profile 存的 key > 全局配置的 key。
|
||||
// 已存 profile 的 key 不回传浏览器,所以测试已存配置时表单为空,需从 DB 取。
|
||||
@@ -1634,6 +1651,64 @@ func (s *Server) taskScopeList(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 200, map[string]any{"scope": rows})
|
||||
}
|
||||
|
||||
func (s *Server) taskScopeAdd(w http.ResponseWriter, r *http.Request) {
|
||||
t, ok := s.m.Task(r.PathValue("id"))
|
||||
if !ok {
|
||||
writeErr(w, 404, "task not found")
|
||||
return
|
||||
}
|
||||
as := s.m.Assets()
|
||||
if as == nil {
|
||||
writeErr(w, 503, "asset store 未启用")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Kind string `json:"kind"`
|
||||
Value string `json:"value"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeErr(w, 400, "invalid JSON")
|
||||
return
|
||||
}
|
||||
taskID, _ := strconv.ParseInt(t.ID, 10, 64)
|
||||
ts, err := as.AddAgentScope(taskID, body.Kind, body.Value, body.Reason, "manual")
|
||||
if err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, ts)
|
||||
}
|
||||
|
||||
func (s *Server) taskScopeDelete(w http.ResponseWriter, r *http.Request) {
|
||||
t, ok := s.m.Task(r.PathValue("id"))
|
||||
if !ok {
|
||||
writeErr(w, 404, "task not found")
|
||||
return
|
||||
}
|
||||
as := s.m.Assets()
|
||||
if as == nil {
|
||||
writeErr(w, 503, "asset store 未启用")
|
||||
return
|
||||
}
|
||||
taskID, _ := strconv.ParseInt(t.ID, 10, 64)
|
||||
scopeID, err := strconv.ParseInt(r.PathValue("sid"), 10, 64)
|
||||
if err != nil {
|
||||
writeErr(w, 400, "invalid scope id")
|
||||
return
|
||||
}
|
||||
deleted, err := as.DeleteTaskScope(taskID, scopeID)
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
if !deleted {
|
||||
writeErr(w, 404, "scope row not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) frontier(w http.ResponseWriter, r *http.Request) {
|
||||
t := s.m.ResolveTask(r.URL.Query().Get("task"))
|
||||
if t == nil {
|
||||
@@ -1833,6 +1908,110 @@ func (s *Server) getFinding(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 200, dto)
|
||||
}
|
||||
|
||||
// findingsExport 导出发现页的漏洞。
|
||||
//
|
||||
// scope = filtered(沿用页面筛选)| all(全部)| selected(勾选的 ids)
|
||||
// format = md-single(整合一份 .md)| md-zip(一漏洞一 .md,打包 zip)
|
||||
// | csv | json
|
||||
// ids = 逗号分隔的 finding id(scope=selected 时必填)
|
||||
// 筛选参数 severity/status/vulnclass/task_id/sort 与列表接口一致(scope=filtered 用)。
|
||||
func (s *Server) findingsExport(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
scope := q.Get("scope")
|
||||
format := q.Get("format")
|
||||
|
||||
var ids []int64
|
||||
var filter db.FindingFilter
|
||||
switch scope {
|
||||
case "selected":
|
||||
for _, part := range strings.Split(q.Get("ids"), ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
id, err := strconv.ParseInt(part, 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
writeErr(w, 400, "bad finding id: "+part)
|
||||
return
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
writeErr(w, 400, "no findings selected")
|
||||
return
|
||||
}
|
||||
case "all":
|
||||
// 空 filter = 不加任何条件。
|
||||
case "filtered", "":
|
||||
filter = db.FindingFilter{
|
||||
Severity: normFilter(q.Get("severity")),
|
||||
Status: normFilter(q.Get("status")),
|
||||
VulnClass: normFilter(q.Get("vulnclass")),
|
||||
TaskID: normFilter(q.Get("task_id")),
|
||||
Sort: q.Get("sort"),
|
||||
}
|
||||
default:
|
||||
writeErr(w, 400, "bad scope: "+scope)
|
||||
return
|
||||
}
|
||||
|
||||
fs, err := s.m.pg.ListFindingsForExport(filter, ids)
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
stamp := now.Format("20060102-150405")
|
||||
setDownload := func(contentType, filename string) {
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
|
||||
}
|
||||
|
||||
switch format {
|
||||
case "md-single":
|
||||
setDownload("text/markdown; charset=utf-8", "findings-"+stamp+".md")
|
||||
_, _ = w.Write([]byte(report.FindingsMarkdown(fs, now)))
|
||||
case "md-zip":
|
||||
setDownload("application/zip", "findings-"+stamp+".zip")
|
||||
zw := zip.NewWriter(w)
|
||||
used := map[string]int{}
|
||||
for _, f := range fs {
|
||||
base := report.FindingFilename(f)
|
||||
name := base
|
||||
// 去重:同名文件追加 -2、-3……
|
||||
if n := used[base]; n > 0 {
|
||||
name = fmt.Sprintf("%s-%d.md", strings.TrimSuffix(base, ".md"), n+1)
|
||||
}
|
||||
used[base]++
|
||||
fw, werr := zw.Create(name)
|
||||
if werr != nil {
|
||||
log.Printf("[findings-export] zip create %s: %v", name, werr)
|
||||
continue
|
||||
}
|
||||
_, _ = fw.Write([]byte(report.SingleFindingMarkdown(f, now)))
|
||||
}
|
||||
if cerr := zw.Close(); cerr != nil {
|
||||
log.Printf("[findings-export] zip close: %v", cerr)
|
||||
}
|
||||
case "csv":
|
||||
setDownload("text/csv; charset=utf-8", "findings-"+stamp+".csv")
|
||||
_, _ = w.Write(report.FindingsCSV(fs))
|
||||
case "json":
|
||||
assets := s.resolveFindingAssets(fs)
|
||||
out := make([]FindingDTO, 0, len(fs))
|
||||
for _, f := range fs {
|
||||
out = append(out, findingFromDB(f, assets))
|
||||
}
|
||||
setDownload("application/json; charset=utf-8", "findings-"+stamp+".json")
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
_ = enc.Encode(out)
|
||||
default:
|
||||
writeErr(w, 400, "bad format: "+format)
|
||||
}
|
||||
}
|
||||
|
||||
func findingProvenanceInTask(contextTask *Task, findingTaskID *int64) (sourceTaskID string, inherited, allowed bool) {
|
||||
if contextTask == nil || findingTaskID == nil {
|
||||
return "", false, false
|
||||
@@ -2940,10 +3119,8 @@ func (s *Server) chat(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, 409, "任务正在删除,无法发送新消息")
|
||||
return
|
||||
}
|
||||
if s.engine.IsPaused(t.ID) {
|
||||
writeErr(w, 409, "任务已暂停,无法发送新消息")
|
||||
return
|
||||
}
|
||||
// 注意:任务暂停(paused)不拦截主 Agent 对话。主 Agent 编排会话独立于 planner/
|
||||
// worker 的暂停,暂停中仍可继续对话(暂停只终止其正在进行的那一轮,见 control())。
|
||||
var req struct {
|
||||
Message string `json:"message"`
|
||||
Attachments []chatAttachment `json:"attachments,omitempty"` // 方式1 上传的文件(路径相对任务工作目录)
|
||||
@@ -2956,9 +3133,9 @@ func (s *Server) chat(w http.ResponseWriter, r *http.Request) {
|
||||
// turn busy before its first activity/file write, including rule-mode turns.
|
||||
// If deletion wins the race, the second barrier check rejects this request.
|
||||
s.chatMu.Lock()
|
||||
if s.engine.IsDeleting(t.ID) || s.engine.IsPaused(t.ID) {
|
||||
if s.engine.IsDeleting(t.ID) {
|
||||
s.chatMu.Unlock()
|
||||
writeErr(w, 409, "任务正在删除或已暂停,无法发送新消息")
|
||||
writeErr(w, 409, "任务正在删除,无法发送新消息")
|
||||
return
|
||||
}
|
||||
if s.chatBusy[t.ID] {
|
||||
|
||||
+58
-1
@@ -897,6 +897,12 @@ type skillFileNode struct {
|
||||
Compatibility string `json:"compatibility,omitempty"`
|
||||
MCPs []string `json:"mcps,omitempty"`
|
||||
Files []string `json:"files"`
|
||||
// Usage ledger (db/skill_usage.go). Calls is 0 and LastUsed nil for a skill that
|
||||
// was never invoked — the ledger only has rows for skills agents actually loaded.
|
||||
Calls int `json:"calls"`
|
||||
Tasks int `json:"tasks"`
|
||||
Agents []string `json:"usage_agents"`
|
||||
LastUsed *time.Time `json:"last_used,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) fsListSkills(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -915,13 +921,28 @@ func (s *Server) fsListSkills(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
// Usage is best-effort decoration: a ledger read failure leaves the counts at
|
||||
// zero rather than failing the skill list itself.
|
||||
statBySkill := map[string]db.SkillStat{}
|
||||
if s.m.pg != nil {
|
||||
stats, err := s.m.pg.SkillStats()
|
||||
if err != nil {
|
||||
log.Printf("[skills] 读取调用统计失败: %v", err)
|
||||
}
|
||||
for _, st := range stats {
|
||||
statBySkill[st.Skill] = st
|
||||
}
|
||||
}
|
||||
nodes := []skillFileNode{}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
dirName := e.Name()
|
||||
node := skillFileNode{Name: dirName}
|
||||
node := skillFileNode{Name: dirName, Agents: []string{}}
|
||||
if st, ok := statBySkill[dirName]; ok {
|
||||
node.Calls, node.Tasks, node.Agents, node.LastUsed = st.Calls, st.Tasks, st.Agents, st.LastUsed
|
||||
}
|
||||
if meta, ok := metaByDir[dirName]; ok {
|
||||
node.Description = meta.Description
|
||||
node.License = meta.License
|
||||
@@ -937,6 +958,42 @@ func (s *Server) fsListSkills(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 200, map[string]any{"skills": nodes})
|
||||
}
|
||||
|
||||
// fsSkillUsage returns one skill's recent invocations, newest first (detail panel).
|
||||
func (s *Server) fsSkillUsage(w http.ResponseWriter, r *http.Request) {
|
||||
pg := s.pg(w)
|
||||
if pg == nil {
|
||||
return
|
||||
}
|
||||
name := r.PathValue("name")
|
||||
if !validSkillName(name) {
|
||||
writeErr(w, 400, "非法 skill 名")
|
||||
return
|
||||
}
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
calls, err := pg.RecentSkillCalls(name, limit)
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"calls": calls})
|
||||
}
|
||||
|
||||
// fsMissingSkills lists skill names agents asked for that do not exist — the gap
|
||||
// list, i.e. which procedures are worth writing next.
|
||||
func (s *Server) fsMissingSkills(w http.ResponseWriter, r *http.Request) {
|
||||
pg := s.pg(w)
|
||||
if pg == nil {
|
||||
return
|
||||
}
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
missing, err := pg.MissingSkillStats(limit)
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"missing": missing})
|
||||
}
|
||||
|
||||
// cleanStrs trims each string and drops empties.
|
||||
func cleanStrs(in []string) []string {
|
||||
out := make([]string, 0, len(in))
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/Autumn-27/norma/skill"
|
||||
actool "github.com/Autumn-27/norma/tool"
|
||||
|
||||
"github.com/Autumn-27/artex/agent"
|
||||
"github.com/Autumn-27/artex/db"
|
||||
)
|
||||
|
||||
// maxLedgerSkillName caps the skill name stored for a MISS — that string comes
|
||||
// straight from the model and is otherwise unbounded.
|
||||
const maxLedgerSkillName = 128
|
||||
|
||||
// meteredSkill wraps the Skill meta-tool to append one skill_usage row per call.
|
||||
// The metering lives here rather than in Registry.OnInvoke because OnInvoke only
|
||||
// receives the resolved Skill: it never fires for an unknown skill name (norma
|
||||
// returns a tool error before the hook) and it cannot see the caller's args. Both
|
||||
// matter — a miss tells you which procedure the agents wished existed.
|
||||
type meteredSkill struct {
|
||||
actool.CoreTool
|
||||
pg *db.DB
|
||||
reg *skill.Registry
|
||||
agentKey string
|
||||
ri agent.RunInfo
|
||||
}
|
||||
|
||||
// meterSkillTool returns t unchanged when there is no DB to meter into.
|
||||
func meterSkillTool(t actool.CoreTool, pg *db.DB, reg *skill.Registry, agentKey string, ri agent.RunInfo) actool.CoreTool {
|
||||
if pg == nil {
|
||||
return t
|
||||
}
|
||||
return &meteredSkill{CoreTool: t, pg: pg, reg: reg, agentKey: agentKey, ri: ri}
|
||||
}
|
||||
|
||||
func (m *meteredSkill) Call(ctx context.Context, input json.RawMessage, tc *actool.ToolContext) (actool.Result, error) {
|
||||
m.record(input)
|
||||
return m.CoreTool.Call(ctx, input, tc)
|
||||
}
|
||||
|
||||
// record appends the ledger row. Best-effort by design: a metering failure must
|
||||
// never turn into a failed skill invocation, so every error path just logs.
|
||||
func (m *meteredSkill) record(input json.RawMessage) {
|
||||
var in struct {
|
||||
Name string `json:"name"`
|
||||
Args string `json:"args"`
|
||||
}
|
||||
if err := json.Unmarshal(input, &in); err != nil {
|
||||
return // malformed call; norma will reject it too
|
||||
}
|
||||
name := strings.TrimSpace(in.Name)
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
// Resolve to the directory name so the ledger keys match agent_skill_visibility
|
||||
// and the skills page (a skill's display Name can differ from its directory).
|
||||
found := false
|
||||
if m.reg != nil {
|
||||
if s, ok := m.reg.Get(name); ok {
|
||||
found = true
|
||||
if s.Dir != "" {
|
||||
name = filepath.Base(s.Dir)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(name) > maxLedgerSkillName {
|
||||
name = name[:maxLedgerSkillName]
|
||||
}
|
||||
err := m.pg.InsertSkillUsage(&db.SkillUsage{
|
||||
Skill: name,
|
||||
AgentKey: m.agentKey,
|
||||
TaskID: m.ri.TaskID,
|
||||
ExplorationID: m.ri.ExplorationID,
|
||||
IntentID: m.ri.IntentID,
|
||||
SessionID: m.ri.SessionID,
|
||||
ArgsLen: len(in.Args),
|
||||
Found: found,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[skillusage] insert: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -77,8 +77,8 @@ function LiveBadge() {
|
||||
);
|
||||
}
|
||||
|
||||
// Composer is the shared bottom input (textarea grows to a cap, Enter newlines,
|
||||
// Ctrl/Cmd+Enter sends) — the same affordance across DraftChat and ChatView.
|
||||
// Composer is the shared bottom input (textarea grows to a cap, Enter sends,
|
||||
// Shift+Enter newlines) — the same affordance across DraftChat and ChatView.
|
||||
function Composer({
|
||||
value,
|
||||
onChange,
|
||||
@@ -112,11 +112,10 @@ function Composer({
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const atts = attachments ?? [];
|
||||
function onKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
|
||||
// Enter 换行;只有 Ctrl/Cmd+Enter 才发送(避免长消息误发)。
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
onSend();
|
||||
}
|
||||
// Enter 发送;Shift+Enter 换行。isComposing 时是中文等输入法在选字,不拦截。
|
||||
if (e.key !== "Enter" || e.shiftKey || e.nativeEvent.isComposing) return;
|
||||
e.preventDefault();
|
||||
onSend();
|
||||
}
|
||||
return (
|
||||
<div className="border-t p-3">
|
||||
@@ -364,7 +363,7 @@ function DraftChat({
|
||||
onChange={setInput}
|
||||
onSend={send}
|
||||
disabled={sending || uploading || !agentKey}
|
||||
placeholder="输入消息,Enter 换行,Ctrl+Enter 发送"
|
||||
placeholder="输入消息,Enter 发送,Shift+Enter 换行"
|
||||
leftSlot={agentPicker}
|
||||
onPickFiles={pickFiles}
|
||||
uploading={uploading}
|
||||
@@ -692,7 +691,7 @@ function ChatView({
|
||||
onChange={setInput}
|
||||
onSend={send}
|
||||
disabled={running}
|
||||
placeholder={running ? "Agent 正在回复…" : "输入消息,Enter 换行,Ctrl+Enter 发送"}
|
||||
placeholder={running ? "Agent 正在回复…" : "输入消息,Enter 发送,Shift+Enter 换行"}
|
||||
running={running}
|
||||
onStop={stop}
|
||||
stopDisabled={stopping}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
import {
|
||||
ActivityIcon,
|
||||
ArrowUpRightIcon,
|
||||
@@ -13,29 +15,18 @@ import {
|
||||
TargetIcon,
|
||||
ZapIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip as RechartsTooltip,
|
||||
} from "recharts";
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from "@/components/ui/chart";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Bar, BarChart, CartesianGrid, Tooltip as RechartsTooltip, XAxis, YAxis } from "recharts";
|
||||
|
||||
import { StatusBadge } from "@/components/status-badge";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { type ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { StatusBadge } from "@/components/status-badge";
|
||||
import { api } from "@/lib/api";
|
||||
import type {
|
||||
Activity,
|
||||
Agent,
|
||||
ConvTokenSummary,
|
||||
Finding,
|
||||
InterceptPending,
|
||||
LLMProfile,
|
||||
@@ -47,14 +38,15 @@ import type {
|
||||
TokenTotal,
|
||||
Tool,
|
||||
TrafficExchange,
|
||||
ConvTokenSummary,
|
||||
UsageStats,
|
||||
} from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ── chart constants ───────────────────────────────────────────────────────────
|
||||
|
||||
const dailyTrendConfig = {
|
||||
input: { label: "输入", color: "hsl(217 91% 60%)" },
|
||||
output: { label: "输出", color: "hsl(263 70% 60%)" },
|
||||
input: { label: "输入", color: "hsl(217 91% 60%)" },
|
||||
output: { label: "输出", color: "hsl(263 70% 60%)" },
|
||||
cacheRead: { label: "缓存读", color: "hsl(160 60% 45%)" },
|
||||
} satisfies ChartConfig;
|
||||
|
||||
@@ -62,9 +54,7 @@ const dailyTrendConfig = {
|
||||
|
||||
function fmtRel(ts?: string | number): string {
|
||||
if (!ts) return "—";
|
||||
const ms =
|
||||
Date.now() -
|
||||
(typeof ts === "number" ? ts * 1000 : Date.parse(ts as string));
|
||||
const ms = Date.now() - (typeof ts === "number" ? ts * 1000 : Date.parse(ts as string));
|
||||
const s = Math.floor(ms / 1000);
|
||||
if (s < 60) return `${s}s 前`;
|
||||
const m = Math.floor(s / 60);
|
||||
@@ -115,14 +105,7 @@ function statusBg(code: number): string {
|
||||
// ── sub-components ────────────────────────────────────────────────────────────
|
||||
|
||||
function LiveDot({ className }: { className?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block size-1.5 shrink-0 animate-pulse rounded-full bg-blue-400",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
return <span className={cn("inline-block size-1.5 shrink-0 animate-pulse rounded-full bg-blue-400", className)} />;
|
||||
}
|
||||
|
||||
function SectionTitle({
|
||||
@@ -157,6 +140,7 @@ export default function DashboardPage() {
|
||||
const [activity, setActivity] = React.useState<Activity[]>([]);
|
||||
const [tokens, setTokens] = React.useState<TokenTotal | null>(null);
|
||||
const [convTokens, setConvTokens] = React.useState<ConvTokenSummary[]>([]);
|
||||
const [usageStats, setUsageStats] = React.useState<UsageStats | null>(null);
|
||||
const [assetCounts, setAssetCounts] = React.useState<Record<string, number>>({});
|
||||
const [traffic, setTraffic] = React.useState<TrafficExchange[]>([]);
|
||||
const [agents, setAgents] = React.useState<Agent[]>([]);
|
||||
@@ -195,7 +179,10 @@ export default function DashboardPage() {
|
||||
};
|
||||
load();
|
||||
const t = setInterval(load, 5000);
|
||||
return () => { alive = false; clearInterval(t); };
|
||||
return () => {
|
||||
alive = false;
|
||||
clearInterval(t);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// slow poll: traffic, assets, system-static (every 15s)
|
||||
@@ -203,16 +190,16 @@ export default function DashboardPage() {
|
||||
let alive = true;
|
||||
const load = async () => {
|
||||
try {
|
||||
const [traf, counts, agentList, mcpList, skillList, toolList, profileList] =
|
||||
await Promise.all([
|
||||
api.traffic(0, 50),
|
||||
api.assetCounts(),
|
||||
api.agents(),
|
||||
api.mcpServers(),
|
||||
api.skills(),
|
||||
api.tools(),
|
||||
api.llmProfiles(),
|
||||
]);
|
||||
const [traf, counts, agentList, mcpList, skillList, toolList, profileList, usage] = await Promise.all([
|
||||
api.traffic(0, 50),
|
||||
api.assetCounts(),
|
||||
api.agents(),
|
||||
api.mcpServers(),
|
||||
api.skills(),
|
||||
api.tools(),
|
||||
api.llmProfiles(),
|
||||
api.usageStats(365),
|
||||
]);
|
||||
if (!alive) return;
|
||||
setTraffic(traf.exchanges ?? []);
|
||||
setAssetCounts(counts ?? {});
|
||||
@@ -221,13 +208,17 @@ export default function DashboardPage() {
|
||||
setSkills(skillList);
|
||||
setTools(toolList);
|
||||
setLLMProfiles(profileList);
|
||||
setUsageStats(usage);
|
||||
} catch {
|
||||
/* transient errors */
|
||||
}
|
||||
};
|
||||
load();
|
||||
const t = setInterval(load, 15000);
|
||||
return () => { alive = false; clearInterval(t); };
|
||||
return () => {
|
||||
alive = false;
|
||||
clearInterval(t);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ── derived ───────────────────────────────────────────────────────────────
|
||||
@@ -249,20 +240,13 @@ export default function DashboardPage() {
|
||||
const sortedTasks = React.useMemo(
|
||||
() =>
|
||||
[...tasks]
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(b.last_activity_unix ?? b.created_unix ?? 0) -
|
||||
(a.last_activity_unix ?? a.created_unix ?? 0),
|
||||
)
|
||||
.sort((a, b) => (b.last_activity_unix ?? b.created_unix ?? 0) - (a.last_activity_unix ?? a.created_unix ?? 0))
|
||||
.slice(0, 5),
|
||||
[tasks],
|
||||
);
|
||||
|
||||
const recentFindings = React.useMemo(
|
||||
() =>
|
||||
[...findings]
|
||||
.sort((a, b) => Date.parse(b.ts) - Date.parse(a.ts))
|
||||
.slice(0, 8),
|
||||
() => [...findings].sort((a, b) => Date.parse(b.ts) - Date.parse(a.ts)).slice(0, 8),
|
||||
[findings],
|
||||
);
|
||||
|
||||
@@ -275,10 +259,7 @@ export default function DashboardPage() {
|
||||
[activity],
|
||||
);
|
||||
|
||||
const totalAssets = React.useMemo(
|
||||
() => Object.values(assetCounts).reduce((a, b) => a + b, 0),
|
||||
[assetCounts],
|
||||
);
|
||||
const totalAssets = React.useMemo(() => Object.values(assetCounts).reduce((a, b) => a + b, 0), [assetCounts]);
|
||||
|
||||
// asset type breakdown
|
||||
const assetByType = React.useMemo(() => {
|
||||
@@ -303,9 +284,7 @@ export default function DashboardPage() {
|
||||
}, [traffic]);
|
||||
|
||||
const trafficMax = Math.max(...trafficByCodes.map((x) => x.n), 1);
|
||||
const recentTraffic = [...traffic]
|
||||
.sort((a, b) => Date.parse(b.ts) - Date.parse(a.ts))
|
||||
.slice(0, 5);
|
||||
const recentTraffic = [...traffic].sort((a, b) => Date.parse(b.ts) - Date.parse(a.ts)).slice(0, 5);
|
||||
|
||||
// system
|
||||
const activeProfile = llmProfiles.find((p) => p.is_default);
|
||||
@@ -316,100 +295,147 @@ export default function DashboardPage() {
|
||||
// tasks whose llm_profile_id is null/undefined used the active default profile
|
||||
const defaultProfileId = activeProfile ? Number(activeProfile.id) : null;
|
||||
|
||||
const tokenByProfile = React.useMemo<
|
||||
Map<number | null, { input: number; output: number; cacheRead: number; cacheWrite: number; taskCount: number }>
|
||||
>(() => {
|
||||
const m = new Map<number | null, { input: number; output: number; cacheRead: number; cacheWrite: number; taskCount: number }>();
|
||||
for (const t of tasks) {
|
||||
// null means "used whatever was active" → bucket under defaultProfileId
|
||||
const key = t.llm_profile_id != null ? t.llm_profile_id : defaultProfileId;
|
||||
const prev = m.get(key) ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, taskCount: 0 };
|
||||
m.set(key, {
|
||||
input: prev.input + (t.tokens?.input_tokens ?? 0),
|
||||
output: prev.output + (t.tokens?.output_tokens ?? 0),
|
||||
cacheRead: prev.cacheRead + (t.tokens?.cache_read_tokens ?? 0),
|
||||
cacheWrite: prev.cacheWrite + (t.tokens?.cache_write_tokens ?? 0),
|
||||
taskCount: prev.taskCount + 1,
|
||||
});
|
||||
}
|
||||
// merge conversation (chat) usage into the same per-profile buckets — token sums
|
||||
// only; taskCount stays a task count.
|
||||
for (const c of convTokens) {
|
||||
const key = c.llm_profile_id != null ? c.llm_profile_id : defaultProfileId;
|
||||
const prev = m.get(key) ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, taskCount: 0 };
|
||||
m.set(key, {
|
||||
input: prev.input + c.input_tokens,
|
||||
output: prev.output + c.output_tokens,
|
||||
cacheRead: prev.cacheRead + c.cache_read_tokens,
|
||||
cacheWrite: prev.cacheWrite + c.cache_write_tokens,
|
||||
taskCount: prev.taskCount,
|
||||
});
|
||||
}
|
||||
return m;
|
||||
}, [tasks, convTokens, defaultProfileId]);
|
||||
|
||||
// 数据源开关:旧版 = activity(task.tokens + 会话),新版 = llm_usage 计量账本。
|
||||
const [tokenVersion, setTokenVersion] = React.useState<"old" | "new">("old");
|
||||
// selected profile tab: "all" = 全部; number = specific profile id
|
||||
const [tokenTab, setTokenTab] = React.useState<number | null | "all">("all");
|
||||
// day range for the daily bar chart
|
||||
const [tokenDays, setTokenDays] = React.useState<7 | 30 | 90 | 180 | 365>(30);
|
||||
|
||||
// profile 名 → id,用于把 llm_usage 的 profile_name 映射到现有 profile 分栏。
|
||||
const profileIdByName = React.useMemo(() => {
|
||||
const m = new Map<string, number>();
|
||||
for (const p of llmProfiles) m.set(p.name, Number(p.id));
|
||||
return m;
|
||||
}, [llmProfiles]);
|
||||
|
||||
type Bucket = { input: number; output: number; cacheRead: number; cacheWrite: number; taskCount: number };
|
||||
|
||||
// 旧版:按 profile 归桶(来自 activity 的 task.tokens + 会话用量)。
|
||||
const tokenByProfileOld = React.useMemo<Map<number | null, Bucket>>(() => {
|
||||
const m = new Map<number | null, Bucket>();
|
||||
const fold = (key: number | null, inp: number, out: number, cr: number, cw: number, addTask: boolean) => {
|
||||
const prev = m.get(key) ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, taskCount: 0 };
|
||||
m.set(key, {
|
||||
input: prev.input + inp,
|
||||
output: prev.output + out,
|
||||
cacheRead: prev.cacheRead + cr,
|
||||
cacheWrite: prev.cacheWrite + cw,
|
||||
taskCount: prev.taskCount + (addTask ? 1 : 0),
|
||||
});
|
||||
};
|
||||
for (const t of tasks) {
|
||||
fold(
|
||||
t.llm_profile_id ?? defaultProfileId,
|
||||
t.tokens?.input_tokens ?? 0,
|
||||
t.tokens?.output_tokens ?? 0,
|
||||
t.tokens?.cache_read_tokens ?? 0,
|
||||
t.tokens?.cache_write_tokens ?? 0,
|
||||
true,
|
||||
);
|
||||
}
|
||||
for (const c of convTokens) {
|
||||
fold(
|
||||
c.llm_profile_id ?? defaultProfileId,
|
||||
c.input_tokens,
|
||||
c.output_tokens,
|
||||
c.cache_read_tokens,
|
||||
c.cache_write_tokens,
|
||||
false,
|
||||
);
|
||||
}
|
||||
return m;
|
||||
}, [tasks, convTokens, defaultProfileId]);
|
||||
|
||||
// 新版:按 profile 归桶(来自 llm_usage 全局聚合,逐次调用精确)。
|
||||
const tokenByProfileNew = React.useMemo<Map<number | null, Bucket>>(() => {
|
||||
const m = new Map<number | null, Bucket>();
|
||||
for (const p of usageStats?.by_profile ?? []) {
|
||||
// 未匹配到现有 profile(改名/删除/空名)→ 落到默认桶,仍计入「全部」。
|
||||
const key = profileIdByName.get(p.profile_name) ?? defaultProfileId;
|
||||
const prev = m.get(key) ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, taskCount: 0 };
|
||||
m.set(key, {
|
||||
input: prev.input + p.input_tokens,
|
||||
output: prev.output + p.output_tokens,
|
||||
cacheRead: prev.cacheRead + p.cache_read_tokens,
|
||||
cacheWrite: prev.cacheWrite + p.cache_write_tokens,
|
||||
taskCount: prev.taskCount + p.tasks,
|
||||
});
|
||||
}
|
||||
return m;
|
||||
}, [usageStats, profileIdByName, defaultProfileId]);
|
||||
|
||||
const tokenByProfile = tokenVersion === "new" ? tokenByProfileNew : tokenByProfileOld;
|
||||
|
||||
const displayedTokens = React.useMemo(() => {
|
||||
if (tokenTab === "all") {
|
||||
// sum across all profiles
|
||||
let input = 0, output = 0, cacheRead = 0, cacheWrite = 0, taskCount = 0;
|
||||
let input = 0,
|
||||
output = 0,
|
||||
cacheRead = 0,
|
||||
cacheWrite = 0,
|
||||
taskCount = 0;
|
||||
for (const v of tokenByProfile.values()) {
|
||||
input += v.input; output += v.output;
|
||||
cacheRead += v.cacheRead; cacheWrite += v.cacheWrite;
|
||||
input += v.input;
|
||||
output += v.output;
|
||||
cacheRead += v.cacheRead;
|
||||
cacheWrite += v.cacheWrite;
|
||||
taskCount += v.taskCount;
|
||||
}
|
||||
return { input, output, cacheRead, cacheWrite, taskCount };
|
||||
}
|
||||
const v = tokenByProfile.get(tokenTab as number | null);
|
||||
return v ? { input: v.input, output: v.output, cacheRead: v.cacheRead, cacheWrite: v.cacheWrite, taskCount: v.taskCount }
|
||||
: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, taskCount: 0 };
|
||||
return v
|
||||
? { input: v.input, output: v.output, cacheRead: v.cacheRead, cacheWrite: v.cacheWrite, taskCount: v.taskCount }
|
||||
: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, taskCount: 0 };
|
||||
}, [tokenTab, tokenByProfile]);
|
||||
|
||||
// daily token data: tasks grouped by date, filtered by day range and selected profile
|
||||
const dailyTokenData = React.useMemo(() => {
|
||||
// 旧版每日:把任务/会话的总量按其创建日期归桶(近似,非真实每日消耗)。
|
||||
const dailyTokenDataOld = React.useMemo(() => {
|
||||
const now = new Date();
|
||||
now.setDate(now.getDate() - tokenDays);
|
||||
const cutoff = now.toISOString().slice(0, 10);
|
||||
|
||||
const m = new Map<string, { input: number; output: number; cacheRead: number }>();
|
||||
const fold = (date: string, inp: number, out: number, cr: number) => {
|
||||
if (date < cutoff) return;
|
||||
const prev = m.get(date) ?? { input: 0, output: 0, cacheRead: 0 };
|
||||
m.set(date, { input: prev.input + inp, output: prev.output + out, cacheRead: prev.cacheRead + cr });
|
||||
};
|
||||
for (const t of tasks) {
|
||||
// filter by profile tab when not "all"
|
||||
if (tokenTab !== "all") {
|
||||
const taskKey = t.llm_profile_id ?? defaultProfileId;
|
||||
if (taskKey !== tokenTab) continue;
|
||||
}
|
||||
const date = t.created_at.slice(0, 10);
|
||||
if (date < cutoff) continue;
|
||||
const prev = m.get(date) ?? { input: 0, output: 0, cacheRead: 0 };
|
||||
m.set(date, {
|
||||
input: prev.input + (t.tokens?.input_tokens ?? 0),
|
||||
output: prev.output + (t.tokens?.output_tokens ?? 0),
|
||||
cacheRead: prev.cacheRead + (t.tokens?.cache_read_tokens ?? 0),
|
||||
});
|
||||
if (tokenTab !== "all" && (t.llm_profile_id ?? defaultProfileId) !== tokenTab) continue;
|
||||
fold(
|
||||
t.created_at.slice(0, 10),
|
||||
t.tokens?.input_tokens ?? 0,
|
||||
t.tokens?.output_tokens ?? 0,
|
||||
t.tokens?.cache_read_tokens ?? 0,
|
||||
);
|
||||
}
|
||||
// merge conversation usage by conversation created_at date, same profile filter.
|
||||
for (const c of convTokens) {
|
||||
if (tokenTab !== "all") {
|
||||
const convKey = c.llm_profile_id ?? defaultProfileId;
|
||||
if (convKey !== tokenTab) continue;
|
||||
}
|
||||
const date = c.created_at.slice(0, 10);
|
||||
if (date < cutoff) continue;
|
||||
const prev = m.get(date) ?? { input: 0, output: 0, cacheRead: 0 };
|
||||
m.set(date, {
|
||||
input: prev.input + c.input_tokens,
|
||||
output: prev.output + c.output_tokens,
|
||||
cacheRead: prev.cacheRead + c.cache_read_tokens,
|
||||
if (tokenTab !== "all" && (c.llm_profile_id ?? defaultProfileId) !== tokenTab) continue;
|
||||
fold(c.created_at.slice(0, 10), c.input_tokens, c.output_tokens, c.cache_read_tokens);
|
||||
}
|
||||
return [...m.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([date, v]) => ({ date: date.slice(5), ...v }));
|
||||
}, [tasks, convTokens, tokenDays, tokenTab, defaultProfileId]);
|
||||
|
||||
// 新版每日:来自 llm_usage 的真实每日消耗(ts 是实际调用时刻)。
|
||||
const dailyTokenDataNew = React.useMemo(() => {
|
||||
const now = new Date();
|
||||
now.setDate(now.getDate() - tokenDays);
|
||||
const cutoff = now.toISOString().slice(0, 10);
|
||||
const m = new Map<string, { input: number; output: number; cacheRead: number }>();
|
||||
for (const d of usageStats?.daily ?? []) {
|
||||
if (d.date < cutoff) continue;
|
||||
if (tokenTab !== "all" && (profileIdByName.get(d.profile_name) ?? defaultProfileId) !== tokenTab) continue;
|
||||
const prev = m.get(d.date) ?? { input: 0, output: 0, cacheRead: 0 };
|
||||
m.set(d.date, {
|
||||
input: prev.input + d.input_tokens,
|
||||
output: prev.output + d.output_tokens,
|
||||
cacheRead: prev.cacheRead + d.cache_read_tokens,
|
||||
});
|
||||
}
|
||||
return [...m.entries()]
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([date, v]) => ({ date: date.slice(5), ...v }));
|
||||
}, [tasks, convTokens, tokenDays, tokenTab, defaultProfileId]);
|
||||
return [...m.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([date, v]) => ({ date: date.slice(5), ...v }));
|
||||
}, [usageStats, tokenDays, tokenTab, profileIdByName, defaultProfileId]);
|
||||
|
||||
const dailyTokenData = tokenVersion === "new" ? dailyTokenDataNew : dailyTokenDataOld;
|
||||
|
||||
// activity kind label
|
||||
function kindLabel(a: Activity): string {
|
||||
@@ -434,7 +460,6 @@ export default function DashboardPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-4 pb-6">
|
||||
|
||||
{/* ── Header ── */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -451,7 +476,6 @@ export default function DashboardPage() {
|
||||
|
||||
{/* ── Row 1: 5 stat cards ── */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||
|
||||
{/* 活跃任务 */}
|
||||
<Card className="gap-1">
|
||||
<CardHeader className="pb-0">
|
||||
@@ -459,25 +483,15 @@ export default function DashboardPage() {
|
||||
<TargetIcon className="size-3" /> 活跃任务
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-2xl font-semibold tabular-nums">
|
||||
{tasksByStatus.running ?? 0}
|
||||
</span>
|
||||
<span className="text-2xl font-semibold tabular-nums">{tasksByStatus.running ?? 0}</span>
|
||||
<span className="text-xs text-muted-foreground">/ {tasks.length}</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap gap-x-2.5 gap-y-0.5 text-[10px]">
|
||||
{(tasksByStatus.running ?? 0) > 0 && (
|
||||
<span className="text-blue-400">探索 {tasksByStatus.running}</span>
|
||||
)}
|
||||
{(tasksByStatus.paused ?? 0) > 0 && (
|
||||
<span className="text-amber-400">暂停 {tasksByStatus.paused}</span>
|
||||
)}
|
||||
{(tasksByStatus.done ?? 0) > 0 && (
|
||||
<span className="text-emerald-400">完成 {tasksByStatus.done}</span>
|
||||
)}
|
||||
{tasks.length === 0 && (
|
||||
<span className="text-muted-foreground">暂无任务</span>
|
||||
)}
|
||||
{(tasksByStatus.running ?? 0) > 0 && <span className="text-blue-400">探索 {tasksByStatus.running}</span>}
|
||||
{(tasksByStatus.paused ?? 0) > 0 && <span className="text-amber-400">暂停 {tasksByStatus.paused}</span>}
|
||||
{(tasksByStatus.done ?? 0) > 0 && <span className="text-emerald-400">完成 {tasksByStatus.done}</span>}
|
||||
{tasks.length === 0 && <span className="text-muted-foreground">暂无任务</span>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -487,9 +501,7 @@ export default function DashboardPage() {
|
||||
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
|
||||
<BugIcon className="size-3" /> 确认发现
|
||||
</div>
|
||||
<div className="text-2xl font-semibold tabular-nums">
|
||||
{findings.length}
|
||||
</div>
|
||||
<div className="text-2xl font-semibold tabular-nums">{findings.length}</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap gap-2.5 text-[10px]">
|
||||
<span className="text-rose-500">严重 {findingsBySev.critical}</span>
|
||||
@@ -505,13 +517,9 @@ export default function DashboardPage() {
|
||||
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
|
||||
<NetworkIcon className="size-3" /> 资产节点
|
||||
</div>
|
||||
<div className="text-2xl font-semibold tabular-nums">
|
||||
{totalAssets}
|
||||
</div>
|
||||
<div className="text-2xl font-semibold tabular-nums">{totalAssets}</div>
|
||||
</CardHeader>
|
||||
<CardContent className="text-[10px] text-muted-foreground">
|
||||
跨任务共享
|
||||
</CardContent>
|
||||
<CardContent className="text-[10px] text-muted-foreground">跨任务共享</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 流量交互 */}
|
||||
@@ -520,9 +528,7 @@ export default function DashboardPage() {
|
||||
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
|
||||
<ActivityIcon className="size-3" /> 流量交互
|
||||
</div>
|
||||
<div className="text-2xl font-semibold tabular-nums">
|
||||
{traffic.length}
|
||||
</div>
|
||||
<div className="text-2xl font-semibold tabular-nums">{traffic.length}</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
|
||||
{settings?.traffic_capture ? (
|
||||
@@ -547,7 +553,8 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="text-[10px] text-muted-foreground">
|
||||
入 {fmtTokens(displayedTokens.input)}(含缓存 {fmtTokens(displayedTokens.cacheRead)})· 出 {fmtTokens(displayedTokens.output)}
|
||||
入 {fmtTokens(displayedTokens.input)}(含缓存 {fmtTokens(displayedTokens.cacheRead)})· 出{" "}
|
||||
{fmtTokens(displayedTokens.output)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -559,8 +566,35 @@ export default function DashboardPage() {
|
||||
<div className="flex items-center gap-1.5 text-xs font-semibold">
|
||||
<ZapIcon className="size-3.5 text-muted-foreground" />
|
||||
LLM Token 消耗
|
||||
<span className="ml-1 text-[10px] font-normal text-muted-foreground">
|
||||
{tasks.length} 个任务累计
|
||||
{/* 数据源开关:旧版=activity 统计(含历史任务),新版=llm_usage 计量账本(更准,仅覆盖启用后) */}
|
||||
<div className="ml-1 flex gap-0.5 rounded-md border bg-muted/30 p-0.5">
|
||||
{(
|
||||
[
|
||||
{ v: "old", label: "旧版" },
|
||||
{ v: "new", label: "新版" },
|
||||
] as const
|
||||
).map(({ v, label }) => (
|
||||
<button
|
||||
key={v}
|
||||
onClick={() => setTokenVersion(v)}
|
||||
title={
|
||||
v === "new"
|
||||
? "新版:来自 llm_usage 计量账本,逐次调用精确、含中断消耗;仅覆盖启用后的数据"
|
||||
: "旧版:来自 activity 统计(含历史任务),中断消耗不计、无法精确到模型"
|
||||
}
|
||||
className={cn(
|
||||
"rounded px-2 py-0.5 text-[9px] font-medium transition-colors",
|
||||
tokenVersion === v
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-[10px] font-normal text-muted-foreground">
|
||||
{tokenVersion === "new" ? "llm_usage" : "activity"}
|
||||
</span>
|
||||
</div>
|
||||
{/* Profile tabs */}
|
||||
@@ -579,7 +613,7 @@ export default function DashboardPage() {
|
||||
{llmProfiles.map((p) => {
|
||||
const key = Number(p.id);
|
||||
const hasData = tokenByProfile.has(key) || (p.is_default && tokenByProfile.has(defaultProfileId));
|
||||
const resolvedKey = tokenByProfile.has(key) ? key : (p.is_default ? defaultProfileId : key);
|
||||
const resolvedKey = tokenByProfile.has(key) ? key : p.is_default ? defaultProfileId : key;
|
||||
return (
|
||||
<button
|
||||
key={p.id}
|
||||
@@ -594,12 +628,14 @@ export default function DashboardPage() {
|
||||
>
|
||||
{p.name}
|
||||
{p.is_default && (
|
||||
<span className={cn(
|
||||
"rounded px-1 py-0 text-[8px]",
|
||||
tokenTab === resolvedKey
|
||||
? "bg-background/20 text-background"
|
||||
: "bg-emerald-500/20 text-emerald-400",
|
||||
)}>
|
||||
<span
|
||||
className={cn(
|
||||
"rounded px-1 py-0 text-[8px]",
|
||||
tokenTab === resolvedKey
|
||||
? "bg-background/20 text-background"
|
||||
: "bg-emerald-500/20 text-emerald-400",
|
||||
)}
|
||||
>
|
||||
默认
|
||||
</span>
|
||||
)}
|
||||
@@ -611,7 +647,6 @@ export default function DashboardPage() {
|
||||
|
||||
{/* Body: metrics left + bar chart right */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[220px_1fr]">
|
||||
|
||||
{/* Left: key metrics */}
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Total */}
|
||||
@@ -620,9 +655,7 @@ export default function DashboardPage() {
|
||||
<div className="mt-0.5 text-3xl font-bold tabular-nums tracking-tight">
|
||||
{fmtTokens(displayedTokens.input + displayedTokens.output) || "—"}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[10px] text-muted-foreground">
|
||||
{displayedTokens.taskCount} 个任务
|
||||
</div>
|
||||
<div className="mt-0.5 text-[10px] text-muted-foreground">{displayedTokens.taskCount} 个任务</div>
|
||||
</div>
|
||||
|
||||
{/* Per-type bars */}
|
||||
@@ -631,18 +664,31 @@ export default function DashboardPage() {
|
||||
// input 已含缓存;拆成不重叠三段:未命中输入 + 缓存命中 + 输出 = 总量。
|
||||
const total = displayedTokens.input + displayedTokens.output;
|
||||
return [
|
||||
{ label: "输入(未命中)", value: displayedTokens.input - displayedTokens.cacheRead, barColor: dailyTrendConfig.input.color!, text: "text-blue-400" },
|
||||
{ label: "缓存命中", value: displayedTokens.cacheRead, barColor: dailyTrendConfig.cacheRead.color!, text: "text-emerald-400" },
|
||||
{ label: "输出", value: displayedTokens.output, barColor: dailyTrendConfig.output.color!, text: "text-violet-400" },
|
||||
{
|
||||
label: "输入(未命中)",
|
||||
value: displayedTokens.input - displayedTokens.cacheRead,
|
||||
barColor: dailyTrendConfig.input.color!,
|
||||
text: "text-blue-400",
|
||||
},
|
||||
{
|
||||
label: "缓存命中",
|
||||
value: displayedTokens.cacheRead,
|
||||
barColor: dailyTrendConfig.cacheRead.color!,
|
||||
text: "text-emerald-400",
|
||||
},
|
||||
{
|
||||
label: "输出",
|
||||
value: displayedTokens.output,
|
||||
barColor: dailyTrendConfig.output.color!,
|
||||
text: "text-violet-400",
|
||||
},
|
||||
].map(({ label, value, barColor, text }) => {
|
||||
const pct = total > 0 ? (value / total) * 100 : 0;
|
||||
return (
|
||||
<div key={label}>
|
||||
<div className="mb-1 flex items-center justify-between text-[10px]">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className={cn("font-mono font-semibold tabular-nums", text)}>
|
||||
{fmtTokens(value)}
|
||||
</span>
|
||||
<span className={cn("font-mono font-semibold tabular-nums", text)}>{fmtTokens(value)}</span>
|
||||
</div>
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
@@ -664,7 +710,9 @@ export default function DashboardPage() {
|
||||
return (
|
||||
<div className="flex items-center justify-between rounded-lg border bg-muted/20 px-3 py-2 text-[10px]">
|
||||
<span className="text-muted-foreground">缓存命中率</span>
|
||||
<span className={cn("font-semibold tabular-nums", hitPct > 50 ? "text-emerald-400" : "text-amber-400")}>
|
||||
<span
|
||||
className={cn("font-semibold tabular-nums", hitPct > 50 ? "text-emerald-400" : "text-amber-400")}
|
||||
>
|
||||
{hitPct}%
|
||||
</span>
|
||||
</div>
|
||||
@@ -687,13 +735,15 @@ export default function DashboardPage() {
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-0.5 rounded-md border bg-muted/30 p-0.5">
|
||||
{([
|
||||
{ days: 7, label: "7天" },
|
||||
{ days: 30, label: "30天" },
|
||||
{ days: 90, label: "3月" },
|
||||
{ days: 180, label: "6月" },
|
||||
{ days: 365, label: "一年" },
|
||||
] as const).map(({ days, label }) => (
|
||||
{(
|
||||
[
|
||||
{ days: 7, label: "7天" },
|
||||
{ days: 30, label: "30天" },
|
||||
{ days: 90, label: "3月" },
|
||||
{ days: 180, label: "6月" },
|
||||
{ days: 365, label: "一年" },
|
||||
] as const
|
||||
).map(({ days, label }) => (
|
||||
<button
|
||||
key={days}
|
||||
onClick={() => setTokenDays(days)}
|
||||
@@ -711,16 +761,15 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
|
||||
{dailyTokenData.length === 0 ? (
|
||||
<div className="flex flex-1 items-center justify-center rounded-lg border bg-muted/10 text-xs text-muted-foreground" style={{ minHeight: 180 }}>
|
||||
<div
|
||||
className="flex flex-1 items-center justify-center rounded-lg border bg-muted/10 text-xs text-muted-foreground"
|
||||
style={{ minHeight: 180 }}
|
||||
>
|
||||
暂无数据
|
||||
</div>
|
||||
) : (
|
||||
<ChartContainer config={dailyTrendConfig} className="h-[200px] w-full">
|
||||
<BarChart
|
||||
data={dailyTokenData}
|
||||
margin={{ top: 4, right: 4, bottom: 0, left: 0 }}
|
||||
maxBarSize={40}
|
||||
>
|
||||
<BarChart data={dailyTokenData} margin={{ top: 4, right: 4, bottom: 0, left: 0 }} maxBarSize={40}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
@@ -744,17 +793,18 @@ export default function DashboardPage() {
|
||||
indicator="line"
|
||||
formatter={(value, name) => (
|
||||
<div className="flex w-full items-center justify-between gap-4">
|
||||
<span>{(dailyTrendConfig as Record<string, { label: string }>)[String(name)]?.label ?? String(name)}</span>
|
||||
<span className="font-mono font-semibold tabular-nums">
|
||||
{fmtTokens(Number(value))}
|
||||
<span>
|
||||
{(dailyTrendConfig as Record<string, { label: string }>)[String(name)]?.label ??
|
||||
String(name)}
|
||||
</span>
|
||||
<span className="font-mono font-semibold tabular-nums">{fmtTokens(Number(value))}</span>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Bar dataKey="input" stackId="1" fill="var(--color-input)" radius={[0, 0, 0, 0]} />
|
||||
<Bar dataKey="output" stackId="1" fill="var(--color-output)" radius={[0, 0, 0, 0]} />
|
||||
<Bar dataKey="input" stackId="1" fill="var(--color-input)" radius={[0, 0, 0, 0]} />
|
||||
<Bar dataKey="output" stackId="1" fill="var(--color-output)" radius={[0, 0, 0, 0]} />
|
||||
<Bar dataKey="cacheRead" stackId="1" fill="var(--color-cacheRead)" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
@@ -765,7 +815,6 @@ export default function DashboardPage() {
|
||||
|
||||
{/* ── Row 3: 活动流 | 发现 ── */}
|
||||
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||||
|
||||
{/* 活动流 */}
|
||||
<Card className="p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
@@ -787,27 +836,20 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
|
||||
<div className="mb-2.5 flex flex-wrap gap-1.5">
|
||||
{(["tool_use", "tool_result", "text", "thinking", "result"] as const).map(
|
||||
(kind) => {
|
||||
const count = activity.filter((a) => a.kind === kind).length;
|
||||
if (count === 0) return null;
|
||||
return (
|
||||
<span
|
||||
key={kind}
|
||||
className="rounded border bg-muted/20 px-1.5 py-0.5 text-[9px] text-muted-foreground"
|
||||
>
|
||||
{kind} <strong className="text-foreground/70">{count}</strong>
|
||||
</span>
|
||||
);
|
||||
},
|
||||
)}
|
||||
{(["tool_use", "tool_result", "text", "thinking", "result"] as const).map((kind) => {
|
||||
const count = activity.filter((a) => a.kind === kind).length;
|
||||
if (count === 0) return null;
|
||||
return (
|
||||
<span key={kind} className="rounded border bg-muted/20 px-1.5 py-0.5 text-[9px] text-muted-foreground">
|
||||
{kind} <strong className="text-foreground/70">{count}</strong>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="divide-y">
|
||||
{recentActivity.length === 0 ? (
|
||||
<div className="py-4 text-center text-xs text-muted-foreground">
|
||||
暂无活动记录
|
||||
</div>
|
||||
<div className="py-4 text-center text-xs text-muted-foreground">暂无活动记录</div>
|
||||
) : (
|
||||
recentActivity.map((a) => (
|
||||
<div key={a.seq} className="flex gap-2.5 py-2">
|
||||
@@ -821,20 +863,13 @@ export default function DashboardPage() {
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[11px] font-medium">{kindLabel(a)}</div>
|
||||
<div className="mt-0.5 truncate text-[10px] text-muted-foreground">
|
||||
{a.summary}
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-[10px] text-muted-foreground">{a.summary}</div>
|
||||
</div>
|
||||
<div className="shrink-0 text-right">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="px-1.5 py-0 text-[9px] text-muted-foreground"
|
||||
>
|
||||
<Badge variant="outline" className="px-1.5 py-0 text-[9px] text-muted-foreground">
|
||||
{a.kind}
|
||||
</Badge>
|
||||
<div className="mt-0.5 text-[9px] tabular-nums text-muted-foreground">
|
||||
{fmtRel(a.ts)}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[9px] tabular-nums text-muted-foreground">{fmtRel(a.ts)}</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
@@ -859,9 +894,7 @@ export default function DashboardPage() {
|
||||
|
||||
<div className="divide-y">
|
||||
{recentFindings.length === 0 ? (
|
||||
<div className="py-4 text-center text-xs text-muted-foreground">
|
||||
暂无发现
|
||||
</div>
|
||||
<div className="py-4 text-center text-xs text-muted-foreground">暂无发现</div>
|
||||
) : (
|
||||
recentFindings.map((f) => (
|
||||
<div key={f.id} className="flex items-start gap-2 py-2">
|
||||
@@ -874,18 +907,14 @@ export default function DashboardPage() {
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-[11px] font-semibold">{f.vulnclass}</span>
|
||||
{f.task_description && (
|
||||
<span className="truncate text-[10px] text-muted-foreground">
|
||||
{f.task_description}
|
||||
</span>
|
||||
<span className="truncate text-[10px] text-muted-foreground">{f.task_description}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 line-clamp-2 text-[10px] leading-snug text-muted-foreground">
|
||||
{f.summary}
|
||||
</div>
|
||||
</div>
|
||||
<span className="shrink-0 text-[9px] tabular-nums text-muted-foreground">
|
||||
{fmtRel(f.ts)}
|
||||
</span>
|
||||
<span className="shrink-0 text-[9px] tabular-nums text-muted-foreground">{fmtRel(f.ts)}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
@@ -901,9 +930,7 @@ export default function DashboardPage() {
|
||||
任务
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{tasks.length} 个任务
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">{tasks.length} 个任务</span>
|
||||
<Link
|
||||
href="/function/tasks"
|
||||
className="flex items-center gap-0.5 text-[10px] text-muted-foreground hover:text-foreground"
|
||||
@@ -915,49 +942,32 @@ export default function DashboardPage() {
|
||||
<table className="w-full border-collapse text-xs">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
{["任务", "状态", "引擎", "目标进度", "在途", "最近活动"].map(
|
||||
(h) => (
|
||||
<th
|
||||
key={h}
|
||||
className="px-4 py-2 text-left text-[9px] font-semibold uppercase tracking-widest text-muted-foreground first:pl-4"
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
),
|
||||
)}
|
||||
{["任务", "状态", "引擎", "目标进度", "在途", "最近活动"].map((h) => (
|
||||
<th
|
||||
key={h}
|
||||
className="px-4 py-2 text-left text-[9px] font-semibold uppercase tracking-widest text-muted-foreground first:pl-4"
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedTasks.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={6}
|
||||
className="px-4 py-6 text-center text-xs text-muted-foreground"
|
||||
>
|
||||
<td colSpan={6} className="px-4 py-6 text-center text-xs text-muted-foreground">
|
||||
暂无任务
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
sortedTasks.map((t) => {
|
||||
const goalsPct = t.goals_total
|
||||
? Math.round(((t.goals_met ?? 0) / t.goals_total) * 100)
|
||||
: null;
|
||||
const goalsPct = t.goals_total ? Math.round(((t.goals_met ?? 0) / t.goals_total) * 100) : null;
|
||||
return (
|
||||
<tr
|
||||
key={t.id}
|
||||
className="border-b last:border-0 transition-colors hover:bg-muted/30"
|
||||
>
|
||||
<tr key={t.id} className="border-b last:border-0 transition-colors hover:bg-muted/30">
|
||||
<td className="max-w-xs px-4 py-3">
|
||||
<Link
|
||||
href={`/function/tasks/detail?id=${t.id}`}
|
||||
className="group flex flex-col"
|
||||
>
|
||||
<span className="truncate font-medium group-hover:underline">
|
||||
{t.description}
|
||||
</span>
|
||||
<span className="mt-0.5 truncate font-mono text-[10px] text-muted-foreground">
|
||||
{t.id}
|
||||
</span>
|
||||
<Link href={`/function/tasks/detail?id=${t.id}`} className="group flex flex-col">
|
||||
<span className="truncate font-medium group-hover:underline">{t.description}</span>
|
||||
<span className="mt-0.5 truncate font-mono text-[10px] text-muted-foreground">{t.id}</span>
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
@@ -983,7 +993,7 @@ export default function DashboardPage() {
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 tabular-nums">
|
||||
{t.in_flight ?? 0 > 0 ? (
|
||||
{(t.in_flight ?? 0 > 0) ? (
|
||||
<span className="font-semibold">{t.in_flight}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
@@ -1002,7 +1012,6 @@ export default function DashboardPage() {
|
||||
|
||||
{/* ── Row 5: 资产分布 | 流量状态码 | 拦截 & 待审批 ── */}
|
||||
<div className="grid grid-cols-1 gap-3 lg:grid-cols-3">
|
||||
|
||||
{/* 资产分布 */}
|
||||
<Card className="p-4">
|
||||
<SectionTitle icon={NetworkIcon} sub="按类型">
|
||||
@@ -1010,9 +1019,7 @@ export default function DashboardPage() {
|
||||
</SectionTitle>
|
||||
|
||||
{assetByType.length === 0 ? (
|
||||
<div className="py-6 text-center text-xs text-muted-foreground">
|
||||
暂无资产数据
|
||||
</div>
|
||||
<div className="py-6 text-center text-xs text-muted-foreground">暂无资产数据</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{assetByType.map(([type, count]) => (
|
||||
@@ -1032,9 +1039,7 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 border-t pt-3 text-[10px] text-muted-foreground">
|
||||
共 {totalAssets} 节点
|
||||
</div>
|
||||
<div className="mt-3 border-t pt-3 text-[10px] text-muted-foreground">共 {totalAssets} 节点</div>
|
||||
</Card>
|
||||
|
||||
{/* 流量状态码 */}
|
||||
@@ -1045,36 +1050,24 @@ export default function DashboardPage() {
|
||||
|
||||
{/* bar chart */}
|
||||
{trafficByCodes.length === 0 ? (
|
||||
<div className="py-6 text-center text-xs text-muted-foreground">
|
||||
暂无流量数据
|
||||
</div>
|
||||
<div className="py-6 text-center text-xs text-muted-foreground">暂无流量数据</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-3 flex items-end gap-2" style={{ height: 52 }}>
|
||||
{trafficByCodes.map(({ code, n }) => (
|
||||
<div key={code} className="flex flex-1 flex-col items-center gap-1">
|
||||
<span className="text-[9px] tabular-nums text-muted-foreground">
|
||||
{n}
|
||||
</span>
|
||||
<span className="text-[9px] tabular-nums text-muted-foreground">{n}</span>
|
||||
<div
|
||||
className={cn(
|
||||
"w-full min-h-1 rounded-sm",
|
||||
statusBg(code),
|
||||
"opacity-80",
|
||||
)}
|
||||
className={cn("w-full min-h-1 rounded-sm", statusBg(code), "opacity-80")}
|
||||
style={{ height: Math.max(4, (n / trafficMax) * 36) }}
|
||||
/>
|
||||
<span className={cn("text-[9px] font-mono", statusColor(code))}>
|
||||
{code}
|
||||
</span>
|
||||
<span className={cn("text-[9px] font-mono", statusColor(code))}>{code}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-2.5">
|
||||
<div className="mb-1.5 text-[10px] text-muted-foreground">
|
||||
最近请求
|
||||
</div>
|
||||
<div className="mb-1.5 text-[10px] text-muted-foreground">最近请求</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{recentTraffic.map((e) => (
|
||||
<div key={e.id} className="flex items-center gap-1.5 text-[10px]">
|
||||
@@ -1092,14 +1085,7 @@ export default function DashboardPage() {
|
||||
{e.host}
|
||||
{e.url.replace(/^https?:\/\/[^/]+/, "").substring(0, 30)}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 font-mono text-[9px]",
|
||||
statusColor(e.status),
|
||||
)}
|
||||
>
|
||||
{e.status}
|
||||
</span>
|
||||
<span className={cn("shrink-0 font-mono text-[9px]", statusColor(e.status))}>{e.status}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -1154,9 +1140,7 @@ export default function DashboardPage() {
|
||||
{/* pending approvals */}
|
||||
{pendingCount > 0 && (
|
||||
<div className="mt-3">
|
||||
<div className="mb-1.5 text-[10px] font-medium text-amber-400">
|
||||
待审批 ({pendingCount})
|
||||
</div>
|
||||
<div className="mb-1.5 text-[10px] font-medium text-amber-400">待审批 ({pendingCount})</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{pending.slice(0, 3).map((p) => (
|
||||
<Link
|
||||
@@ -1166,9 +1150,7 @@ export default function DashboardPage() {
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] font-medium">{p.tool_name}</div>
|
||||
<div className="text-[9px] text-muted-foreground">
|
||||
{p.agent_name}
|
||||
</div>
|
||||
<div className="text-[9px] text-muted-foreground">{p.agent_name}</div>
|
||||
</div>
|
||||
<ArrowUpRightIcon className="size-3 shrink-0 text-amber-400" />
|
||||
</Link>
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
SelectTrigger,
|
||||
} from "@/components/ui/select";
|
||||
import { StatusBadge } from "@/components/status-badge";
|
||||
import { CopyButton } from "@/components/copy-button";
|
||||
import { Markdown } from "@/components/markdown";
|
||||
import { FindingLineageView } from "./lineage";
|
||||
import { statusMeta } from "@/lib/status";
|
||||
@@ -208,8 +209,11 @@ function FindingDetailInner() {
|
||||
</Card>
|
||||
{/* 证据下方:详细报告(Markdown 渲染) */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardHeader className="flex-row items-center justify-between">
|
||||
<CardTitle className="text-sm">详细报告</CardTitle>
|
||||
{finding.report && (
|
||||
<CopyButton text={finding.report} successMessage="已复制详细报告" />
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{finding.report ? (
|
||||
|
||||
@@ -8,10 +8,22 @@ import {
|
||||
ArrowUpRightIcon,
|
||||
FileTextIcon,
|
||||
Trash2Icon,
|
||||
DownloadIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import { StatusBadge } from "@/components/status-badge";
|
||||
import { CopyButton } from "@/components/copy-button";
|
||||
import { Markdown } from "@/components/markdown";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -101,6 +113,73 @@ export default function FindingsPage() {
|
||||
const [page, setPage] = React.useState(1);
|
||||
const [pageSize, setPageSize] = React.useState(20);
|
||||
|
||||
// 勾选导出:按 finding_id(独立表 id)记选中项,跨页保留。
|
||||
const [selectedIds, setSelectedIds] = React.useState<Set<string>>(() => new Set());
|
||||
// 导出弹窗状态:范围(当前筛选/全部/选中) × 格式(md 单文件/md 分文件 zip/csv/json)。
|
||||
const [exportOpen, setExportOpen] = React.useState(false);
|
||||
const [exportScope, setExportScope] = React.useState<"filtered" | "all" | "selected">("filtered");
|
||||
const [exportFormat, setExportFormat] = React.useState<"md-single" | "md-zip" | "csv" | "json">("md-single");
|
||||
const [exporting, setExporting] = React.useState(false);
|
||||
|
||||
const toggleSelected = React.useCallback((id: string, checked: boolean) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (checked) next.add(id);
|
||||
else next.delete(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 当前页可勾选的 finding_id(排除无 finding_id 的继承行)。
|
||||
const pageSelectableIds = React.useMemo(
|
||||
() => findings.map((f) => f.finding_id).filter((id): id is string => !!id),
|
||||
[findings],
|
||||
);
|
||||
const pageSelectedCount = pageSelectableIds.filter((id) => selectedIds.has(id)).length;
|
||||
let headerChecked: boolean | "indeterminate" = false;
|
||||
if (pageSelectableIds.length > 0 && pageSelectedCount === pageSelectableIds.length) {
|
||||
headerChecked = true;
|
||||
} else if (pageSelectedCount > 0) {
|
||||
headerChecked = "indeterminate";
|
||||
}
|
||||
const toggleSelectedPage = React.useCallback(
|
||||
(checked: boolean) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const id of pageSelectableIds) {
|
||||
if (checked) next.add(id);
|
||||
else next.delete(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[pageSelectableIds],
|
||||
);
|
||||
|
||||
// 打开导出弹窗时,若有勾选项则默认范围切到「选中」,否则「当前筛选」。
|
||||
function openExport() {
|
||||
setExportScope(selectedIds.size > 0 ? "selected" : "filtered");
|
||||
setExportOpen(true);
|
||||
}
|
||||
|
||||
async function doExport() {
|
||||
setExporting(true);
|
||||
try {
|
||||
await api.exportFindings({
|
||||
format: exportFormat,
|
||||
scope: exportScope,
|
||||
filters: { severity, status, vulnclass, task, sort },
|
||||
ids: [...selectedIds],
|
||||
});
|
||||
setExportOpen(false);
|
||||
toast.success("已开始下载导出文件");
|
||||
} catch (e) {
|
||||
toast.error("导出失败:" + (e as Error).message);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
// reset to page 1 whenever filters change
|
||||
React.useEffect(() => {
|
||||
setPage(1);
|
||||
@@ -386,9 +465,19 @@ export default function FindingsPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
|
||||
共 {total} 条
|
||||
</span>
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
{selectedIds.size > 0 && (
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
已选 {selectedIds.size} 条
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
共 {total} 条
|
||||
</span>
|
||||
<Button size="sm" variant="outline" onClick={openExport}>
|
||||
<DownloadIcon /> 导出
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="py-0">
|
||||
@@ -398,6 +487,13 @@ export default function FindingsPage() {
|
||||
<Table className="table-fixed">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-8">
|
||||
<Checkbox
|
||||
checked={headerChecked}
|
||||
onCheckedChange={(c) => toggleSelectedPage(c === true)}
|
||||
aria-label="选择本页全部"
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="w-8" />
|
||||
<TableHead className="w-20">严重度</TableHead>
|
||||
<TableHead>漏洞名称</TableHead>
|
||||
@@ -417,6 +513,17 @@ export default function FindingsPage() {
|
||||
className="cursor-pointer"
|
||||
onClick={() => toggleRow(f)}
|
||||
>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
{f.finding_id && (
|
||||
<Checkbox
|
||||
checked={selectedIds.has(f.finding_id)}
|
||||
onCheckedChange={(c) =>
|
||||
toggleSelected(f.finding_id as string, c === true)
|
||||
}
|
||||
aria-label="选择该漏洞"
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<ChevronRightIcon
|
||||
className={cn(
|
||||
@@ -555,7 +662,7 @@ export default function FindingsPage() {
|
||||
<TableRow className="hover:bg-transparent">
|
||||
{/* whitespace-normal 覆盖 TableCell 默认的 nowrap,否则展开区文字
|
||||
被强制单行、直接溢出单元格。 */}
|
||||
<TableCell colSpan={8} className="bg-muted/30 whitespace-normal">
|
||||
<TableCell colSpan={9} className="bg-muted/30 whitespace-normal">
|
||||
<div className="flex flex-col gap-2 px-2 py-1">
|
||||
{/* 行内编辑:名称/类别/严重等级,可改并保存(仅独立 finding 行)。 */}
|
||||
{f.finding_id && edit && (
|
||||
@@ -643,9 +750,20 @@ export default function FindingsPage() {
|
||||
{/* 详细报告(Markdown):展开时按 finding_id 懒加载,免进详情页即可查看。 */}
|
||||
{f.finding_id && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<FileTextIcon className="size-3.5" />
|
||||
详细报告
|
||||
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-2">
|
||||
<FileTextIcon className="size-3.5" />
|
||||
详细报告
|
||||
</span>
|
||||
{reports[f.id]?.status === "done" &&
|
||||
reports[f.id]?.text.trim() && (
|
||||
<CopyButton
|
||||
text={reports[f.id]?.text}
|
||||
successMessage="已复制详细报告"
|
||||
variant="ghost"
|
||||
className="h-6 px-2 text-xs"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{(() => {
|
||||
const rep = reports[f.id];
|
||||
@@ -686,7 +804,7 @@ export default function FindingsPage() {
|
||||
{findings.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={8}
|
||||
colSpan={9}
|
||||
className="py-12 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
没有匹配的发现。
|
||||
@@ -705,6 +823,76 @@ export default function FindingsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Dialog open={exportOpen} onOpenChange={setExportOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>导出发现</DialogTitle>
|
||||
<DialogDescription>
|
||||
选择导出范围与格式,生成后浏览器会自动下载。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-5 py-1">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-xs text-muted-foreground">导出范围</Label>
|
||||
<RadioGroup
|
||||
value={exportScope}
|
||||
onValueChange={(v) => setExportScope(v as typeof exportScope)}
|
||||
>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<RadioGroupItem value="filtered" /> 导出当前筛选结果(共 {total} 条)
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<RadioGroupItem value="all" /> 导出全部
|
||||
</label>
|
||||
<label
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm",
|
||||
selectedIds.size === 0 && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<RadioGroupItem value="selected" disabled={selectedIds.size === 0} />
|
||||
导出勾选的 {selectedIds.size} 条
|
||||
</label>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-xs text-muted-foreground">导出格式</Label>
|
||||
<RadioGroup
|
||||
value={exportFormat}
|
||||
onValueChange={(v) => setExportFormat(v as typeof exportFormat)}
|
||||
>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<RadioGroupItem value="md-single" /> Markdown 汇总报告(单个 .md 文件)
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<RadioGroupItem value="md-zip" /> Markdown 分文件(一漏洞一 .md,打包 .zip)
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<RadioGroupItem value="csv" /> CSV 表格(.csv)
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<RadioGroupItem value="json" /> JSON(.json)
|
||||
</label>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setExportOpen(false)} disabled={exporting}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={doExport}
|
||||
disabled={exporting || (exportScope === "selected" && selectedIds.size === 0)}
|
||||
>
|
||||
<DownloadIcon /> {exporting ? "导出中…" : "导出"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
ActivityIcon,
|
||||
AlertTriangleIcon,
|
||||
BugIcon,
|
||||
ClockIcon,
|
||||
CoinsIcon,
|
||||
PlusIcon,
|
||||
RefreshCwIcon,
|
||||
ShieldCheckIcon,
|
||||
TargetIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
|
||||
import { StatusBadge } from "@/components/status-badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { api } from "@/lib/api";
|
||||
import type { Task, Stats, TaskNode, Finding } from "@/lib/types";
|
||||
import type { Finding, ModelTokenStat, Stats, Task, TaskNode, TaskScopeRow } from "@/lib/types";
|
||||
|
||||
// 紧凑格式化 token 数(12345 → 12.3k,2000000 → 2M)。
|
||||
function fmtTokens(n: number): string {
|
||||
if (n >= 1_000_000) return (n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1) + "M";
|
||||
if (n >= 1000) return (n / 1000).toFixed(n >= 10000 ? 0 : 1) + "k";
|
||||
return String(n);
|
||||
}
|
||||
|
||||
// 缓存命中率 = 缓存读 / 输入(InputTokens 已含 cache_read 子集,故比值在 0–100%)。
|
||||
function cacheHitRate(cacheRead: number, input: number): string {
|
||||
if (input <= 0) return "—";
|
||||
return Math.round((cacheRead / input) * 100) + "%";
|
||||
}
|
||||
|
||||
// 测试范围一条的显示值:域名 / 网段 / 公司。
|
||||
function scopeValue(row: TaskScopeRow): string {
|
||||
if (row.domain) return row.domain;
|
||||
if (row.net) return row.net;
|
||||
if (row.company_id) return `company #${row.company_id}`;
|
||||
return "—";
|
||||
}
|
||||
|
||||
const SCOPE_KIND_LABELS: Record<TaskScopeRow["kind"], string> = {
|
||||
company: "公司",
|
||||
root_domain: "根域名",
|
||||
subdomain: "子域名",
|
||||
ip: "IP",
|
||||
cidr: "网段",
|
||||
};
|
||||
|
||||
const SCOPE_SOURCE_LABELS: Record<TaskScopeRow["source"], string> = {
|
||||
auto: "自动",
|
||||
agent: "Agent",
|
||||
manual: "手动",
|
||||
};
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
@@ -42,9 +78,7 @@ function StatCard({
|
||||
</CardDescription>
|
||||
<CardTitle className="text-2xl tabular-nums">{value}</CardTitle>
|
||||
</CardHeader>
|
||||
{sub && (
|
||||
<CardContent className="text-xs text-muted-foreground">{sub}</CardContent>
|
||||
)}
|
||||
{sub && <CardContent className="text-xs text-muted-foreground">{sub}</CardContent>}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -63,6 +97,57 @@ export function OverviewTab({ taskId }: { taskId: string }) {
|
||||
} | null>(null);
|
||||
// 正在重跑的意图 id(含 "__all__" 表示批量),用于禁用按钮 + 转圈。
|
||||
const [rerunning, setRerunning] = React.useState<Set<string>>(new Set());
|
||||
// 测试范围列表 + 新增表单状态。
|
||||
const [scope, setScope] = React.useState<TaskScopeRow[]>([]);
|
||||
const [scopeKind, setScopeKind] = React.useState<TaskScopeRow["kind"]>("root_domain");
|
||||
const [scopeValueInput, setScopeValueInput] = React.useState("");
|
||||
const [scopeBusy, setScopeBusy] = React.useState(false);
|
||||
const [scopeErr, setScopeErr] = React.useState("");
|
||||
// 按模型的 token 用量(来自常开的 llm_usage 计量账本,逐次精确)。
|
||||
const [modelTokens, setModelTokens] = React.useState<ModelTokenStat[]>([]);
|
||||
|
||||
const loadTokens = React.useCallback(async () => {
|
||||
try {
|
||||
const resp = await api.tokensByModel(taskId);
|
||||
setModelTokens(resp.models);
|
||||
} catch {
|
||||
// 忽略:无 PG 时接口报错,卡片自然为空
|
||||
}
|
||||
}, [taskId]);
|
||||
|
||||
const loadScope = React.useCallback(async () => {
|
||||
try {
|
||||
const resp = await api.taskScope(taskId);
|
||||
setScope(resp.scope);
|
||||
} catch {
|
||||
// 忽略:无 asset store 时接口 503,范围卡片自然为空
|
||||
}
|
||||
}, [taskId]);
|
||||
|
||||
const addScope = async () => {
|
||||
const value = scopeValueInput.trim();
|
||||
if (!value) return;
|
||||
setScopeBusy(true);
|
||||
setScopeErr("");
|
||||
try {
|
||||
await api.addTaskScope(taskId, scopeKind, value);
|
||||
setScopeValueInput("");
|
||||
await loadScope();
|
||||
} catch (e) {
|
||||
setScopeErr(e instanceof Error ? e.message : "添加失败");
|
||||
} finally {
|
||||
setScopeBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeScope = async (row: TaskScopeRow) => {
|
||||
setScope((prev) => prev.filter((s) => s.id !== row.id));
|
||||
try {
|
||||
await api.deleteTaskScope(taskId, row.id);
|
||||
} catch {
|
||||
await loadScope(); // 删除失败:重新拉取还原
|
||||
}
|
||||
};
|
||||
|
||||
const markRerun = (key: string, on: boolean) =>
|
||||
setRerunning((prev) => {
|
||||
@@ -77,9 +162,7 @@ export function OverviewTab({ taskId }: { taskId: string }) {
|
||||
markRerun(id, true);
|
||||
try {
|
||||
await api.rerunIntent(taskId, id);
|
||||
setIntents((prev) =>
|
||||
prev.map((i) => (i.id === id ? { ...i, state: "open" } : i)),
|
||||
);
|
||||
setIntents((prev) => prev.map((i) => (i.id === id ? { ...i, state: "open" } : i)));
|
||||
} catch {
|
||||
// 失败忽略:下次轮询仍显示 blocked,用户可再点
|
||||
} finally {
|
||||
@@ -92,9 +175,7 @@ export function OverviewTab({ taskId }: { taskId: string }) {
|
||||
markRerun("__all__", true);
|
||||
try {
|
||||
await api.rerunBlocked(taskId);
|
||||
setIntents((prev) =>
|
||||
prev.map((i) => (i.state === "blocked" ? { ...i, state: "open" } : i)),
|
||||
);
|
||||
setIntents((prev) => prev.map((i) => (i.state === "blocked" ? { ...i, state: "open" } : i)));
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
@@ -107,13 +188,12 @@ export function OverviewTab({ taskId }: { taskId: string }) {
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const [tasksResp, statsResp, intentsResp, findingsResp] =
|
||||
await Promise.all([
|
||||
api.tasks(),
|
||||
api.stats(taskId),
|
||||
api.intents(taskId),
|
||||
api.findings(taskId),
|
||||
]);
|
||||
const [tasksResp, statsResp, intentsResp, findingsResp] = await Promise.all([
|
||||
api.tasks(),
|
||||
api.stats(taskId),
|
||||
api.intents(taskId),
|
||||
api.findings(taskId),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setTask(tasksResp.tasks.find((t) => t.id === taskId) ?? null);
|
||||
setStats(statsResp);
|
||||
@@ -133,20 +213,35 @@ export function OverviewTab({ taskId }: { taskId: string }) {
|
||||
};
|
||||
|
||||
load();
|
||||
const timer = setInterval(load, 3000);
|
||||
void loadScope();
|
||||
void loadTokens();
|
||||
const timer = setInterval(() => {
|
||||
void load();
|
||||
void loadTokens();
|
||||
}, 3000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [taskId]);
|
||||
}, [taskId, loadScope, loadTokens]);
|
||||
|
||||
const running = intents.filter((i) => i.state === "running");
|
||||
const open = intents.filter((i) => i.state === "open");
|
||||
const blocked = intents.filter((i) => i.state === "blocked");
|
||||
const taskFindings = findings.filter((f) => f.task_id === taskId);
|
||||
const goalsPct = task?.goals_total
|
||||
? Math.round(((task.goals_met ?? 0) / task.goals_total) * 100)
|
||||
: 0;
|
||||
const goalsPct = task?.goals_total ? Math.round(((task.goals_met ?? 0) / task.goals_total) * 100) : 0;
|
||||
// token 合计(跨全部模型),用于卡片头部总览。
|
||||
const tokenTotals = modelTokens.reduce(
|
||||
(acc, m) => {
|
||||
acc.input += m.input_tokens;
|
||||
acc.output += m.output_tokens;
|
||||
acc.cacheRead += m.cache_read_tokens;
|
||||
acc.cacheWrite += m.cache_write_tokens;
|
||||
acc.calls += m.calls;
|
||||
return acc;
|
||||
},
|
||||
{ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, calls: 0 },
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
@@ -160,15 +255,11 @@ export function OverviewTab({ taskId }: { taskId: string }) {
|
||||
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="text-xs font-medium text-muted-foreground">描述</div>
|
||||
<p className="text-sm whitespace-pre-wrap break-words">
|
||||
{task?.description?.trim() || "—"}
|
||||
</p>
|
||||
<p className="text-sm whitespace-pre-wrap break-words">{task?.description?.trim() || "—"}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="text-xs font-medium text-muted-foreground">目标</div>
|
||||
<p className="text-sm whitespace-pre-wrap break-words">
|
||||
{task?.goal?.trim() || "—"}
|
||||
</p>
|
||||
<p className="text-sm whitespace-pre-wrap break-words">{task?.goal?.trim() || "—"}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -205,6 +296,157 @@ export function OverviewTab({ taskId }: { taskId: string }) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{/* LLM Token 用量:按模型分组,数据来自 llm_records(需开启 LLM 录制)。 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<CoinsIcon className="size-4 text-amber-500" /> LLM Token 用量
|
||||
<span className="text-muted-foreground text-xs font-normal">
|
||||
(按模型统计{tokenTotals.calls > 0 ? `,共 ${tokenTotals.calls} 次调用` : ""})
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
{modelTokens.length > 0 ? (
|
||||
<>
|
||||
{/* 合计总览 */}
|
||||
<div className="flex flex-wrap items-baseline gap-x-4 gap-y-1 text-sm">
|
||||
<span className="tabular-nums">
|
||||
<span className="text-muted-foreground">输入 </span>
|
||||
<span className="font-semibold">{fmtTokens(tokenTotals.input)}</span>
|
||||
</span>
|
||||
<span className="tabular-nums">
|
||||
<span className="text-muted-foreground">输出 </span>
|
||||
<span className="font-semibold">{fmtTokens(tokenTotals.output)}</span>
|
||||
</span>
|
||||
<span className="tabular-nums">
|
||||
<span className="text-muted-foreground">缓存读 </span>
|
||||
<span className="font-semibold">{fmtTokens(tokenTotals.cacheRead)}</span>
|
||||
</span>
|
||||
<span className="tabular-nums">
|
||||
<span className="text-muted-foreground">缓存命中率 </span>
|
||||
<span className="font-semibold text-emerald-500">
|
||||
{cacheHitRate(tokenTotals.cacheRead, tokenTotals.input)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
{/* 按模型明细表 */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground border-b text-left text-xs">
|
||||
<th className="py-1.5 pr-3 font-medium">模型</th>
|
||||
<th className="py-1.5 pr-3 text-right font-medium">调用</th>
|
||||
<th className="py-1.5 pr-3 text-right font-medium">输入</th>
|
||||
<th className="py-1.5 pr-3 text-right font-medium">输出</th>
|
||||
<th className="py-1.5 pr-3 text-right font-medium">缓存读</th>
|
||||
<th className="py-1.5 text-right font-medium">命中率</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{modelTokens.map((m) => (
|
||||
<tr key={m.model} className="border-b last:border-0">
|
||||
<td className="max-w-[16rem] truncate py-1.5 pr-3 font-mono text-xs" title={m.model}>
|
||||
{m.model}
|
||||
</td>
|
||||
<td className="py-1.5 pr-3 text-right tabular-nums">{m.calls}</td>
|
||||
<td className="py-1.5 pr-3 text-right tabular-nums">{fmtTokens(m.input_tokens)}</td>
|
||||
<td className="py-1.5 pr-3 text-right tabular-nums">{fmtTokens(m.output_tokens)}</td>
|
||||
<td className="py-1.5 pr-3 text-right tabular-nums">{fmtTokens(m.cache_read_tokens)}</td>
|
||||
<td className="py-1.5 text-right tabular-nums text-emerald-500">
|
||||
{cacheHitRate(m.cache_read_tokens, m.input_tokens)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">暂无 LLM 用量(任务尚未产生调用,或记录仍在写入)。</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* 测试范围:覆盖度分母 + 授权边界,可手动增删。 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<ShieldCheckIcon className="size-4 text-emerald-500" /> 测试范围
|
||||
<span className="text-muted-foreground text-xs font-normal">
|
||||
(覆盖度分母 + 授权边界,共 {scope.length} 条)
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
{/* 新增表单 */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<NativeSelect
|
||||
size="sm"
|
||||
value={scopeKind}
|
||||
onChange={(e) => setScopeKind(e.target.value as TaskScopeRow["kind"])}
|
||||
>
|
||||
<NativeSelectOption value="root_domain">根域名</NativeSelectOption>
|
||||
<NativeSelectOption value="subdomain">子域名</NativeSelectOption>
|
||||
<NativeSelectOption value="ip">IP</NativeSelectOption>
|
||||
<NativeSelectOption value="cidr">网段</NativeSelectOption>
|
||||
<NativeSelectOption value="company">公司</NativeSelectOption>
|
||||
</NativeSelect>
|
||||
<Input
|
||||
className="h-7 w-56 text-sm"
|
||||
placeholder={
|
||||
scopeKind === "company"
|
||||
? "公司名或 id"
|
||||
: scopeKind === "ip" || scopeKind === "cidr"
|
||||
? "如 10.0.0.1 或 10.0.0.0/24"
|
||||
: "如 example.com"
|
||||
}
|
||||
value={scopeValueInput}
|
||||
onChange={(e) => setScopeValueInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void addScope();
|
||||
}}
|
||||
disabled={scopeBusy}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={scopeBusy || !scopeValueInput.trim()}
|
||||
onClick={() => void addScope()}
|
||||
>
|
||||
<PlusIcon className="size-3.5" /> 添加
|
||||
</Button>
|
||||
{scopeErr && <span className="text-xs text-red-500">{scopeErr}</span>}
|
||||
</div>
|
||||
{/* 范围列表 */}
|
||||
{scope.length > 0 ? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{scope.map((row) => (
|
||||
<div key={row.id} className="flex items-center gap-2 rounded-md border px-2.5 py-1.5 text-sm">
|
||||
<span className="bg-muted text-muted-foreground shrink-0 rounded px-1.5 py-0.5 text-xs">
|
||||
{SCOPE_KIND_LABELS[row.kind]}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-xs">{scopeValue(row)}</span>
|
||||
<span className="text-muted-foreground shrink-0 text-xs">{SCOPE_SOURCE_LABELS[row.source]}</span>
|
||||
{row.task_id.toString() === taskId ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 shrink-0 px-1.5"
|
||||
onClick={() => void removeScope(row)}
|
||||
>
|
||||
<Trash2Icon className="size-3.5 text-red-500" />
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-muted-foreground shrink-0 text-xs">继承</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">暂无测试范围,添加后可作为资产覆盖度的分母。</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Heartbeat */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -224,17 +466,13 @@ export function OverviewTab({ taskId }: { taskId: string }) {
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">运行中 Worker</div>
|
||||
<div className="mt-1 text-lg font-semibold tabular-nums">
|
||||
{running.length}
|
||||
</div>
|
||||
<div className="mt-1 text-lg font-semibold tabular-nums">{running.length}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">最近活动</div>
|
||||
<div className="mt-1 inline-flex items-center gap-1 text-sm">
|
||||
<ClockIcon className="size-3.5" />
|
||||
{task?.last_activity
|
||||
? new Date(task.last_activity).toLocaleTimeString("zh-CN")
|
||||
: "—"}
|
||||
{task?.last_activity ? new Date(task.last_activity).toLocaleTimeString("zh-CN") : "—"}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
@@ -270,9 +508,7 @@ export function OverviewTab({ taskId }: { taskId: string }) {
|
||||
<span className="min-w-0 flex-1 truncate">{i.payload}</span>
|
||||
</div>
|
||||
))}
|
||||
{running.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">暂无进行中意图</p>
|
||||
)}
|
||||
{running.length === 0 && <p className="text-sm text-muted-foreground">暂无进行中意图</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -284,27 +520,19 @@ export function OverviewTab({ taskId }: { taskId: string }) {
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<div className="text-2xl font-semibold tabular-nums text-red-600">
|
||||
{taskFindings.length}
|
||||
</div>
|
||||
<div className="text-2xl font-semibold tabular-nums text-red-600">{taskFindings.length}</div>
|
||||
<div className="text-xs text-muted-foreground">确认漏洞</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-semibold tabular-nums text-blue-600">
|
||||
{running.length}
|
||||
</div>
|
||||
<div className="text-2xl font-semibold tabular-nums text-blue-600">{running.length}</div>
|
||||
<div className="text-xs text-muted-foreground">执行中</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-semibold tabular-nums">
|
||||
{open.length}
|
||||
</div>
|
||||
<div className="text-2xl font-semibold tabular-nums">{open.length}</div>
|
||||
<div className="text-xs text-muted-foreground">frontier 待领</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-semibold tabular-nums text-red-600">
|
||||
{blocked.length}
|
||||
</div>
|
||||
<div className="text-2xl font-semibold tabular-nums text-red-600">{blocked.length}</div>
|
||||
<div className="text-xs text-muted-foreground">被拦意图</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -323,9 +551,7 @@ export function OverviewTab({ taskId }: { taskId: string }) {
|
||||
<span className="min-w-0 flex-1 truncate">{f.summary}</span>
|
||||
</div>
|
||||
))}
|
||||
{taskFindings.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">暂无发现</p>
|
||||
)}
|
||||
{taskFindings.length === 0 && <p className="text-sm text-muted-foreground">暂无发现</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -337,19 +563,10 @@ export function OverviewTab({ taskId }: { taskId: string }) {
|
||||
<CardHeader className="flex-row items-center justify-between gap-2 space-y-0">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<AlertTriangleIcon className="size-4 text-red-500" /> 被拦/出错意图
|
||||
<span className="text-xs font-normal text-muted-foreground">
|
||||
(共 {blocked.length} 条,可重跑)
|
||||
</span>
|
||||
<span className="text-xs font-normal text-muted-foreground">(共 {blocked.length} 条,可重跑)</span>
|
||||
</CardTitle>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={rerunning.has("__all__")}
|
||||
onClick={() => void rerunAll()}
|
||||
>
|
||||
<RefreshCwIcon
|
||||
className={`size-3.5 ${rerunning.has("__all__") ? "animate-spin" : ""}`}
|
||||
/>
|
||||
<Button size="sm" variant="outline" disabled={rerunning.has("__all__")} onClick={() => void rerunAll()}>
|
||||
<RefreshCwIcon className={`size-3.5 ${rerunning.has("__all__") ? "animate-spin" : ""}`} />
|
||||
全部重跑
|
||||
</Button>
|
||||
</CardHeader>
|
||||
@@ -365,9 +582,7 @@ export function OverviewTab({ taskId }: { taskId: string }) {
|
||||
disabled={rerunning.has(i.id)}
|
||||
onClick={() => void rerunOne(i.id)}
|
||||
>
|
||||
<RefreshCwIcon
|
||||
className={`size-3 ${rerunning.has(i.id) ? "animate-spin" : ""}`}
|
||||
/>
|
||||
<RefreshCwIcon className={`size-3 ${rerunning.has(i.id) ? "animate-spin" : ""}`} />
|
||||
重跑
|
||||
</Button>
|
||||
</div>
|
||||
@@ -383,24 +598,9 @@ export function OverviewTab({ taskId }: { taskId: string }) {
|
||||
|
||||
{/* Stat cards */}
|
||||
<div className="grid grid-cols-2 gap-4 lg:grid-cols-3">
|
||||
<StatCard
|
||||
label="待领意图"
|
||||
value={open.length}
|
||||
icon={ShieldCheckIcon}
|
||||
sub="frontier 开放"
|
||||
/>
|
||||
<StatCard
|
||||
label="确认发现"
|
||||
value={taskFindings.length}
|
||||
icon={BugIcon}
|
||||
sub="本任务"
|
||||
/>
|
||||
<StatCard
|
||||
label="意图总数"
|
||||
value={intents.length}
|
||||
icon={AlertTriangleIcon}
|
||||
sub="本任务全部意图"
|
||||
/>
|
||||
<StatCard label="待领意图" value={open.length} icon={ShieldCheckIcon} sub="frontier 开放" />
|
||||
<StatCard label="确认发现" value={taskFindings.length} icon={BugIcon} sub="本任务" />
|
||||
<StatCard label="意图总数" value={intents.length} icon={AlertTriangleIcon} sub="本任务全部意图" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Markdown } from "@/components/markdown";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { api } from "@/lib/api";
|
||||
import { copyText } from "@/lib/utils";
|
||||
|
||||
export function ReportTab({ taskId }: { taskId: string }) {
|
||||
const [report, setReport] = React.useState<string>("");
|
||||
@@ -34,12 +35,16 @@ export function ReportTab({ taskId }: { taskId: string }) {
|
||||
};
|
||||
}, [taskId]);
|
||||
|
||||
function copy() {
|
||||
async function copy() {
|
||||
if (!report) return;
|
||||
navigator.clipboard?.writeText(report);
|
||||
setCopied(true);
|
||||
toast.success("已复制 Markdown");
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
const ok = await copyText(report);
|
||||
if (ok) {
|
||||
setCopied(true);
|
||||
toast.success("已复制 Markdown");
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} else {
|
||||
toast.error("复制失败,请手动选择文本复制");
|
||||
}
|
||||
}
|
||||
|
||||
let content: React.ReactNode;
|
||||
|
||||
@@ -359,8 +359,6 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
const [olderIntents, setOlderIntents] = React.useState<TaskNode[]>([]);
|
||||
const [intentsHasMore, setIntentsHasMore] = React.useState(false);
|
||||
const [loadingOlderIntents, setLoadingOlderIntents] = React.useState(false);
|
||||
// Local-only chat messages overlaid onto the main-agent transcript.
|
||||
const [chatExtra, setChatExtra] = React.useState<Activity[]>([]);
|
||||
const [input, setInput] = React.useState("");
|
||||
const [sending, setSending] = React.useState(false);
|
||||
const [stopping, setStopping] = React.useState(false);
|
||||
@@ -613,7 +611,6 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
// ID) resumes from the DB, so a dropped realtime link self-heals; seq-merge dedups.
|
||||
React.useEffect(() => {
|
||||
setStore({});
|
||||
setChatExtra([]);
|
||||
setSseLive(false);
|
||||
setActiveId(MAIN_ID); // a stale worker id from the previous task must not leak in
|
||||
snapshotRef.current = 0;
|
||||
@@ -892,13 +889,11 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
// worker session shows only its intent's activity, led by the intent objective.
|
||||
const activity = React.useMemo(() => {
|
||||
const items = activeState?.items ?? [];
|
||||
if (isMain) {
|
||||
// chatExtra is an optimistic local echo — drop any entry the persisted (SSE)
|
||||
// copy already covers so messages don't show twice after the backend round-trips.
|
||||
const seen = new Set(items.map((a) => `${a.kind} ${a.summary}`));
|
||||
const pending = chatExtra.filter((a) => !seen.has(`${a.kind} ${a.summary}`));
|
||||
return [...items, ...pending].sort((a, b) => a.seq - b.seq);
|
||||
}
|
||||
// The main-agent console renders purely from server data: the human turn is
|
||||
// persisted+broadcast by the backend BEFORE it returns, so it arrives over the
|
||||
// same SSE stream (worker="mainagent") as every agent step — no client-side
|
||||
// optimistic echo, hence no fabricated seq that could collide with real DB ids.
|
||||
if (isMain) return items;
|
||||
if (isPlanner || isSystem) return items;
|
||||
// Worker session: the intent leads the transcript as a right-aligned "user"-style
|
||||
// message (the task handed to this worker), followed by its execution steps.
|
||||
@@ -918,7 +913,6 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
isPlanner,
|
||||
isSystem,
|
||||
activeState,
|
||||
chatExtra,
|
||||
active.id,
|
||||
active.title,
|
||||
active.last_activity,
|
||||
@@ -1010,6 +1004,26 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
const vp = viewport();
|
||||
if (vp) vp.scrollTop = vp.scrollHeight;
|
||||
}, [activity, viewport]);
|
||||
// Lazy detail loads (AnswerBlock / ToolBlock / Markdown) grow the content AFTER the
|
||||
// activity array settles, WITHOUT changing its reference — so the layout effects
|
||||
// above never re-fire and a freshly opened session would leave its last message
|
||||
// scrolled partly off-screen (the final answer expands from a one-line summary to
|
||||
// full markdown below the fold). A ResizeObserver re-pins to the bottom on any
|
||||
// height change while the user is still at the bottom, so opening the main agent
|
||||
// lands on the last message fully shown. contentRef's div is always mounted, so the
|
||||
// observer catches the transcript mounting + each detail expanding.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: activeId intentionally rebinds the observer to the new session's content.
|
||||
React.useEffect(() => {
|
||||
const el = contentRef.current;
|
||||
if (!el || typeof ResizeObserver === "undefined") return;
|
||||
const ro = new ResizeObserver(() => {
|
||||
if (!atBottomRef.current) return;
|
||||
const vp = viewport();
|
||||
if (vp) vp.scrollTop = vp.scrollHeight;
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [activeId, viewport]);
|
||||
|
||||
function stop() {
|
||||
if (stopping) return;
|
||||
@@ -1021,36 +1035,19 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
const text = input.trim();
|
||||
const atts = attachments;
|
||||
if ((!text && atts.length === 0) || sending) return;
|
||||
const base = [...(store.main?.items ?? []), ...chatExtra];
|
||||
const nextSeq = Math.max(0, ...base.map((a) => a.seq)) + 1;
|
||||
const userMsg: Activity = {
|
||||
seq: nextSeq,
|
||||
worker: "mainagent",
|
||||
ts: new Date().toISOString(),
|
||||
kind: "user",
|
||||
summary: text,
|
||||
// 有附件时把 {text, attachments} 塞进 detail,乐观气泡直接渲染卡片,无需再拉取。
|
||||
detail: atts.length > 0 ? JSON.stringify({ text, attachments: atts }) : undefined,
|
||||
};
|
||||
// optimistic echo of the human turn only; the agent's steps + final answer
|
||||
// stream back live via SSE (worker="mainagent"), so we don't append the reply.
|
||||
setChatExtra((prev) => [...prev, userMsg]);
|
||||
// No optimistic echo: the backend persists+broadcasts the human turn before it
|
||||
// returns, so it streams back over SSE (worker="mainagent") with its real DB
|
||||
// seq — the transcript renders it from server data like every other step. Clear
|
||||
// the composer eagerly for responsiveness; restore it if the send fails.
|
||||
setInput("");
|
||||
setAttachments([]);
|
||||
setSending(true);
|
||||
api
|
||||
.chat(text, taskId, atts.length > 0 ? atts : undefined)
|
||||
.catch(() => {
|
||||
setChatExtra((prev) => [
|
||||
...prev,
|
||||
{
|
||||
seq: nextSeq + 1,
|
||||
worker: "mainagent",
|
||||
ts: new Date().toISOString(),
|
||||
kind: "text",
|
||||
summary: "(发送失败,请稍后重试)",
|
||||
},
|
||||
]);
|
||||
.catch((e) => {
|
||||
setInput(text); // restore so the user doesn't lose their text / attachments
|
||||
setAttachments(atts);
|
||||
toast.error("发送失败:" + ((e as Error).message || "请稍后重试"));
|
||||
})
|
||||
.finally(() => setSending(false));
|
||||
}
|
||||
|
||||
@@ -172,7 +172,11 @@ function taskDuration(task: Task, nowSec: number): number {
|
||||
return end > start ? end - start : 0;
|
||||
}
|
||||
|
||||
function deleteSummary(result: DeleteTaskResult): string {
|
||||
// deleteDetails takes only the countable part of the result so callers can pass an
|
||||
// aggregate accumulated over a bulk delete.
|
||||
type DeleteCounts = Omit<DeleteTaskResult, "deleted" | "cleanup_warning">;
|
||||
|
||||
function deleteDetails(result: DeleteCounts): string[] {
|
||||
const details: string[] = [];
|
||||
if (result.assets_deleted > 0) details.push(`删除资产 ${result.assets_deleted} 条`);
|
||||
if (result.assets_detached > 0) details.push(`解除共享资产关联 ${result.assets_detached} 条`);
|
||||
@@ -180,6 +184,11 @@ function deleteSummary(result: DeleteTaskResult): string {
|
||||
if (result.files_deleted) details.push("删除任务文件");
|
||||
if (result.findings_deleted > 0) details.push(`删除漏洞 ${result.findings_deleted} 条`);
|
||||
if (result.llm_records_deleted > 0) details.push(`删除 LLM 请求/响应记录 ${result.llm_records_deleted} 条`);
|
||||
return details;
|
||||
}
|
||||
|
||||
function deleteSummary(result: DeleteTaskResult): string {
|
||||
const details = deleteDetails(result);
|
||||
return details.length > 0 ? `任务已删除(${details.join(",")})` : "任务已删除";
|
||||
}
|
||||
|
||||
@@ -232,6 +241,48 @@ export default function TasksPage() {
|
||||
[filtered, page, pageSize],
|
||||
);
|
||||
|
||||
// 多选删除:选择跨翻页/筛选保留,只在任务真的消失(被删或后端不再返回)时收敛。
|
||||
const [selectedIds, setSelectedIds] = React.useState<Set<string>>(() => new Set());
|
||||
|
||||
React.useEffect(() => {
|
||||
setSelectedIds((prev) => {
|
||||
if (prev.size === 0) return prev;
|
||||
const live = new Set(tasks.map((t) => t.id));
|
||||
const next = new Set([...prev].filter((id) => live.has(id)));
|
||||
return next.size === prev.size ? prev : next;
|
||||
});
|
||||
}, [tasks]);
|
||||
|
||||
const toggleSelected = React.useCallback((id: string, checked: boolean) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (checked) next.add(id); else next.delete(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const pageIds = React.useMemo(() => paginated.map((t) => t.id), [paginated]);
|
||||
const pageSelectedCount = React.useMemo(
|
||||
() => pageIds.filter((id) => selectedIds.has(id)).length,
|
||||
[pageIds, selectedIds],
|
||||
);
|
||||
let headerChecked: boolean | "indeterminate" = false;
|
||||
if (pageIds.length > 0 && pageSelectedCount === pageIds.length) {
|
||||
headerChecked = true;
|
||||
} else if (pageSelectedCount > 0) {
|
||||
headerChecked = "indeterminate";
|
||||
}
|
||||
|
||||
const toggleSelectedPage = React.useCallback((checked: boolean) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const id of pageIds) {
|
||||
if (checked) next.add(id); else next.delete(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [pageIds]);
|
||||
|
||||
// lastRef holds the previous poll's serialized payload: the list is re-fetched every
|
||||
// POLL_MS but usually comes back unchanged, and setTasks on an identical payload would
|
||||
// re-render the whole page for nothing. Bail out when it matches.
|
||||
@@ -284,6 +335,63 @@ export default function TasksPage() {
|
||||
}
|
||||
}, [load]);
|
||||
|
||||
// deleteTasks 逐个删除所选任务:后端没有批量接口,且单次删除会连带清理资产/流量/文件,
|
||||
// 串行执行以免一次性打爆后端;成功的从选中集移除,失败的保留以便重试。
|
||||
const deleteTasks = React.useCallback(async (
|
||||
ids: string[],
|
||||
options: DeleteTaskOptions,
|
||||
onProgress: (done: number) => void,
|
||||
) => {
|
||||
const total: DeleteCounts = {
|
||||
assets_deleted: 0,
|
||||
assets_detached: 0,
|
||||
traffic_deleted: 0,
|
||||
files_deleted: false,
|
||||
findings_deleted: 0,
|
||||
llm_records_deleted: 0,
|
||||
};
|
||||
const deleted: string[] = [];
|
||||
const failed: { id: string; message: string }[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
for (const id of ids) {
|
||||
try {
|
||||
const r = await api.deleteTask(id, options);
|
||||
total.assets_deleted += r.assets_deleted;
|
||||
total.assets_detached += r.assets_detached;
|
||||
total.traffic_deleted += r.traffic_deleted;
|
||||
total.files_deleted = total.files_deleted || r.files_deleted;
|
||||
total.findings_deleted += r.findings_deleted;
|
||||
total.llm_records_deleted += r.llm_records_deleted;
|
||||
if (r.cleanup_warning) warnings.push(`#${id}:${r.cleanup_warning}`);
|
||||
deleted.push(id);
|
||||
} catch (e) {
|
||||
failed.push({ id, message: (e as Error).message });
|
||||
}
|
||||
onProgress(deleted.length + failed.length);
|
||||
}
|
||||
|
||||
if (deleted.length > 0) {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const id of deleted) next.delete(id);
|
||||
return next;
|
||||
});
|
||||
const details = deleteDetails(total);
|
||||
const summary = `已删除 ${deleted.length} 个任务` + (details.length > 0 ? `(${details.join(",")})` : "");
|
||||
if (warnings.length > 0) {
|
||||
toast.warning(`${summary};部分外部数据清理未完成:${warnings.join(";")}`);
|
||||
} else {
|
||||
toast.success(summary);
|
||||
}
|
||||
}
|
||||
if (failed.length > 0) {
|
||||
const head = failed.slice(0, 3).map((f) => `#${f.id}(${f.message})`).join(";");
|
||||
toast.error(`${failed.length} 个任务删除失败:${head}${failed.length > 3 ? " 等" : ""}`);
|
||||
}
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col gap-4 px-0 pt-6">
|
||||
@@ -325,6 +433,15 @@ export default function TasksPage() {
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{filtered.length}/{tasks.length} 条
|
||||
</span>
|
||||
{selectedIds.size > 0 && (
|
||||
<>
|
||||
<span className="text-xs tabular-nums">已选 {selectedIds.size} 个</span>
|
||||
<Button size="sm" variant="ghost" onClick={() => setSelectedIds(new Set())}>
|
||||
取消选择
|
||||
</Button>
|
||||
<BulkDeleteTasksDialog ids={[...selectedIds]} onDelete={deleteTasks} />
|
||||
</>
|
||||
)}
|
||||
<ConcurrencySettingsDialog />
|
||||
<CreateTaskSheet tasks={tasks} onCreated={load} />
|
||||
</div>
|
||||
@@ -341,6 +458,13 @@ export default function TasksPage() {
|
||||
<Table className="**:data-[slot='table-cell']:px-4 **:data-[slot='table-head']:px-4">
|
||||
<TableHeader className="[&_tr]:border-t">
|
||||
<TableRow>
|
||||
<TableHead className="w-10">
|
||||
<Checkbox
|
||||
checked={headerChecked}
|
||||
onCheckedChange={(checked) => toggleSelectedPage(checked === true)}
|
||||
aria-label="选择本页全部任务"
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="font-mono">ID</TableHead>
|
||||
<TableHead>描述</TableHead>
|
||||
<TableHead>目标</TableHead>
|
||||
@@ -361,6 +485,8 @@ export default function TasksPage() {
|
||||
// 带来的整表重渲染,只让在跑的那几行走时长。
|
||||
nowSec={task.status === "running" ? nowSec : 0}
|
||||
onDelete={deleteTask}
|
||||
selected={selectedIds.has(task.id)}
|
||||
onSelectedChange={toggleSelected}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -461,13 +587,24 @@ const TaskRow = React.memo(function TaskRow({
|
||||
task,
|
||||
nowSec,
|
||||
onDelete,
|
||||
selected,
|
||||
onSelectedChange,
|
||||
}: {
|
||||
task: Task;
|
||||
nowSec: number;
|
||||
onDelete: (id: string, options: DeleteTaskOptions) => Promise<void>;
|
||||
selected: boolean;
|
||||
onSelectedChange: (id: string, checked: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<TableRow className="group border-border/60">
|
||||
<TableRow className="group border-border/60" data-state={selected ? "selected" : undefined}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selected}
|
||||
onCheckedChange={(checked) => onSelectedChange(task.id, checked === true)}
|
||||
aria-label={`选择任务 ${task.id}`}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="bg-muted rounded px-1.5 py-0.5 font-mono text-xs">{task.id}</code>
|
||||
</TableCell>
|
||||
@@ -554,6 +691,117 @@ const deleteOptionKeys: (keyof DeleteTaskOptions)[] = [
|
||||
"delete_llm_records",
|
||||
];
|
||||
|
||||
// DeleteOptionFields renders the「同时清理关联数据」checkbox block shared by the
|
||||
// single-task and bulk delete dialogs. idPrefix keeps the label/input ids unique when
|
||||
// several dialogs live in the same table.
|
||||
function DeleteOptionFields({
|
||||
idPrefix,
|
||||
options,
|
||||
onOptionsChange,
|
||||
disabled,
|
||||
}: {
|
||||
idPrefix: string;
|
||||
options: DeleteTaskOptions;
|
||||
onOptionsChange: React.Dispatch<React.SetStateAction<DeleteTaskOptions>>;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const selectedCount = deleteOptionKeys.filter((key) => options[key]).length;
|
||||
let allChecked: boolean | "indeterminate" = false;
|
||||
if (selectedCount === deleteOptionKeys.length) {
|
||||
allChecked = true;
|
||||
} else if (selectedCount > 0) {
|
||||
allChecked = "indeterminate";
|
||||
}
|
||||
|
||||
const updateOption = (key: keyof DeleteTaskOptions, checked: boolean) => {
|
||||
onOptionsChange((current) => ({ ...current, [key]: checked }));
|
||||
};
|
||||
|
||||
const updateAllOptions = (checked: boolean) => {
|
||||
onOptionsChange({
|
||||
delete_assets: checked,
|
||||
delete_traffic: checked,
|
||||
delete_files: checked,
|
||||
delete_findings: checked,
|
||||
delete_llm_records: checked,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<FieldSet disabled={disabled}>
|
||||
<FieldLegend variant="label">同时清理关联数据</FieldLegend>
|
||||
<FieldGroup className="gap-3">
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox
|
||||
id={`delete-all-${idPrefix}`}
|
||||
checked={allChecked}
|
||||
onCheckedChange={(checked) => updateAllOptions(checked === true)}
|
||||
/>
|
||||
<FieldContent>
|
||||
<FieldLabel htmlFor={`delete-all-${idPrefix}`}>全部删除</FieldLabel>
|
||||
<FieldDescription>选中下方全部关联数据,包括资产、流量、文件、漏洞和 LLM 请求/响应记录。</FieldDescription>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox
|
||||
id={`delete-assets-${idPrefix}`}
|
||||
checked={options.delete_assets}
|
||||
onCheckedChange={(checked) => updateOption("delete_assets", checked === true)}
|
||||
/>
|
||||
<FieldContent>
|
||||
<FieldLabel htmlFor={`delete-assets-${idPrefix}`}>关联资产</FieldLabel>
|
||||
<FieldDescription>删除仅属于该任务的资产;共享资产只解除当前任务关联。</FieldDescription>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox
|
||||
id={`delete-traffic-${idPrefix}`}
|
||||
checked={options.delete_traffic}
|
||||
onCheckedChange={(checked) => updateOption("delete_traffic", checked === true)}
|
||||
/>
|
||||
<FieldContent>
|
||||
<FieldLabel htmlFor={`delete-traffic-${idPrefix}`}>关联流量</FieldLabel>
|
||||
<FieldDescription>按关联资产的精确主机名删除;仍被其他任务引用的共享主机流量会保留。</FieldDescription>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox
|
||||
id={`delete-files-${idPrefix}`}
|
||||
checked={options.delete_files}
|
||||
onCheckedChange={(checked) => updateOption("delete_files", checked === true)}
|
||||
/>
|
||||
<FieldContent>
|
||||
<FieldLabel htmlFor={`delete-files-${idPrefix}`}>任务文件</FieldLabel>
|
||||
<FieldDescription>删除该任务工作目录中的上传文件、命令输出和其他产物。</FieldDescription>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox
|
||||
id={`delete-findings-${idPrefix}`}
|
||||
checked={options.delete_findings}
|
||||
onCheckedChange={(checked) => updateOption("delete_findings", checked === true)}
|
||||
/>
|
||||
<FieldContent>
|
||||
<FieldLabel htmlFor={`delete-findings-${idPrefix}`}>关联漏洞</FieldLabel>
|
||||
<FieldDescription>永久删除该任务产生的独立漏洞记录与漏洞报告。</FieldDescription>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox
|
||||
id={`delete-llm-records-${idPrefix}`}
|
||||
checked={options.delete_llm_records}
|
||||
onCheckedChange={(checked) => updateOption("delete_llm_records", checked === true)}
|
||||
/>
|
||||
<FieldContent>
|
||||
<FieldLabel htmlFor={`delete-llm-records-${idPrefix}`}>LLM 请求/响应记录</FieldLabel>
|
||||
<FieldDescription>永久删除该任务录制的 LLM 请求、响应、Token 与错误详情。</FieldDescription>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</FieldSet>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteTaskDialog({
|
||||
task,
|
||||
onDelete,
|
||||
@@ -565,29 +813,6 @@ function DeleteTaskDialog({
|
||||
const [deleting, setDeleting] = React.useState(false);
|
||||
const [options, setOptions] = React.useState<DeleteTaskOptions>(emptyDeleteOptions);
|
||||
|
||||
const selectedCount = deleteOptionKeys.filter((key) => options[key]).length;
|
||||
const allSelected = selectedCount === deleteOptionKeys.length;
|
||||
let allChecked: boolean | "indeterminate" = false;
|
||||
if (allSelected) {
|
||||
allChecked = true;
|
||||
} else if (selectedCount > 0) {
|
||||
allChecked = "indeterminate";
|
||||
}
|
||||
|
||||
const updateOption = (key: keyof DeleteTaskOptions, checked: boolean) => {
|
||||
setOptions((current) => ({ ...current, [key]: checked }));
|
||||
};
|
||||
|
||||
const updateAllOptions = (checked: boolean) => {
|
||||
setOptions({
|
||||
delete_assets: checked,
|
||||
delete_traffic: checked,
|
||||
delete_files: checked,
|
||||
delete_findings: checked,
|
||||
delete_llm_records: checked,
|
||||
});
|
||||
};
|
||||
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
if (deleting) return;
|
||||
setOpen(next);
|
||||
@@ -621,77 +846,12 @@ function DeleteTaskDialog({
|
||||
的执行记录与探索链路将被永久删除。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<FieldSet disabled={deleting}>
|
||||
<FieldLegend variant="label">同时清理关联数据</FieldLegend>
|
||||
<FieldGroup className="gap-3">
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox
|
||||
id={`delete-all-${task.id}`}
|
||||
checked={allChecked}
|
||||
onCheckedChange={(checked) => updateAllOptions(checked === true)}
|
||||
/>
|
||||
<FieldContent>
|
||||
<FieldLabel htmlFor={`delete-all-${task.id}`}>全部删除</FieldLabel>
|
||||
<FieldDescription>选中下方全部关联数据,包括资产、流量、文件、漏洞和 LLM 请求/响应记录。</FieldDescription>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox
|
||||
id={`delete-assets-${task.id}`}
|
||||
checked={options.delete_assets}
|
||||
onCheckedChange={(checked) => updateOption("delete_assets", checked === true)}
|
||||
/>
|
||||
<FieldContent>
|
||||
<FieldLabel htmlFor={`delete-assets-${task.id}`}>关联资产</FieldLabel>
|
||||
<FieldDescription>删除仅属于该任务的资产;共享资产只解除当前任务关联。</FieldDescription>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox
|
||||
id={`delete-traffic-${task.id}`}
|
||||
checked={options.delete_traffic}
|
||||
onCheckedChange={(checked) => updateOption("delete_traffic", checked === true)}
|
||||
/>
|
||||
<FieldContent>
|
||||
<FieldLabel htmlFor={`delete-traffic-${task.id}`}>关联流量</FieldLabel>
|
||||
<FieldDescription>按关联资产的精确主机名删除;仍被其他任务引用的共享主机流量会保留。</FieldDescription>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox
|
||||
id={`delete-files-${task.id}`}
|
||||
checked={options.delete_files}
|
||||
onCheckedChange={(checked) => updateOption("delete_files", checked === true)}
|
||||
/>
|
||||
<FieldContent>
|
||||
<FieldLabel htmlFor={`delete-files-${task.id}`}>任务文件</FieldLabel>
|
||||
<FieldDescription>删除该任务工作目录中的上传文件、命令输出和其他产物。</FieldDescription>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox
|
||||
id={`delete-findings-${task.id}`}
|
||||
checked={options.delete_findings}
|
||||
onCheckedChange={(checked) => updateOption("delete_findings", checked === true)}
|
||||
/>
|
||||
<FieldContent>
|
||||
<FieldLabel htmlFor={`delete-findings-${task.id}`}>关联漏洞</FieldLabel>
|
||||
<FieldDescription>永久删除该任务产生的独立漏洞记录与漏洞报告。</FieldDescription>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox
|
||||
id={`delete-llm-records-${task.id}`}
|
||||
checked={options.delete_llm_records}
|
||||
onCheckedChange={(checked) => updateOption("delete_llm_records", checked === true)}
|
||||
/>
|
||||
<FieldContent>
|
||||
<FieldLabel htmlFor={`delete-llm-records-${task.id}`}>LLM 请求/响应记录</FieldLabel>
|
||||
<FieldDescription>永久删除该任务录制的 LLM 请求、响应、Token 与错误详情。</FieldDescription>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</FieldSet>
|
||||
<DeleteOptionFields
|
||||
idPrefix={task.id}
|
||||
options={options}
|
||||
onOptionsChange={setOptions}
|
||||
disabled={deleting}
|
||||
/>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleting}>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
@@ -711,6 +871,90 @@ function DeleteTaskDialog({
|
||||
);
|
||||
}
|
||||
|
||||
// BulkDeleteTasksDialog deletes every checked task with one shared set of cleanup options.
|
||||
// The backend has no batch endpoint, so deletion runs one task at a time (see deleteTasks)
|
||||
// and the button shows live progress.
|
||||
function BulkDeleteTasksDialog({
|
||||
ids,
|
||||
onDelete,
|
||||
}: {
|
||||
ids: string[];
|
||||
onDelete: (
|
||||
ids: string[],
|
||||
options: DeleteTaskOptions,
|
||||
onProgress: (done: number) => void,
|
||||
) => Promise<void>;
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [deleting, setDeleting] = React.useState(false);
|
||||
const [done, setDone] = React.useState(0);
|
||||
const [options, setOptions] = React.useState<DeleteTaskOptions>(emptyDeleteOptions);
|
||||
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
if (deleting) return;
|
||||
setOpen(next);
|
||||
if (next) {
|
||||
setOptions(emptyDeleteOptions());
|
||||
setDone(0);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
setDeleting(true);
|
||||
setDone(0);
|
||||
try {
|
||||
await onDelete(ids, options, setDone);
|
||||
setOpen(false);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={handleOpenChange}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button size="sm" variant="outline">
|
||||
<Trash2Icon className="text-destructive" /> 删除所选 {ids.length}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent className="max-h-[85vh] overflow-y-auto">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除所选 {ids.length} 个任务?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
这些任务的执行记录与探索链路将被永久删除,下方清理选项对所选任务统一生效。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<div className="text-muted-foreground flex flex-wrap gap-1 text-xs">
|
||||
{ids.slice(0, 30).map((id) => (
|
||||
<code key={id} className="bg-muted rounded px-1.5 py-0.5 font-mono">#{id}</code>
|
||||
))}
|
||||
{ids.length > 30 && <span className="self-center">…等 {ids.length} 个</span>}
|
||||
</div>
|
||||
<DeleteOptionFields
|
||||
idPrefix="bulk"
|
||||
options={options}
|
||||
onOptionsChange={setOptions}
|
||||
disabled={deleting}
|
||||
/>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleting}>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
disabled={deleting}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
void handleDelete();
|
||||
}}
|
||||
>
|
||||
{deleting && <Spinner data-icon="inline-start" />}
|
||||
{deleting ? `删除中 ${done}/${ids.length}` : `删除 ${ids.length} 个任务`}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
// CreateTaskSheet is the 新建任务 drawer. Its form state lives HERE, not in TasksPage: with
|
||||
// 描述/目标 held by the page component every keystroke re-rendered the whole task table
|
||||
// behind the drawer (plus its sticky column and 20 AlertDialog trees), which showed up as
|
||||
@@ -872,7 +1116,7 @@ function CreateTaskSheet({ tasks, onCreated }: { tasks: Task[]; onCreated: () =>
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button size="sm" className="ml-auto">
|
||||
<Button size="sm">
|
||||
<PlusIcon /> 新建任务
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
|
||||
@@ -29,26 +29,26 @@ import { api } from "@/lib/api";
|
||||
import type { LLMPoolMember, LLMPoolStatus, LLMProfile } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// 思考强度档位(仅在开关打开时生效)。
|
||||
// 思考开关(thinking.type)与思考强度(reasoning_effort)是两个【互相独立】的字段,
|
||||
// 各自单独设置——有些接口没有 thinking 字段、只靠强度参数就能激活思考,故需解耦。
|
||||
// 存库空字符串 = 该字段【不发送】;Radix Select 不接受空 value,故 UI 用 "none"
|
||||
// 哨兵表示不发送,存取时与 "" 互转(NONE / fromStore / toStore)。
|
||||
const NONE = "none";
|
||||
const fromStore = (v?: string) => (v ? v : NONE);
|
||||
const toStore = (v: string) => (v === NONE ? "" : v);
|
||||
const THINKING_TYPES: { value: string; label: string }[] = [
|
||||
{ value: NONE, label: "不发送(默认)" },
|
||||
{ value: "disabled", label: "关闭" },
|
||||
{ value: "enabled", label: "开启" },
|
||||
];
|
||||
const EFFORT_LEVELS: { value: string; label: string }[] = [
|
||||
{ value: "low", label: "低" },
|
||||
{ value: "medium", label: "中" },
|
||||
{ value: "high", label: "高" },
|
||||
{ value: "max", label: "最大" },
|
||||
{ value: NONE, label: "不发送(默认)" },
|
||||
{ value: "low", label: "low" },
|
||||
{ value: "medium", label: "medium" },
|
||||
{ value: "high", label: "high" },
|
||||
{ value: "xhigh", label: "xhigh" },
|
||||
{ value: "max", label: "max" },
|
||||
];
|
||||
// 思考模式三态 ↔ 存库字符串 reasoning_effort:
|
||||
// none→""(thinking / reasoning_effort 两个字段都【不发送】,兼容不支持该字段的模型,如 MiniMax)
|
||||
// off →"off"(发送 thinking:{type:disabled},用于默认开启思考、需要显式关闭的模型)
|
||||
// on →强度值(发送 thinking:{enabled} + reasoning_effort)
|
||||
type ThinkMode = "none" | "off" | "on";
|
||||
const THINK_MODES: { value: ThinkMode; label: string }[] = [
|
||||
{ value: "none", label: "不发送(默认)" },
|
||||
{ value: "off", label: "显式关闭" },
|
||||
{ value: "on", label: "开启" },
|
||||
];
|
||||
const toStore = (mode: ThinkMode, effort: string) => (mode === "on" ? effort : mode === "off" ? "off" : "");
|
||||
const modeFromStore = (v?: string): ThinkMode => (!v ? "none" : v === "off" ? "off" : "on");
|
||||
const effortFromStore = (v?: string) => (v && v !== "off" ? v : "high");
|
||||
|
||||
function cooldownText(secs: number) {
|
||||
if (secs <= 0) return "";
|
||||
@@ -305,8 +305,8 @@ function ProfileSheet({
|
||||
const [rps, setRps] = React.useState("0");
|
||||
const [rpm, setRpm] = React.useState("0");
|
||||
const [cw, setCw] = React.useState("0"); // 上下文窗口(K tokens);0=默认200K
|
||||
const [thinkMode, setThinkMode] = React.useState<ThinkMode>("none");
|
||||
const [effort, setEffort] = React.useState("high");
|
||||
const [thinkingType, setThinkingType] = React.useState(NONE);
|
||||
const [effort, setEffort] = React.useState(NONE);
|
||||
const [priority, setPriority] = React.useState("0"); // 轮询顺位;越大越先
|
||||
const [poolExclude, setPoolExclude] = React.useState(false);
|
||||
const [testing, setTesting] = React.useState(false);
|
||||
@@ -327,8 +327,8 @@ function ProfileSheet({
|
||||
setRps(String(profile?.rate_per_second ?? 0));
|
||||
setRpm(String(profile?.rate_per_minute ?? 0));
|
||||
setCw(String(profile?.context_window_k ?? 0));
|
||||
setThinkMode(modeFromStore(profile?.reasoning_effort));
|
||||
setEffort(effortFromStore(profile?.reasoning_effort));
|
||||
setThinkingType(fromStore(profile?.thinking_type));
|
||||
setEffort(fromStore(profile?.reasoning_effort));
|
||||
setPriority(String(profile?.priority ?? 0));
|
||||
setPoolExclude(profile?.pool_exclude ?? false);
|
||||
setApiKey("");
|
||||
@@ -365,7 +365,7 @@ function ProfileSheet({
|
||||
try {
|
||||
// 用配置实际会跑的思考参数来测,这样不支持该字段的模型在这里就失败,
|
||||
// 而不是等到跑任务时才炸。传 profile id:Key 输入框留空时用已存的 Key。
|
||||
const r = await api.testLLM(format, model, baseUrl, apiKey, proxy, toStore(thinkMode, effort), profileId);
|
||||
const r = await api.testLLM(format, model, baseUrl, apiKey, proxy, toStore(thinkingType), toStore(effort), profileId);
|
||||
if (r.ok) toast.success(`连接成功 · ${r.latency_ms ?? "?"}ms · ${r.model ?? model}`);
|
||||
else toast.error(`连接失败:${r.error ?? "未知"}`);
|
||||
} catch (e) {
|
||||
@@ -394,7 +394,8 @@ function ProfileSheet({
|
||||
rate_per_second: Number(rps) || 0,
|
||||
rate_per_minute: Number(rpm) || 0,
|
||||
context_window_k: Number(cw) || 0,
|
||||
reasoning_effort: toStore(thinkMode, effort),
|
||||
thinking_type: toStore(thinkingType),
|
||||
reasoning_effort: toStore(effort),
|
||||
priority: Number(priority) || 0,
|
||||
pool_exclude: poolExclude,
|
||||
});
|
||||
@@ -601,18 +602,18 @@ function ProfileSheet({
|
||||
<div className="grid gap-3 rounded-lg border p-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="grid gap-0.5">
|
||||
<Label className="text-sm">思考模式 · Extended Thinking</Label>
|
||||
<Label className="text-sm">思考开关 · thinking.type</Label>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
不发送=不带思考字段(兼容 MiniMax 等不支持该字段的模型);显式关闭=发
|
||||
disabled(默认开思考的模型才需要);开启=先推理再作答。
|
||||
控制是否发送 thinking 字段。不发送=不带该字段(兼容 MiniMax 等不支持
|
||||
的模型);关闭=发 disabled;开启=发 enabled。与下面的强度互相独立。
|
||||
</p>
|
||||
</div>
|
||||
<Select value={thinkMode} onValueChange={(v) => setThinkMode(v as ThinkMode)}>
|
||||
<Select value={thinkingType} onValueChange={setThinkingType}>
|
||||
<SelectTrigger className="w-32 shrink-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{THINK_MODES.map((o) => (
|
||||
{THINKING_TYPES.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
@@ -620,28 +621,27 @@ function ProfileSheet({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{thinkMode === "on" && (
|
||||
<div className="flex items-center justify-between gap-4 border-t pt-3">
|
||||
<div className="grid gap-0.5">
|
||||
<Label className="text-sm">思考强度</Label>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
推理投入档位,越高越深入(OpenAI reasoning_effort / Anthropic output_config.effort)。
|
||||
</p>
|
||||
</div>
|
||||
<Select value={effort} onValueChange={setEffort}>
|
||||
<SelectTrigger className="w-28 shrink-0">
|
||||
<SelectValue placeholder="强度" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{EFFORT_LEVELS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-center justify-between gap-4 border-t pt-3">
|
||||
<div className="grid gap-0.5">
|
||||
<Label className="text-sm">思考强度 · reasoning_effort</Label>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
独立的强度档位(OpenAI reasoning_effort / Anthropic output_config.effort)。
|
||||
有些接口没有 thinking 字段、只靠强度即可激活思考,故可单独设置、不发送思考开关。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<Select value={effort} onValueChange={setEffort}>
|
||||
<SelectTrigger className="w-32 shrink-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{EFFORT_LEVELS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -12,13 +12,25 @@ import {
|
||||
PlusIcon,
|
||||
Trash2Icon,
|
||||
UploadIcon,
|
||||
AlertTriangleIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
@@ -31,7 +43,17 @@ import {
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/lib/api";
|
||||
import type { Agent, SkillItem, MCPServer } from "@/lib/types";
|
||||
import type { Agent, SkillItem, MCPServer, SkillCall, MissingSkill } from "@/lib/types";
|
||||
|
||||
function fmtTime(ts?: string) {
|
||||
if (!ts) return "从未调用";
|
||||
return new Date(ts).toLocaleString("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
// ── Tree node ──────────────────────────────────────────────────────────────
|
||||
interface TreeNode {
|
||||
@@ -85,9 +107,22 @@ function buildTree(entries: string[]): TreeNode[] {
|
||||
if (fname) nodes.push({ name: fname, path: entry, type: "file", children: [] });
|
||||
}
|
||||
}
|
||||
sortNodes(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
// sortNodes orders every level like a file explorer: directories before files,
|
||||
// then case-insensitive by name. Recurses into children.
|
||||
function sortNodes(nodes: TreeNode[]): void {
|
||||
nodes.sort((a, b) => {
|
||||
if (a.type !== b.type) return a.type === "dir" ? -1 : 1;
|
||||
return a.name.localeCompare(b.name, undefined, { sensitivity: "base" });
|
||||
});
|
||||
for (const n of nodes) {
|
||||
if (n.children.length > 0) sortNodes(n.children);
|
||||
}
|
||||
}
|
||||
|
||||
// ── State types ───────────────────────────────────────────────────────────
|
||||
type Selected =
|
||||
| { skill: string; path: null }
|
||||
@@ -99,6 +134,164 @@ type Creating = {
|
||||
kind: "file" | "dir";
|
||||
} | null;
|
||||
|
||||
type PendingDelete =
|
||||
| { kind: "skill"; skill: string }
|
||||
| { kind: "file"; skill: string; path: string }
|
||||
| { kind: "dir"; skill: string; path: string }
|
||||
| null;
|
||||
|
||||
// ── Overview (empty-state) ──────────────────────────────────────────────────
|
||||
// Shown when nothing is selected: a library-wide snapshot from data already loaded
|
||||
// (the skill list carries per-skill usage; missing is fetched alongside). No extra
|
||||
// requests — this is pure aggregation over props.
|
||||
function SkillsOverview({
|
||||
skills,
|
||||
missing,
|
||||
onSelect,
|
||||
}: {
|
||||
skills: SkillItem[];
|
||||
missing: MissingSkill[];
|
||||
onSelect: (name: string) => void;
|
||||
}) {
|
||||
const agg = React.useMemo(() => {
|
||||
const totalCalls = skills.reduce((n, s) => n + s.calls, 0);
|
||||
const used = skills.filter((s) => s.calls > 0);
|
||||
const ranked = [...used].sort((a, b) => b.calls - a.calls);
|
||||
const neverUsed = skills.filter((s) => s.calls === 0);
|
||||
const recent = skills
|
||||
.filter((s) => s.last_used)
|
||||
.sort((a, b) => (a.last_used! < b.last_used! ? 1 : -1))
|
||||
.slice(0, 6);
|
||||
const missingCalls = missing.reduce((n, m) => n + m.calls, 0);
|
||||
return {
|
||||
totalCalls,
|
||||
usedCount: used.length,
|
||||
ranked,
|
||||
topCalls: ranked[0]?.calls ?? 0,
|
||||
neverUsed,
|
||||
recent,
|
||||
missingCalls,
|
||||
};
|
||||
}, [skills, missing]);
|
||||
|
||||
if (skills.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">暂无 Skill,点击左侧「新建」或「上传压缩包」开始</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const stats: { label: string; value: React.ReactNode; hint?: string }[] = [
|
||||
{ label: "Skill 总数", value: skills.length, hint: `${agg.usedCount} 个被调用过` },
|
||||
{ label: "累计调用", value: agg.totalCalls },
|
||||
{ label: "未使用", value: agg.neverUsed.length, hint: agg.neverUsed.length > 0 ? "从未被任何 agent 加载" : "全部用过" },
|
||||
{ label: "未命中调用", value: agg.missingCalls, hint: missing.length > 0 ? `${missing.length} 个不存在的 skill` : "无" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-3xl space-y-6">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">技能库总览</h2>
|
||||
<p className="text-muted-foreground text-sm">选择左侧的 Skill 查看详情与调用记录,或从这里快速了解整体使用情况。</p>
|
||||
</div>
|
||||
|
||||
{/* 指标卡 */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{stats.map((s) => (
|
||||
<div key={s.label} className="rounded-lg border p-3">
|
||||
<p className="text-2xl font-semibold tabular-nums">{s.value}</p>
|
||||
<p className="text-xs font-medium">{s.label}</p>
|
||||
{s.hint && <p className="text-muted-foreground mt-0.5 text-[11px]">{s.hint}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 调用排行 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">调用排行</Label>
|
||||
{agg.ranked.length === 0 ? (
|
||||
<p className="text-muted-foreground rounded-lg border border-dashed p-4 text-center text-xs">
|
||||
还没有任何 Skill 调用记录。
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{agg.ranked.slice(0, 8).map((s) => (
|
||||
<button
|
||||
key={s.name}
|
||||
type="button"
|
||||
onClick={() => onSelect(s.name)}
|
||||
className="group flex w-full items-center gap-3 rounded-md px-2 py-1.5 text-left hover:bg-muted"
|
||||
>
|
||||
<span className="w-40 shrink-0 truncate font-mono text-xs" title={s.name}>{s.name}</span>
|
||||
<span className="relative h-2 flex-1 overflow-hidden rounded-full bg-muted">
|
||||
<span
|
||||
className="absolute inset-y-0 left-0 rounded-full bg-primary/70"
|
||||
style={{ width: `${agg.topCalls > 0 ? (s.calls / agg.topCalls) * 100 : 0}%` }}
|
||||
/>
|
||||
</span>
|
||||
<span className="w-16 shrink-0 text-right text-xs tabular-nums text-muted-foreground">
|
||||
{s.calls} 次
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 sm:grid-cols-2">
|
||||
{/* 最近调用 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">最近调用</Label>
|
||||
{agg.recent.length === 0 ? (
|
||||
<p className="text-muted-foreground text-xs">暂无记录。</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{agg.recent.map((s) => (
|
||||
<button
|
||||
key={s.name}
|
||||
type="button"
|
||||
onClick={() => onSelect(s.name)}
|
||||
className="flex w-full items-center gap-2 rounded px-1.5 py-1 text-left hover:bg-muted"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-xs" title={s.name}>{s.name}</span>
|
||||
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground">{fmtTime(s.last_used)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 未使用(可清理 / 需曝光) */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
未使用的 Skill
|
||||
{agg.neverUsed.length > 0 && <span className="ml-1 font-normal">({agg.neverUsed.length})</span>}
|
||||
</Label>
|
||||
{agg.neverUsed.length === 0 ? (
|
||||
<p className="text-muted-foreground text-xs">所有 Skill 都被调用过。</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{agg.neverUsed.map((s) => (
|
||||
<button
|
||||
key={s.name}
|
||||
type="button"
|
||||
onClick={() => onSelect(s.name)}
|
||||
title={s.name}
|
||||
>
|
||||
<Badge variant="outline" className="max-w-[12rem] cursor-pointer truncate font-mono text-xs font-normal hover:bg-muted">
|
||||
{s.name}
|
||||
</Badge>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Page ──────────────────────────────────────────────────────────────────
|
||||
export default function SkillsPage() {
|
||||
const [skills, setSkills] = React.useState<SkillItem[]>([]);
|
||||
@@ -137,10 +330,21 @@ export default function SkillsPage() {
|
||||
// skill detail — MCP edit state (optimistic, rolls back on error)
|
||||
const [detailMcps, setDetailMcps] = React.useState<string[]>([]);
|
||||
|
||||
// delete confirmation
|
||||
const [pendingDelete, setPendingDelete] = React.useState<PendingDelete>(null);
|
||||
const [deleting, setDeleting] = React.useState(false);
|
||||
|
||||
// 调用统计:列表页的次数/最近调用随 api.skills() 一起回来;选中某个 skill 时再拉它的
|
||||
// 最近调用明细。missing = 被点名但不存在的 skill(想用但没有)。
|
||||
const [usageCalls, setUsageCalls] = React.useState<SkillCall[]>([]);
|
||||
const [usageLoading, setUsageLoading] = React.useState(false);
|
||||
const [missing, setMissing] = React.useState<MissingSkill[]>([]);
|
||||
|
||||
// ── Data ──────────────────────────────────────────────────────────────────
|
||||
const load = React.useCallback(() => {
|
||||
api.agents().then(setAgents).catch(() => {});
|
||||
api.mcpServers().then(setMcpOptions).catch(() => {});
|
||||
api.missingSkills().then(setMissing).catch(() => {});
|
||||
api.skills().then((ss) => {
|
||||
setSkills(ss);
|
||||
ss.forEach((s) =>
|
||||
@@ -195,6 +399,17 @@ export default function SkillsPage() {
|
||||
setDetailMcps(sk?.mcps ?? []);
|
||||
}, [selected, skills]);
|
||||
|
||||
// Recent calls for the selected skill (detail panel only).
|
||||
React.useEffect(() => {
|
||||
if (!selected || selected.path !== null) { setUsageCalls([]); return; }
|
||||
const name = selected.skill;
|
||||
setUsageLoading(true);
|
||||
api.skillUsage(name, 20)
|
||||
.then((calls) => setUsageCalls(calls))
|
||||
.catch(() => setUsageCalls([]))
|
||||
.finally(() => setUsageLoading(false));
|
||||
}, [selected]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!selected || selected.path === null) { setFileContent(""); setDirty(false); return; }
|
||||
setFileLoading(true);
|
||||
@@ -296,6 +511,19 @@ export default function SkillsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function runPendingDelete() {
|
||||
const p = pendingDelete;
|
||||
if (!p) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
if (p.kind === "skill") await deleteSkill(p.skill);
|
||||
else await deletePath(p.skill, p.path);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
setPendingDelete(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveFile() {
|
||||
if (!selected || selected.path === null) return;
|
||||
setSaving(true);
|
||||
@@ -392,7 +620,9 @@ export default function SkillsPage() {
|
||||
|
||||
// ── Recursive tree renderer ───────────────────────────────────────────────
|
||||
function renderTree(nodes: TreeNode[], skill: string, depth: number): React.ReactNode {
|
||||
const baseIndent = 8 + depth * 14;
|
||||
// depth+1: the skill root sits at 8px (px-2); its children indent one step
|
||||
// further in so the tree reads as nested under the skill folder.
|
||||
const baseIndent = 8 + (depth + 1) * 14;
|
||||
return nodes.map((node) => {
|
||||
if (node.type === "dir") {
|
||||
const key = `${skill}:${node.path}`;
|
||||
@@ -400,7 +630,7 @@ export default function SkillsPage() {
|
||||
return (
|
||||
<div key={node.path}>
|
||||
<div
|
||||
className="group flex cursor-pointer select-none items-center gap-1 rounded py-0.5 pr-1 text-sm hover:bg-muted"
|
||||
className="group relative flex cursor-pointer select-none items-center gap-1 rounded py-0.5 pr-1 text-sm hover:bg-muted"
|
||||
style={{ paddingLeft: baseIndent }}
|
||||
onClick={() => toggleExpanded(key)}
|
||||
>
|
||||
@@ -409,8 +639,9 @@ export default function SkillsPage() {
|
||||
? <FolderOpenIcon className="size-3.5 shrink-0 text-amber-500" />
|
||||
: <FolderIcon className="size-3.5 shrink-0 text-amber-500" />
|
||||
}
|
||||
<span className="flex-1 truncate">{node.name}</span>
|
||||
<span className="ml-auto hidden shrink-0 items-center gap-0.5 group-hover:flex">
|
||||
<span className="min-w-0 flex-1 truncate" title={node.path}>{node.name}</span>
|
||||
{/* Absolute so a long name can never push the actions out of view */}
|
||||
<span className="absolute inset-y-0 right-1 hidden items-center gap-0.5 rounded bg-muted pl-1 group-hover:flex">
|
||||
<Button size="icon" variant="ghost" className="size-5" title="新建文件"
|
||||
onClick={(e) => { e.stopPropagation(); startCreate(skill, node.path, "file"); }}>
|
||||
<FilePlusIcon className="size-3 text-muted-foreground" />
|
||||
@@ -420,7 +651,7 @@ export default function SkillsPage() {
|
||||
<FolderPlusIcon className="size-3 text-muted-foreground" />
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" className="size-5" title="删除文件夹"
|
||||
onClick={(e) => { e.stopPropagation(); deletePath(skill, node.path); }}>
|
||||
onClick={(e) => { e.stopPropagation(); setPendingDelete({ kind: "dir", skill, path: node.path }); }}>
|
||||
<Trash2Icon className="size-3 text-destructive" />
|
||||
</Button>
|
||||
</span>
|
||||
@@ -442,19 +673,24 @@ export default function SkillsPage() {
|
||||
<div
|
||||
key={node.path}
|
||||
className={cn(
|
||||
"group flex cursor-pointer select-none items-center gap-1 rounded py-0.5 pr-1 text-sm",
|
||||
"group relative flex cursor-pointer select-none items-center gap-1 rounded py-0.5 pr-1 text-sm",
|
||||
isSelected ? "bg-accent text-accent-foreground" : "hover:bg-muted",
|
||||
)}
|
||||
style={{ paddingLeft: baseIndent + 16 }}
|
||||
onClick={() => setSelected({ skill, path: node.path })}
|
||||
>
|
||||
<FileTextIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 truncate font-mono text-xs">{node.name}</span>
|
||||
<Button size="icon" variant="ghost" className="ml-auto hidden size-5 group-hover:flex"
|
||||
title="删除文件"
|
||||
onClick={(e) => { e.stopPropagation(); deletePath(skill, node.path); }}>
|
||||
<Trash2Icon className="size-3 text-destructive" />
|
||||
</Button>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-xs" title={node.path}>{node.name}</span>
|
||||
<span className={cn(
|
||||
"absolute inset-y-0 right-1 hidden items-center rounded pl-1 group-hover:flex",
|
||||
isSelected ? "bg-accent" : "bg-muted",
|
||||
)}>
|
||||
<Button size="icon" variant="ghost" className="size-5"
|
||||
title="删除文件"
|
||||
onClick={(e) => { e.stopPropagation(); setPendingDelete({ kind: "file", skill, path: node.path }); }}>
|
||||
<Trash2Icon className="size-3 text-destructive" />
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -466,9 +702,36 @@ export default function SkillsPage() {
|
||||
|
||||
return (
|
||||
<div data-content-padding="false" className="flex flex-1 flex-col overflow-hidden">
|
||||
<div className="flex flex-col gap-0.5 border-b px-4 py-2.5 lg:px-6">
|
||||
<h1 className="text-sm font-semibold leading-tight">Skill</h1>
|
||||
<p className="text-muted-foreground text-xs">技能库 · agentskills.io 规范 · 按 Agent 授权可见</p>
|
||||
<div className="flex items-center gap-3 border-b px-4 py-2.5 lg:px-6">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<h1 className="text-sm font-semibold leading-tight">Skill</h1>
|
||||
<p className="text-muted-foreground text-xs">技能库 · agentskills.io 规范 · 按 Agent 授权可见</p>
|
||||
</div>
|
||||
{/* 缺口清单:agent 点名调用、但库里没有的 skill —— 直接是该补什么的依据。 */}
|
||||
{missing.length > 0 && (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button size="sm" variant="outline" className="ml-auto">
|
||||
<AlertTriangleIcon className="size-3.5 text-amber-500" />
|
||||
{missing.length} 个未命中调用
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-80">
|
||||
<p className="mb-2 text-xs text-muted-foreground">
|
||||
Agent 点名调用、但技能库里不存在的 skill。按被点名次数排序。
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{missing.map((m) => (
|
||||
<div key={m.skill} className="flex items-center gap-2 text-sm">
|
||||
<code className="min-w-0 flex-1 truncate font-mono text-xs" title={m.skill}>{m.skill}</code>
|
||||
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">{m.calls} 次</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">{fmtTime(m.last_used)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* ── 左侧文件树 ── */}
|
||||
@@ -496,7 +759,10 @@ export default function SkillsPage() {
|
||||
{uploading ? "上传中…" : "上传压缩包"}
|
||||
</Button>
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
{/* Radix viewport wraps children in a display:table div that grows with
|
||||
content — force it to block so long names truncate instead of
|
||||
widening the rows past the sidebar. */}
|
||||
<ScrollArea className="flex-1 [&>[data-slot=scroll-area-viewport]>div]:!block">
|
||||
<div className="p-1">
|
||||
{skills.map((s) => {
|
||||
const isOpen = expanded.has(s.name);
|
||||
@@ -507,7 +773,7 @@ export default function SkillsPage() {
|
||||
{/* skill 根节点 */}
|
||||
<div
|
||||
className={cn(
|
||||
"group flex cursor-pointer select-none items-center gap-1 rounded px-2 py-1 text-sm",
|
||||
"group relative flex cursor-pointer select-none items-center gap-1 rounded px-2 py-1 text-sm",
|
||||
isSkillSelected ? "bg-accent text-accent-foreground" : "hover:bg-muted",
|
||||
)}
|
||||
onClick={() => {
|
||||
@@ -520,8 +786,19 @@ export default function SkillsPage() {
|
||||
? <FolderOpenIcon className="size-3.5 shrink-0 text-blue-500" />
|
||||
: <FolderIcon className="size-3.5 shrink-0 text-blue-500" />
|
||||
}
|
||||
<span className="flex-1 truncate font-semibold">{s.name}</span>
|
||||
<span className="ml-auto hidden shrink-0 items-center gap-0.5 group-hover:flex">
|
||||
<span className="min-w-0 flex-1 truncate font-semibold" title={s.name}>{s.name}</span>
|
||||
{s.calls > 0 && (
|
||||
<span
|
||||
className="shrink-0 rounded bg-muted px-1 text-[10px] tabular-nums text-muted-foreground"
|
||||
title={`被调用 ${s.calls} 次 · 最近 ${fmtTime(s.last_used)}`}
|
||||
>
|
||||
{s.calls}
|
||||
</span>
|
||||
)}
|
||||
<span className={cn(
|
||||
"absolute inset-y-0 right-1 hidden items-center gap-0.5 rounded pl-1 group-hover:flex",
|
||||
isSkillSelected ? "bg-accent" : "bg-muted",
|
||||
)}>
|
||||
<Button size="icon" variant="ghost" className="size-5" title="新建文件"
|
||||
onClick={(e) => { e.stopPropagation(); startCreate(s.name, "", "file"); }}>
|
||||
<FilePlusIcon className="size-3 text-muted-foreground" />
|
||||
@@ -531,7 +808,7 @@ export default function SkillsPage() {
|
||||
<FolderPlusIcon className="size-3 text-muted-foreground" />
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" className="size-5" title="删除 Skill"
|
||||
onClick={(e) => { e.stopPropagation(); deleteSkill(s.name); }}>
|
||||
onClick={(e) => { e.stopPropagation(); setPendingDelete({ kind: "skill", skill: s.name }); }}>
|
||||
<Trash2Icon className="size-3 text-destructive" />
|
||||
</Button>
|
||||
</span>
|
||||
@@ -559,13 +836,15 @@ export default function SkillsPage() {
|
||||
{/* ── 右侧面板 ── */}
|
||||
<div className="flex flex-1 flex-col overflow-auto p-4">
|
||||
{!selected && (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">选择左侧 Skill 或文件</p>
|
||||
</div>
|
||||
<SkillsOverview
|
||||
skills={skills}
|
||||
missing={missing}
|
||||
onSelect={(name) => setSelected({ skill: name, path: null })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selected && selected.path === null && selectedSkill && (
|
||||
<div className="max-w-xl space-y-5">
|
||||
<div className="max-w-5xl space-y-5">
|
||||
<div>
|
||||
<h2 className="font-mono text-base font-semibold">{selectedSkill.name}</h2>
|
||||
{selectedSkill.description && (
|
||||
@@ -581,45 +860,100 @@ export default function SkillsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 关联 MCP ── */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
关联 MCP
|
||||
<span className="ml-1 font-normal">(加载 Skill 时才披露/解锁其工具)</span>
|
||||
</Label>
|
||||
{mcpOptions.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">暂无 MCP,可在「MCP」页添加。</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-2">
|
||||
{mcpOptions.map((m) => (
|
||||
<label key={m.id} className="flex cursor-pointer items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={detailMcps.includes(m.name)}
|
||||
onCheckedChange={(on) => toggleSkillMcp(selectedSkill.name, m.name, !!on)}
|
||||
/>
|
||||
{m.name}
|
||||
</label>
|
||||
))}
|
||||
{/* 左右分栏:配置(MCP/可见性)在左为主,调用统计在右为辅。
|
||||
lg 以下放不下时用 flex-row-reverse 回落到单列——统计因 DOM 顺序在前,
|
||||
窄屏时自然落到配置上方(与改版前的上下顺序一致)。 */}
|
||||
<div className="flex flex-col gap-6 lg:flex-row-reverse lg:items-start">
|
||||
{/* ── 右侧:调用统计 ── */}
|
||||
<div className="space-y-2 lg:w-80 lg:shrink-0">
|
||||
<Label className="text-xs text-muted-foreground">调用统计</Label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="rounded-md border p-2">
|
||||
<p className="text-lg font-semibold tabular-nums">{selectedSkill.calls}</p>
|
||||
<p className="text-xs text-muted-foreground">总调用次数</p>
|
||||
</div>
|
||||
<div className="rounded-md border p-2">
|
||||
<p className="text-lg font-semibold tabular-nums">{selectedSkill.tasks}</p>
|
||||
<p className="text-xs text-muted-foreground">覆盖任务数</p>
|
||||
</div>
|
||||
<div className="rounded-md border p-2">
|
||||
<p className="truncate text-sm font-medium" title={fmtTime(selectedSkill.last_used)}>
|
||||
{fmtTime(selectedSkill.last_used)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">最近调用</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── 可见性 ── */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">可见性(按 Agent 授权)</Label>
|
||||
<div className="space-y-2">
|
||||
{agents.map((a) => (
|
||||
<label key={a.key} className="flex cursor-pointer items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={(visibility[selectedSkill.name] ?? []).includes(a.id)}
|
||||
onCheckedChange={() => toggleVisibility(selectedSkill.name, a.id, a.name)}
|
||||
/>
|
||||
{a.name}
|
||||
</label>
|
||||
))}
|
||||
{agents.length === 0 && (
|
||||
<span className="text-xs text-muted-foreground">(暂无 Agent)</span>
|
||||
{selectedSkill.usage_agents.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<span className="text-xs text-muted-foreground">调用方:</span>
|
||||
{selectedSkill.usage_agents.map((k) => (
|
||||
<Badge key={k} variant="secondary" className="text-xs font-normal">{k}</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{usageLoading ? (
|
||||
<p className="text-xs text-muted-foreground">加载调用明细…</p>
|
||||
) : usageCalls.length > 0 ? (
|
||||
<div className="rounded-md border">
|
||||
<div className="border-b px-2 py-1 text-xs text-muted-foreground">最近 {usageCalls.length} 次调用</div>
|
||||
<div className="max-h-56 overflow-y-auto">
|
||||
{usageCalls.map((c, i) => (
|
||||
<div key={`${c.ts}-${i}`} className="flex items-center gap-2 border-b px-2 py-1 text-xs last:border-b-0">
|
||||
<span className="tabular-nums text-muted-foreground">{fmtTime(c.ts)}</span>
|
||||
<Badge variant="outline" className="font-normal">{c.agent_key || "—"}</Badge>
|
||||
<span className="ml-auto text-muted-foreground">
|
||||
{c.task_id > 0 ? `任务 #${c.task_id}` : c.session_id ? "对话会话" : "—"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">还没有调用记录。</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── 左侧:关联 MCP + 可见性 ── */}
|
||||
<div className="space-y-5 lg:min-w-0 lg:flex-1">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
关联 MCP
|
||||
<span className="ml-1 font-normal">(加载 Skill 时才披露/解锁其工具)</span>
|
||||
</Label>
|
||||
{mcpOptions.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">暂无 MCP,可在「MCP」页添加。</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-2">
|
||||
{mcpOptions.map((m) => (
|
||||
<label key={m.id} className="flex cursor-pointer items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={detailMcps.includes(m.name)}
|
||||
onCheckedChange={(on) => toggleSkillMcp(selectedSkill.name, m.name, !!on)}
|
||||
/>
|
||||
{m.name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">可见性(按 Agent 授权)</Label>
|
||||
<div className="space-y-2">
|
||||
{agents.map((a) => (
|
||||
<label key={a.key} className="flex cursor-pointer items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={(visibility[selectedSkill.name] ?? []).includes(a.id)}
|
||||
onCheckedChange={() => toggleVisibility(selectedSkill.name, a.id, a.name)}
|
||||
/>
|
||||
{a.name}
|
||||
</label>
|
||||
))}
|
||||
{agents.length === 0 && (
|
||||
<span className="text-xs text-muted-foreground">(暂无 Agent)</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -649,6 +983,35 @@ export default function SkillsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 删除二次确认 ── */}
|
||||
<AlertDialog open={!!pendingDelete} onOpenChange={(o) => { if (!o) setPendingDelete(null); }}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{pendingDelete?.kind === "skill" && `删除 Skill「${pendingDelete.skill}」?`}
|
||||
{pendingDelete?.kind === "dir" && `删除文件夹「${pendingDelete.path}」?`}
|
||||
{pendingDelete?.kind === "file" && `删除文件「${pendingDelete.path}」?`}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{pendingDelete?.kind === "skill"
|
||||
? "将删除该 Skill 的全部文件、MCP 关联与可见性配置。此操作不可撤销。"
|
||||
: pendingDelete?.kind === "dir"
|
||||
? "将一并删除该文件夹下的所有文件。此操作不可撤销。"
|
||||
: "此操作不可撤销。"}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleting}>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={deleting}
|
||||
onClick={(e) => { e.preventDefault(); void runPendingDelete(); }}
|
||||
>
|
||||
{deleting ? "删除中…" : "删除"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* ── 新建 Skill 对话框 ── */}
|
||||
<Sheet open={newOpen} onOpenChange={setNewOpen}>
|
||||
<SheetContent side="right" className="flex w-full flex-col gap-0 data-[side=right]:sm:max-w-xl">
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn, copyText } from "@/lib/utils";
|
||||
|
||||
type CopyButtonProps = {
|
||||
// 要复制的文本;为空则按钮禁用。
|
||||
text: string | null | undefined;
|
||||
// 复制成功后的 toast 文案,默认「已复制」。
|
||||
successMessage?: string;
|
||||
label?: React.ReactNode;
|
||||
size?: React.ComponentProps<typeof Button>["size"];
|
||||
variant?: React.ComponentProps<typeof Button>["variant"];
|
||||
className?: string;
|
||||
};
|
||||
|
||||
// CopyButton 统一的「复制到剪贴板」按钮:内置成功/失败反馈,并在 HTTP 非安全上下文
|
||||
// 下自动降级(见 copyText)。
|
||||
export function CopyButton({
|
||||
text,
|
||||
successMessage = "已复制",
|
||||
label = "复制",
|
||||
size = "sm",
|
||||
variant = "outline",
|
||||
className,
|
||||
}: CopyButtonProps) {
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function handleCopy() {
|
||||
if (!text) return;
|
||||
const ok = await copyText(text);
|
||||
if (ok) {
|
||||
setCopied(true);
|
||||
toast.success(successMessage);
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
timer.current = setTimeout(() => setCopied(false), 1500);
|
||||
} else {
|
||||
toast.error("复制失败,请手动选择文本复制");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
size={size}
|
||||
variant={variant}
|
||||
className={cn(className)}
|
||||
disabled={!text}
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{copied ? <CheckIcon /> : <CopyIcon />}
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
+264
-94
@@ -6,15 +6,63 @@
|
||||
import { MOCK } from "@/lib/mock/enabled";
|
||||
import { mockHandle } from "@/lib/mock/handler";
|
||||
import type {
|
||||
Task, DeleteTaskOptions, DeleteTaskResult, Stats, Asset, AssetNode, Edge, Company, TaskNode, Finding, Severity, FindingStatus,
|
||||
FindingsPage, FindingStats, FindingQuery, Activity,
|
||||
Audit, TrafficResp, TrafficDetail, TrafficHost, Settings, LLMProfile, Agent, AgentDetail, PromptVar,
|
||||
PromptVersion, MCPServer, MCPTool, SkillItem, TokenUsage, TokenTotal, DailyTokenBucket,
|
||||
Tool, Conversation, AgentTrigger, ChatAttachment,
|
||||
InterceptRule, InterceptPending, InterceptApprovalRow,
|
||||
TaskAssetView, SSProject, SSTask, ConvTokenSummary, CoverageGraphData, CoverageAssetRefs,
|
||||
WorkspaceListing, WorkspaceFile,
|
||||
CommandRecord, LLMRecordItem, LLMRecordDetail, LLMTask, LLMPoolStatus,
|
||||
Activity,
|
||||
Agent,
|
||||
AgentDetail,
|
||||
AgentTrigger,
|
||||
Asset,
|
||||
AssetNode,
|
||||
Audit,
|
||||
ChatAttachment,
|
||||
CommandRecord,
|
||||
Company,
|
||||
Conversation,
|
||||
ConvTokenSummary,
|
||||
CoverageAssetRefs,
|
||||
CoverageGraphData,
|
||||
DailyTokenBucket,
|
||||
DeleteTaskOptions,
|
||||
DeleteTaskResult,
|
||||
Edge,
|
||||
Finding,
|
||||
FindingQuery,
|
||||
FindingStats,
|
||||
FindingStatus,
|
||||
FindingsPage,
|
||||
InterceptApprovalRow,
|
||||
InterceptPending,
|
||||
InterceptRule,
|
||||
LLMPoolStatus,
|
||||
LLMProfile,
|
||||
LLMRecordDetail,
|
||||
LLMRecordItem,
|
||||
LLMTask,
|
||||
MCPServer,
|
||||
MCPTool,
|
||||
ModelTokenStat,
|
||||
PromptVar,
|
||||
PromptVersion,
|
||||
Settings,
|
||||
Severity,
|
||||
SkillCall,
|
||||
SkillItem,
|
||||
MissingSkill,
|
||||
SSProject,
|
||||
SSTask,
|
||||
Stats,
|
||||
Task,
|
||||
TaskAssetView,
|
||||
TaskNode,
|
||||
TaskScopeRow,
|
||||
TokenTotal,
|
||||
TokenUsage,
|
||||
Tool,
|
||||
TrafficDetail,
|
||||
TrafficHost,
|
||||
TrafficResp,
|
||||
UsageStats,
|
||||
WorkspaceFile,
|
||||
WorkspaceListing,
|
||||
} from "@/lib/types";
|
||||
|
||||
function getToken(): string | null {
|
||||
@@ -92,19 +140,21 @@ function mockReport(_task?: string): string {
|
||||
export function sseUrl(path: string): string {
|
||||
const base =
|
||||
process.env.NEXT_PUBLIC_SSE_BASE ??
|
||||
(typeof window !== "undefined"
|
||||
? `${window.location.protocol}//${window.location.hostname}:8787`
|
||||
: "");
|
||||
(typeof window !== "undefined" ? `${window.location.protocol}//${window.location.hostname}:8787` : "");
|
||||
const token = getToken();
|
||||
const sep = path.includes("?") ? "&" : "?";
|
||||
return token ? `${base}${path}${sep}token=${encodeURIComponent(token)}` : `${base}${path}`;
|
||||
}
|
||||
|
||||
const get = <T>(p: string) => http<T>(p);
|
||||
const post = <T>(p: string, body?: unknown) => http<T>(p, { method: "POST", body: body ? JSON.stringify(body) : undefined });
|
||||
const put = <T>(p: string, body?: unknown) => http<T>(p, { method: "PUT", body: body ? JSON.stringify(body) : undefined });
|
||||
const patch = <T>(p: string, body?: unknown) => http<T>(p, { method: "PATCH", body: body ? JSON.stringify(body) : undefined });
|
||||
const del = <T>(p: string, body?: unknown) => http<T>(p, { method: "DELETE", body: body ? JSON.stringify(body) : undefined });
|
||||
const post = <T>(p: string, body?: unknown) =>
|
||||
http<T>(p, { method: "POST", body: body ? JSON.stringify(body) : undefined });
|
||||
const put = <T>(p: string, body?: unknown) =>
|
||||
http<T>(p, { method: "PUT", body: body ? JSON.stringify(body) : undefined });
|
||||
const patch = <T>(p: string, body?: unknown) =>
|
||||
http<T>(p, { method: "PATCH", body: body ? JSON.stringify(body) : undefined });
|
||||
const del = <T>(p: string, body?: unknown) =>
|
||||
http<T>(p, { method: "DELETE", body: body ? JSON.stringify(body) : undefined });
|
||||
|
||||
// Go serializes nil slices as JSON null — coerce to [].
|
||||
const arr = <T>(x: T[] | null | undefined): T[] => x ?? [];
|
||||
@@ -116,15 +166,14 @@ export const api = {
|
||||
|
||||
// ---- auth ----
|
||||
authStatus: () => get<{ initialized: boolean }>("/auth/status"),
|
||||
login: (username: string, password: string) =>
|
||||
post<{ token: string }>("/auth/login", { username, password }),
|
||||
initPassword: (password: string) =>
|
||||
post<{ token: string }>("/auth/init", { password }),
|
||||
login: (username: string, password: string) => post<{ token: string }>("/auth/login", { username, password }),
|
||||
initPassword: (password: string) => post<{ token: string }>("/auth/init", { password }),
|
||||
changePassword: (oldPassword: string, newPassword: string) =>
|
||||
post<{ ok: boolean }>("/auth/change-password", { old_password: oldPassword, new_password: newPassword }),
|
||||
|
||||
// ---- tasks ----
|
||||
tasks: () => get<{ tasks: Task[]; active: string }>("/tasks").then((r) => ({ tasks: arr(r.tasks), active: r.active ?? "" })),
|
||||
tasks: () =>
|
||||
get<{ tasks: Task[]; active: string }>("/tasks").then((r) => ({ tasks: arr(r.tasks), active: r.active ?? "" })),
|
||||
createTask: (input: {
|
||||
description: string;
|
||||
goal: string;
|
||||
@@ -155,9 +204,9 @@ export const api = {
|
||||
llm_profile_ids: llmProfileIds,
|
||||
active_llm_profile_id: activeLLMProfileId ?? null,
|
||||
}),
|
||||
deleteTask: (id: string, options: DeleteTaskOptions) =>
|
||||
del<DeleteTaskResult>(`/tasks/${id}`, options),
|
||||
controlTask: (id: string, action: "pause" | "resume") => post<{ id: string; paused: boolean }>(`/tasks/${id}/control`, { action }),
|
||||
deleteTask: (id: string, options: DeleteTaskOptions) => del<DeleteTaskResult>(`/tasks/${id}`, options),
|
||||
controlTask: (id: string, action: "pause" | "resume") =>
|
||||
post<{ id: string; paused: boolean }>(`/tasks/${id}/control`, { action }),
|
||||
controlIntent: (taskId: string, intentId: string, action: "pause" | "resume" | "cancel") =>
|
||||
post<{
|
||||
id: number;
|
||||
@@ -165,7 +214,8 @@ export const api = {
|
||||
deleted?: { intents: number; facts: number; findings: number; activities: number };
|
||||
}>(`/tasks/${taskId}/intents/${intentId}/control`, { action }),
|
||||
// 重跑一条没跑成功的意图(blocked/exhausted/stopped):置回 open,worker 会重新认领、从头再跑。
|
||||
rerunIntent: (taskId: string, intentId: string) => post<{ id: string; reopened: number }>(`/tasks/${taskId}/intents/${intentId}/rerun`),
|
||||
rerunIntent: (taskId: string, intentId: string) =>
|
||||
post<{ id: string; reopened: number }>(`/tasks/${taskId}/intents/${intentId}/rerun`),
|
||||
// 批量重跑本任务全部 blocked 意图(一次网络/LLM 断连导致多条 blocked 时一键全部重试)。
|
||||
rerunBlocked: (taskId: string) => post<{ id: string; reopened: number }>(`/tasks/${taskId}/intents/rerun-blocked`),
|
||||
setActive: (id: string) => post<{ active: string }>("/active", { id }),
|
||||
@@ -181,23 +231,26 @@ export const api = {
|
||||
by_type: { type: string; total: number; tested: number }[];
|
||||
}>(`/tasks/${id}/coverage`),
|
||||
// 资产覆盖图:范围内全部资产 + 连接用的根域名/公司节点,含 tested/in_scope。
|
||||
taskCoverageGraph: (id: string) =>
|
||||
get<CoverageGraphData>(`/tasks/${id}/coverage-graph`),
|
||||
taskCoverageGraph: (id: string) => get<CoverageGraphData>(`/tasks/${id}/coverage-graph`),
|
||||
// 全局 llm_usage 聚合(仪表盘新版 token 视图):按 profile 总量 + 按天分桶。
|
||||
usageStats: (days = 365) => get<UsageStats>(`/tokens/usage?days=${days}`),
|
||||
// 本任务测试范围列表(含继承自来源任务的范围)。
|
||||
taskScope: (id: string) => get<{ scope: TaskScopeRow[] }>(`/tasks/${id}/scope`),
|
||||
// 手动新增一条测试范围(kind=company/root_domain/subdomain/ip/cidr)。
|
||||
addTaskScope: (id: string, kind: string, value: string, reason?: string) =>
|
||||
post<TaskScopeRow>(`/tasks/${id}/scope`, { kind, value, reason }),
|
||||
// 删除本任务的一条测试范围。
|
||||
deleteTaskScope: (id: string, scopeId: number) => del<{ ok: boolean }>(`/tasks/${id}/scope/${scopeId}`),
|
||||
// 某资产在本任务里关联的意图 / 事实 / 发现(覆盖图节点抽屉用)。
|
||||
taskAssetRefs: (id: string, assetId: number) =>
|
||||
get<CoverageAssetRefs>(`/tasks/${id}/asset-refs?asset_id=${assetId}`),
|
||||
taskAssetRefs: (id: string, assetId: number) => get<CoverageAssetRefs>(`/tasks/${id}/asset-refs?asset_id=${assetId}`),
|
||||
|
||||
// ---- workspace file manager (workDir) ----
|
||||
workspaceList: (path = "") =>
|
||||
get<WorkspaceListing>(`/workspace/list?path=${encodeURIComponent(path)}`),
|
||||
workspaceRead: (path: string) =>
|
||||
get<WorkspaceFile>(`/workspace/read?path=${encodeURIComponent(path)}`),
|
||||
workspaceList: (path = "") => get<WorkspaceListing>(`/workspace/list?path=${encodeURIComponent(path)}`),
|
||||
workspaceRead: (path: string) => get<WorkspaceFile>(`/workspace/read?path=${encodeURIComponent(path)}`),
|
||||
workspaceWrite: (path: string, content: string) =>
|
||||
post<{ ok: boolean; path: string }>(`/workspace/write`, { path, content }),
|
||||
workspaceMkdir: (path: string) =>
|
||||
post<{ ok: boolean; path: string }>(`/workspace/mkdir`, { path }),
|
||||
workspaceDelete: (path: string) =>
|
||||
del<{ ok: boolean }>(`/workspace/delete?path=${encodeURIComponent(path)}`),
|
||||
workspaceMkdir: (path: string) => post<{ ok: boolean; path: string }>(`/workspace/mkdir`, { path }),
|
||||
workspaceDelete: (path: string) => del<{ ok: boolean }>(`/workspace/delete?path=${encodeURIComponent(path)}`),
|
||||
workspaceUpload: async (dir: string, files: File[]) => {
|
||||
if (MOCK) return { uploaded: files.length };
|
||||
const fd = new FormData();
|
||||
@@ -236,8 +289,9 @@ export const api = {
|
||||
// ---- assets ----
|
||||
// Server-side paginated: pass limit/offset, get back the page + full match total.
|
||||
assets: (type = "", limit = 50, offset = 0) =>
|
||||
get<{ count: number; total: number; assets: Asset[] }>(`/assets?type=${type}&limit=${limit}&offset=${offset}`)
|
||||
.then((r) => ({ assets: r?.assets ?? [], total: r?.total ?? r?.count ?? 0 })),
|
||||
get<{ count: number; total: number; assets: Asset[] }>(`/assets?type=${type}&limit=${limit}&offset=${offset}`).then(
|
||||
(r) => ({ assets: r?.assets ?? [], total: r?.total ?? r?.count ?? 0 }),
|
||||
),
|
||||
searchAssets: (dsl: string, type = "", limit = 50, offset = 0) =>
|
||||
get<{ count: number; total: number; assets: Asset[] }>(
|
||||
`/assets?dsl=${encodeURIComponent(dsl)}${type ? `&type=${encodeURIComponent(type)}` : ""}&limit=${limit}&offset=${offset}`,
|
||||
@@ -257,18 +311,38 @@ export const api = {
|
||||
createCompany: (name: string, logo: string, scope: string) =>
|
||||
post<{ id: number; created: boolean; scope_added?: number; scope_invalid?: number; scope_errors?: string[] }>(
|
||||
"/companies",
|
||||
{ name, logo, scope: scope.trim() ? scope.split("\n").map((s) => s.trim()).filter(Boolean) : [] },
|
||||
{
|
||||
name,
|
||||
logo,
|
||||
scope: scope.trim()
|
||||
? scope
|
||||
.split("\n")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
: [],
|
||||
},
|
||||
),
|
||||
addCompanyScope: (id: number, scope: string, reason = "") =>
|
||||
post<{ added: number; skipped: number; invalid: number; errors?: string[] }>(
|
||||
`/companies/${id}/scope`,
|
||||
{ scope: scope.trim() ? scope.split("\n").map((s) => s.trim()).filter(Boolean) : [], reason },
|
||||
),
|
||||
post<{ added: number; skipped: number; invalid: number; errors?: string[] }>(`/companies/${id}/scope`, {
|
||||
scope: scope.trim()
|
||||
? scope
|
||||
.split("\n")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
: [],
|
||||
reason,
|
||||
}),
|
||||
updateCompanyScope: (id: number, scope: string, reason = "") =>
|
||||
post<{ added: number; skipped: number; invalid: number; errors?: string[] }>(
|
||||
`/companies/${id}/scope`,
|
||||
{ scope: scope.trim() ? scope.split("\n").map((s) => s.trim()).filter(Boolean) : [], reason, reset: true },
|
||||
),
|
||||
post<{ added: number; skipped: number; invalid: number; errors?: string[] }>(`/companies/${id}/scope`, {
|
||||
scope: scope.trim()
|
||||
? scope
|
||||
.split("\n")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
: [],
|
||||
reason,
|
||||
reset: true,
|
||||
}),
|
||||
deleteCompany: (id: number, deleteAssets = false) =>
|
||||
http<{ deleted: number; assets_deleted: number }>(`/companies/${id}`, {
|
||||
method: "DELETE",
|
||||
@@ -288,17 +362,52 @@ export const api = {
|
||||
return get<FindingsPage>(`/exploration/findings?${p.toString()}`);
|
||||
},
|
||||
findingStats: () => get<FindingStats>("/exploration/findings/stats"),
|
||||
// exportFindings 触发发现页导出并下载文件。scope=selected 时传 ids(finding_id 列表);
|
||||
// scope=filtered 时传当前筛选(沿用 FindingQuery 的筛选字段);scope=all 忽略筛选。
|
||||
exportFindings: async (opts: {
|
||||
format: "md-single" | "md-zip" | "csv" | "json";
|
||||
scope: "filtered" | "all" | "selected";
|
||||
filters?: Omit<FindingQuery, "page" | "pageSize">;
|
||||
ids?: string[];
|
||||
}) => {
|
||||
const p = new URLSearchParams({ format: opts.format, scope: opts.scope });
|
||||
if (opts.scope === "selected") {
|
||||
p.set("ids", (opts.ids ?? []).join(","));
|
||||
} else if (opts.scope === "filtered" && opts.filters) {
|
||||
const f = opts.filters;
|
||||
if (f.severity && f.severity !== "all") p.set("severity", f.severity);
|
||||
if (f.status && f.status !== "all") p.set("status", f.status);
|
||||
if (f.vulnclass && f.vulnclass !== "all") p.set("vulnclass", f.vulnclass);
|
||||
if (f.task && f.task !== "all") p.set("task_id", f.task);
|
||||
if (f.sort) p.set("sort", f.sort);
|
||||
}
|
||||
const token = getToken();
|
||||
const r = await fetch(`/api/exploration/findings/export?${p.toString()}`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
if (!r.ok) throw new Error(`export: ${r.status}`);
|
||||
const blob = await r.blob();
|
||||
// 文件名优先取后端 Content-Disposition,取不到则用默认名。
|
||||
const disp = r.headers.get("Content-Disposition") ?? "";
|
||||
const m = disp.match(/filename="?([^"]+)"?/);
|
||||
const filename = m?.[1] ?? `findings-export`;
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
getFinding: (id: string, contextTaskId?: string) =>
|
||||
get<Finding>(
|
||||
`/exploration/findings/${id}${contextTaskId ? `?context_task=${encodeURIComponent(contextTaskId)}` : ""}`,
|
||||
),
|
||||
// 漏洞链路:该漏洞节点回溯到任务初始节点的子图(节点 + 关系)。
|
||||
findingLineage: (id: string) =>
|
||||
get<{ nodes: TaskNode[]; edges: Edge[] }>(`/exploration/findings/${id}/lineage`),
|
||||
setFindingStatus: (id: string, status: FindingStatus) =>
|
||||
patch<Finding>(`/exploration/findings/${id}`, { status }),
|
||||
setFindingSeverity: (id: string, severity: Severity) =>
|
||||
patch<Finding>(`/exploration/findings/${id}`, { severity }),
|
||||
findingLineage: (id: string) => get<{ nodes: TaskNode[]; edges: Edge[] }>(`/exploration/findings/${id}/lineage`),
|
||||
setFindingStatus: (id: string, status: FindingStatus) => patch<Finding>(`/exploration/findings/${id}`, { status }),
|
||||
setFindingSeverity: (id: string, severity: Severity) => patch<Finding>(`/exploration/findings/${id}`, { severity }),
|
||||
// 一次保存漏洞的名称/类别/严重等级(发现列表行内编辑用),只传出现的字段。
|
||||
updateFinding: (
|
||||
id: string,
|
||||
@@ -312,10 +421,11 @@ export const api = {
|
||||
workers: arr(r.workers),
|
||||
total: r.total,
|
||||
})),
|
||||
tokenDaily: (days = 30) =>
|
||||
get<DailyTokenBucket[]>(`/tokens/daily?days=${days}`).then(arr),
|
||||
tokenDaily: (days = 30) => get<DailyTokenBucket[]>(`/tokens/daily?days=${days}`).then(arr),
|
||||
conversationTokens: () =>
|
||||
get<ConvTokenSummary[]>("/tokens/conversations").then(arr).catch(() => [] as ConvTokenSummary[]),
|
||||
get<ConvTokenSummary[]>("/tokens/conversations")
|
||||
.then(arr)
|
||||
.catch(() => [] as ConvTokenSummary[]),
|
||||
explorationGraph: (task?: string) => get<{ nodes: TaskNode[]; edges: Edge[] }>(`/exploration/graph${tq(task)}`),
|
||||
activity: (task?: string, opts?: { intent?: string; since?: number; limit?: number }) => {
|
||||
const q = new URLSearchParams();
|
||||
@@ -323,7 +433,10 @@ export const api = {
|
||||
if (opts?.intent) q.set("intent", opts.intent);
|
||||
if (opts?.since) q.set("since", String(opts.since));
|
||||
if (opts?.limit) q.set("limit", String(opts.limit));
|
||||
return get<{ items: Activity[]; cursor: number }>(`/exploration/activity?${q.toString()}`).then((r) => ({ items: arr(r.items), cursor: r.cursor ?? 0 }));
|
||||
return get<{ items: Activity[]; cursor: number }>(`/exploration/activity?${q.toString()}`).then((r) => ({
|
||||
items: arr(r.items),
|
||||
cursor: r.cursor ?? 0,
|
||||
}));
|
||||
},
|
||||
activityDetail: (id: number, task?: string) => get<{ detail: string }>(`/exploration/activity/${id}${tq(task)}`),
|
||||
// Reverse-paginated session history. session = "main" | "plan" | "intent:<id>".
|
||||
@@ -366,20 +479,21 @@ export const api = {
|
||||
(method && method !== "all" ? `&method=${encodeURIComponent(method)}` : "") +
|
||||
(q ? `&q=${encodeURIComponent(q)}` : ""),
|
||||
),
|
||||
trafficExchange: (id: string) =>
|
||||
get<TrafficDetail>(`/traffic/exchange?id=${encodeURIComponent(id)}`),
|
||||
trafficExchange: (id: string) => get<TrafficDetail>(`/traffic/exchange?id=${encodeURIComponent(id)}`),
|
||||
trafficHosts: () => get<{ hosts: TrafficHost[] }>(`/traffic/hosts`),
|
||||
trafficDeleteHost: (host: string) =>
|
||||
del<{ deleted: number }>(`/traffic?host=${encodeURIComponent(host)}`),
|
||||
trafficDeleteHosts: (hosts: string[]) =>
|
||||
del<{ deleted: number }>(`/traffic/hosts`, { hosts }),
|
||||
trafficDeleteHost: (host: string) => del<{ deleted: number }>(`/traffic?host=${encodeURIComponent(host)}`),
|
||||
trafficDeleteHosts: (hosts: string[]) => del<{ deleted: number }>(`/traffic/hosts`, { hosts }),
|
||||
|
||||
// ---- app settings (runtime toggles) ----
|
||||
settings: () => get<Settings>(`/settings`),
|
||||
setSettings: (patch: Partial<Settings>) => put<Settings>(`/settings`, patch),
|
||||
// Run a real "test" search with the given (or saved) config to verify it works.
|
||||
testWebSearch: (patch: { web_search_backend?: string; web_search_proxy?: string; brave_search_api_key?: string; tavily_search_api_key?: string }) =>
|
||||
post<{ ok: boolean; error?: string; count?: number; backend?: string }>(`/settings/web-search/test`, patch),
|
||||
testWebSearch: (patch: {
|
||||
web_search_backend?: string;
|
||||
web_search_proxy?: string;
|
||||
brave_search_api_key?: string;
|
||||
tavily_search_api_key?: string;
|
||||
}) => post<{ ok: boolean; error?: string; count?: number; backend?: string }>(`/settings/web-search/test`, patch),
|
||||
report: async (task?: string) => {
|
||||
if (MOCK) return mockReport(task);
|
||||
const token = getToken();
|
||||
@@ -393,7 +507,15 @@ export const api = {
|
||||
post<{ reply: string; mode: string }>(`/chat${tq(task)}`, { message, attachments }),
|
||||
// 方式1 文件上传:落到会话/任务工作目录 uploads/,返回可供 agent Read 的相对路径。
|
||||
chatUpload: async (scope: "task" | "session" | "staging", id: string, files: File[]) => {
|
||||
if (MOCK) return { attachments: files.map((f) => ({ name: f.name, path: `uploads/${f.name}`, size: f.size, abs: `/mock/${f.name}` })) };
|
||||
if (MOCK)
|
||||
return {
|
||||
attachments: files.map((f) => ({
|
||||
name: f.name,
|
||||
path: `uploads/${f.name}`,
|
||||
size: f.size,
|
||||
abs: `/mock/${f.name}`,
|
||||
})),
|
||||
};
|
||||
const fd = new FormData();
|
||||
for (const f of files) fd.append("file", f);
|
||||
const token = getToken();
|
||||
@@ -409,15 +531,37 @@ export const api = {
|
||||
gc: (ttl = 86400) => post<{ removed: number }>(`/gc?ttl=${ttl}`, {}),
|
||||
|
||||
// ---- LLM ----
|
||||
getLLM: () => get<{ configured: boolean; provider: string; model: string; base_url: string; proxy?: string; key_set: boolean; rate_per_second?: number; rate_per_minute?: number; context_window_k?: number; reasoning_effort?: string }>("/llm"),
|
||||
setLLM: (provider: string, model: string, base_url: string, api_key: string, rate_per_second = 0, rate_per_minute = 0, proxy = "", context_window_k = 0) =>
|
||||
post("/llm", { provider, model, base_url, proxy, api_key, rate_per_second, rate_per_minute, context_window_k }),
|
||||
getLLM: () =>
|
||||
get<{
|
||||
configured: boolean;
|
||||
provider: string;
|
||||
model: string;
|
||||
base_url: string;
|
||||
proxy?: string;
|
||||
key_set: boolean;
|
||||
rate_per_second?: number;
|
||||
rate_per_minute?: number;
|
||||
context_window_k?: number;
|
||||
thinking_type?: string;
|
||||
reasoning_effort?: string;
|
||||
}>("/llm"),
|
||||
setLLM: (
|
||||
provider: string,
|
||||
model: string,
|
||||
base_url: string,
|
||||
api_key: string,
|
||||
rate_per_second = 0,
|
||||
rate_per_minute = 0,
|
||||
proxy = "",
|
||||
context_window_k = 0,
|
||||
) => post("/llm", { provider, model, base_url, proxy, api_key, rate_per_second, rate_per_minute, context_window_k }),
|
||||
testLLM: (
|
||||
provider: string,
|
||||
model: string,
|
||||
base_url: string,
|
||||
api_key: string,
|
||||
proxy = "",
|
||||
thinking_type = "",
|
||||
reasoning_effort = "",
|
||||
profile_id?: number,
|
||||
) =>
|
||||
@@ -427,6 +571,7 @@ export const api = {
|
||||
base_url,
|
||||
proxy,
|
||||
api_key,
|
||||
thinking_type,
|
||||
reasoning_effort,
|
||||
profile_id,
|
||||
}),
|
||||
@@ -442,7 +587,8 @@ export const api = {
|
||||
rate_per_second?: number;
|
||||
rate_per_minute?: number;
|
||||
context_window_k?: number;
|
||||
reasoning_effort?: string; // ""|"off"|"low"|"medium"|"high"|"max"
|
||||
thinking_type?: string; // ""(不发送)|"disabled"|"enabled"
|
||||
reasoning_effort?: string; // ""(不发送)|"low"|"medium"|"high"|"xhigh"|"max"
|
||||
priority?: number; // 轮询顺位,越大越先用
|
||||
pool_exclude?: boolean; // true=不作为故障转移目标
|
||||
}) => post<{ id: number }>("/llm/profiles", p),
|
||||
@@ -464,8 +610,7 @@ export const api = {
|
||||
// ---- agents ----
|
||||
agents: () => get<{ agents: Agent[] }>("/agents").then((r) => arr(r.agents)),
|
||||
getAgent: (key: string) => get<AgentDetail>(`/agents/${key}`),
|
||||
createAgent: (key: string, name: string, description = "") =>
|
||||
post<Agent>("/agents", { key, name, description }),
|
||||
createAgent: (key: string, name: string, description = "") => post<Agent>("/agents", { key, name, description }),
|
||||
updateAgent: (key: string, name: string, description = "") =>
|
||||
patch<{ ok: boolean }>(`/agents/${key}`, { name, description }),
|
||||
deleteAgent: (key: string) => del<{ deleted: string }>(`/agents/${key}`),
|
||||
@@ -497,7 +642,8 @@ export const api = {
|
||||
sendConversationMessage: (id: number, message: string, attachments?: ChatAttachment[]) =>
|
||||
post<{ status: string }>(`/conversations/${id}/messages`, { message, attachments }),
|
||||
stopConversation: (id: number) => post<{ status: string }>(`/conversations/${id}/stop`, {}),
|
||||
saveAgentPrompt: (key: string, template: string, note = "") => put<{ version: number }>(`/agents/${key}/prompt`, { template, note }),
|
||||
saveAgentPrompt: (key: string, template: string, note = "") =>
|
||||
put<{ version: number }>(`/agents/${key}/prompt`, { template, note }),
|
||||
resetAgentPrompt: (key: string) => post<{ version: number }>(`/agents/${key}/prompt/reset`, {}),
|
||||
// 收尾提示词(超时/步数耗尽的 settlement 提示);prompt 空串=清除覆盖、用内置默认;
|
||||
// max_turns 省略则不动、传 0=用内置默认轮数
|
||||
@@ -509,7 +655,10 @@ export const api = {
|
||||
saveAgentTaskTimeoutWrapup: (key: string, prompt: string, maxTurns?: number) =>
|
||||
put<{ ok: boolean }>(`/agents/${key}/wrapup/task-timeout`, { prompt, max_turns: maxTurns }),
|
||||
resetAgentTaskTimeoutWrapup: (key: string) =>
|
||||
post<{ ok: boolean; task_timeout_wrapup_default: string; task_timeout_wrapup_max_turns_default: number }>(`/agents/${key}/wrapup/task-timeout/reset`, {}),
|
||||
post<{ ok: boolean; task_timeout_wrapup_default: string; task_timeout_wrapup_max_turns_default: number }>(
|
||||
`/agents/${key}/wrapup/task-timeout/reset`,
|
||||
{},
|
||||
),
|
||||
// P3 triggers (仅自定义 agent)
|
||||
agentTriggers: (key: string) =>
|
||||
get<{ triggers: AgentTrigger[] }>(`/agents/${key}/triggers`).then((r) => arr(r.triggers)),
|
||||
@@ -531,12 +680,15 @@ export const api = {
|
||||
trigger_max_parallel?: number;
|
||||
},
|
||||
) => put<{ ok: boolean }>(`/agents/${key}/config`, patch),
|
||||
agentPromptVersions: (key: string) => get<{ versions: PromptVersion[] }>(`/agents/${key}/prompts`).then((r) => arr(r.versions)),
|
||||
agentVariables: (key: string) => get<{ variables: PromptVar[] }>(`/agents/${key}/variables`).then((r) => arr(r.variables)),
|
||||
agentPromptVersions: (key: string) =>
|
||||
get<{ versions: PromptVersion[] }>(`/agents/${key}/prompts`).then((r) => arr(r.versions)),
|
||||
agentVariables: (key: string) =>
|
||||
get<{ variables: PromptVar[] }>(`/agents/${key}/variables`).then((r) => arr(r.variables)),
|
||||
previewAgentPrompt: (key: string, template: string, sample?: Record<string, string>) =>
|
||||
post<{ rendered: string; error?: string }>(`/agents/${key}/prompt/preview`, { template, sample }),
|
||||
getAgentVisibility: (key: string) => get<{ mcp: number[]; skill: string[] }>(`/agents/${key}/visibility`),
|
||||
setAgentVisibility: (key: string, mcp: number[], skill: string[]) => put<{ ok: boolean }>(`/agents/${key}/visibility`, { mcp, skill }),
|
||||
setAgentVisibility: (key: string, mcp: number[], skill: string[]) =>
|
||||
put<{ ok: boolean }>(`/agents/${key}/visibility`, { mcp, skill }),
|
||||
|
||||
// ---- tools (内置工具目录) ----
|
||||
tools: () => get<{ tools: Tool[] }>("/tools").then((r) => arr(r.tools)),
|
||||
@@ -544,10 +696,13 @@ export const api = {
|
||||
put<{ ok: boolean }>(`/tools/${key}`, patch),
|
||||
resetTool: (key: string) => post<{ ok: boolean }>(`/tools/${key}/reset`, {}),
|
||||
// custom tools (自定义工具)
|
||||
createCustomTool: (t: Pick<Tool, "key" | "description" | "schema" | "agents" | "enabled" | "kind" | "exec" | "deferred">) =>
|
||||
post<{ key: string }>("/tools/custom", t),
|
||||
updateCustomTool: (key: string, t: Pick<Tool, "description" | "schema" | "agents" | "enabled" | "kind" | "exec" | "deferred">) =>
|
||||
put<{ ok: boolean }>(`/tools/custom/${key}`, t),
|
||||
createCustomTool: (
|
||||
t: Pick<Tool, "key" | "description" | "schema" | "agents" | "enabled" | "kind" | "exec" | "deferred">,
|
||||
) => post<{ key: string }>("/tools/custom", t),
|
||||
updateCustomTool: (
|
||||
key: string,
|
||||
t: Pick<Tool, "description" | "schema" | "agents" | "enabled" | "kind" | "exec" | "deferred">,
|
||||
) => put<{ ok: boolean }>(`/tools/custom/${key}`, t),
|
||||
deleteCustomTool: (key: string) => del<{ deleted: string }>(`/tools/custom/${key}`),
|
||||
testCustomTool: (body: { kind: string; exec: Record<string, unknown>; params: Record<string, unknown> }) =>
|
||||
post<{ output: string; is_error: boolean }>("/tools/custom/test", body),
|
||||
@@ -591,8 +746,14 @@ export const api = {
|
||||
|
||||
// ---- skills (文件系统) ----
|
||||
skills: () => get<{ skills: SkillItem[] }>("/skills").then((r) => arr(r.skills)),
|
||||
createSkill: (s: { name: string; description: string; license?: string; compatibility?: string; mcps?: string[]; instructions?: string }) =>
|
||||
post<{ name: string }>("/skills", s),
|
||||
createSkill: (s: {
|
||||
name: string;
|
||||
description: string;
|
||||
license?: string;
|
||||
compatibility?: string;
|
||||
mcps?: string[];
|
||||
instructions?: string;
|
||||
}) => post<{ name: string }>("/skills", s),
|
||||
// uploadSkill installs a skill from a .zip (multipart). Surfaces the backend
|
||||
// error text (e.g. 已存在 / 缺少 SKILL.md) so the UI can show a precise message.
|
||||
uploadSkill: async (file: File, overwrite = false): Promise<{ name: string; files: number }> => {
|
||||
@@ -610,20 +771,25 @@ export const api = {
|
||||
return body;
|
||||
},
|
||||
deleteSkill: (name: string) => del<{ deleted: string }>(`/skills/${name}`),
|
||||
updateSkillMeta: (name: string, meta: { mcps?: string[]; description?: string; license?: string; compatibility?: string }) =>
|
||||
put<{ ok: boolean }>(`/skills/${name}/meta`, meta),
|
||||
createSkillDir: (skill: string, path: string) =>
|
||||
post<{ dir: string }>(`/skills/${skill}/dirs`, { path }),
|
||||
updateSkillMeta: (
|
||||
name: string,
|
||||
meta: { mcps?: string[]; description?: string; license?: string; compatibility?: string },
|
||||
) => put<{ ok: boolean }>(`/skills/${name}/meta`, meta),
|
||||
createSkillDir: (skill: string, path: string) => post<{ dir: string }>(`/skills/${skill}/dirs`, { path }),
|
||||
skillFiles: (name: string) => get<{ files: string[] }>(`/skills/${name}/files`).then((r) => r.files),
|
||||
readSkillFile: (name: string, file: string) =>
|
||||
get<{ content: string; file: string }>(`/skills/${name}/files/${file}`).then((r) => r.content),
|
||||
writeSkillFile: (name: string, file: string, content: string) =>
|
||||
put<{ ok: boolean }>(`/skills/${name}/files/${file}`, { content }),
|
||||
deleteSkillPath: (skill: string, path: string) =>
|
||||
del<{ deleted: string }>(`/skills/${skill}/files/${path}`),
|
||||
deleteSkillPath: (skill: string, path: string) => del<{ deleted: string }>(`/skills/${skill}/files/${path}`),
|
||||
skillUsage: (name: string, limit = 50) =>
|
||||
get<{ calls: SkillCall[] }>(`/skills/${name}/usage?limit=${limit}`).then((r) => arr(r.calls)),
|
||||
missingSkills: (limit = 20) =>
|
||||
get<{ missing: MissingSkill[] }>(`/skills/missing?limit=${limit}`).then((r) => arr(r.missing)),
|
||||
|
||||
// ---- visibility (MCP resource side) ---- (agent ids are strings per spec)
|
||||
resourceVisibility: (kind: string, id: number) => get<{ agents: string[] }>(`/visibility/${kind}/${id}`).then((r) => arr(r.agents)),
|
||||
resourceVisibility: (kind: string, id: number) =>
|
||||
get<{ agents: string[] }>(`/visibility/${kind}/${id}`).then((r) => arr(r.agents)),
|
||||
toggleVisibility: (agentId: string, kind: string, resourceId: number, visible: boolean) =>
|
||||
post<{ ok: boolean }>("/visibility/toggle", { agent_id: agentId, kind, resource_id: resourceId, visible }),
|
||||
|
||||
@@ -648,7 +814,8 @@ export const api = {
|
||||
interceptDecide: (id: number, decision: "allowed" | "denied") =>
|
||||
post<{ ok: boolean }>(`/intercept/pending/${id}/decide`, { decision }),
|
||||
interceptHistory: () => get<{ items: InterceptApprovalRow[] }>("/intercept/history").then((r) => arr(r.items)),
|
||||
interceptTask: (taskId: string) => get<{ items: InterceptApprovalRow[] }>(`/intercept/task/${taskId}`).then((r) => arr(r.items)),
|
||||
interceptTask: (taskId: string) =>
|
||||
get<{ items: InterceptApprovalRow[] }>(`/intercept/task/${taskId}`).then((r) => arr(r.items)),
|
||||
|
||||
// ---- intercept tool-config (全局工具拦截范围) ----
|
||||
interceptGetToolConfig: async (): Promise<{ enabled_tools: string[] }> => {
|
||||
@@ -696,6 +863,9 @@ export const api = {
|
||||
},
|
||||
llmRecordDetail: (id: number) => get<LLMRecordDetail>(`/llm/records/${id}`),
|
||||
llmTasks: () => get<{ tasks: LLMTask[] }>(`/llm/records/tasks`),
|
||||
llmRecordsDeleteTask: (task: string) =>
|
||||
del<{ deleted: number }>(`/llm/records?task=${encodeURIComponent(task)}`),
|
||||
llmRecordsDeleteTask: (task: string) => del<{ deleted: number }>(`/llm/records?task=${encodeURIComponent(task)}`),
|
||||
// 按模型聚合本任务的 token 用量(来自常开的 llm_usage 计量账本,逐次调用精确,
|
||||
// per-agent 绑定 / 轮询 / 中断消耗都覆盖)。
|
||||
tokensByModel: (task: string) =>
|
||||
get<{ models: ModelTokenStat[] }>(`/llm/records/by-model?task=${encodeURIComponent(task)}`),
|
||||
};
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
Activity, Agent, AgentDetail, Asset, Audit, Company, Conversation,
|
||||
ConvTokenSummary, DailyTokenBucket, Edge, Finding, InterceptApprovalRow,
|
||||
InterceptPending, InterceptRule, LLMPoolStatus, LLMProfile, MCPServer, MCPTool, PromptVar,
|
||||
PromptVersion, Settings, SkillItem, Stats, TaskNode, Task, TokenTotal,
|
||||
PromptVersion, Settings, SkillCall, SkillItem, MissingSkill, Stats, TaskNode, Task, TokenTotal,
|
||||
TokenUsage, Tool, TrafficResp, TrafficDetail, TrafficHost, LLMTask,
|
||||
} from "@/lib/types";
|
||||
|
||||
@@ -617,14 +617,14 @@ export const audit: Audit = {
|
||||
|
||||
// ── LLM profiles ─────────────────────────────────────────────────────────────
|
||||
export const llmProfiles: LLMProfile[] = [
|
||||
{ id: "1", name: "Claude Opus 4.8", format: "anthropic", model: "claude-opus-4-8", api_key_hint: "…a3f2", rate_per_second: 0, rate_per_minute: 0, context_window_k: 1000, reasoning_effort: "high", is_default: true, priority: 0, pool_exclude: false },
|
||||
{ id: "2", name: "DeepSeek V4", format: "openai", base_url: "https://api.deepseek.com", model: "deepseek-v4-flash", api_key_hint: "…9c11", rate_per_second: 0, rate_per_minute: 60, context_window_k: 128, reasoning_effort: "", is_default: false, priority: 10, pool_exclude: false },
|
||||
{ id: "1", name: "Claude Opus 4.8", format: "anthropic", model: "claude-opus-4-8", api_key_hint: "…a3f2", rate_per_second: 0, rate_per_minute: 0, context_window_k: 1000, thinking_type: "enabled", reasoning_effort: "high", is_default: true, priority: 0, pool_exclude: false },
|
||||
{ id: "2", name: "DeepSeek V4", format: "openai", base_url: "https://api.deepseek.com", model: "deepseek-v4-flash", api_key_hint: "…9c11", rate_per_second: 0, rate_per_minute: 60, context_window_k: 128, thinking_type: "", reasoning_effort: "", is_default: false, priority: 10, pool_exclude: false },
|
||||
];
|
||||
|
||||
export const llmConfig = {
|
||||
configured: true, provider: "anthropic", model: "claude-opus-4-8",
|
||||
base_url: "", proxy: "", key_set: true,
|
||||
rate_per_second: 0, rate_per_minute: 0, context_window_k: 1000, reasoning_effort: "high",
|
||||
rate_per_second: 0, rate_per_minute: 0, context_window_k: 1000, thinking_type: "enabled", reasoning_effort: "high",
|
||||
};
|
||||
|
||||
// ── Agents ───────────────────────────────────────────────────────────────────
|
||||
@@ -686,9 +686,19 @@ export const mcpToolsById: Record<number, MCPTool[]> = {
|
||||
|
||||
// ── Skills ───────────────────────────────────────────────────────────────────
|
||||
export const skills: SkillItem[] = [
|
||||
{ name: "api-recon", description: "对 REST/GraphQL API 做侦察与越权面枚举;发现新 API 端点时使用。", license: "MIT", mcps: [], files: ["SKILL.md", "scripts/enum.py"] },
|
||||
{ name: "playwright-cli", description: "用 Playwright 驱动浏览器做动态爬取与截图;需要渲染 JS 站点时使用。", mcps: ["playwright"], files: ["SKILL.md"] },
|
||||
{ name: "scopesentry", description: "从 ScopeSentry 拉取资产并归并到公司范围;批量导入资产时使用。", files: ["SKILL.md", "assets/mapping.md"] },
|
||||
{ name: "api-recon", description: "对 REST/GraphQL API 做侦察与越权面枚举;发现新 API 端点时使用。", license: "MIT", mcps: [], files: ["SKILL.md", "scripts/enum.py"], calls: 24, tasks: 6, usage_agents: ["worker", "planner"], last_used: "2026-08-20T09:12:00Z" },
|
||||
{ name: "playwright-cli", description: "用 Playwright 驱动浏览器做动态爬取与截图;需要渲染 JS 站点时使用。", mcps: ["playwright"], files: ["SKILL.md"], calls: 7, tasks: 3, usage_agents: ["worker"], last_used: "2026-08-19T21:40:00Z" },
|
||||
{ name: "scopesentry", description: "从 ScopeSentry 拉取资产并归并到公司范围;批量导入资产时使用。", files: ["SKILL.md", "assets/mapping.md"], calls: 0, tasks: 0, usage_agents: [] },
|
||||
];
|
||||
|
||||
export const skillCalls: SkillCall[] = [
|
||||
{ ts: "2026-08-20T09:12:00Z", agent_key: "worker", task_id: 42, session_id: "", args_len: 128 },
|
||||
{ ts: "2026-08-20T08:03:00Z", agent_key: "planner", task_id: 42, session_id: "", args_len: 0 },
|
||||
{ ts: "2026-08-19T17:55:00Z", agent_key: "worker", task_id: 37, session_id: "", args_len: 64 },
|
||||
];
|
||||
|
||||
export const missingSkills: MissingSkill[] = [
|
||||
{ skill: "jwt-forge", calls: 3, agents: ["worker"], last_used: "2026-08-20T07:20:00Z" },
|
||||
];
|
||||
|
||||
// ── Tools ────────────────────────────────────────────────────────────────────
|
||||
|
||||
+105
-16
@@ -149,6 +149,74 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
if (seg[0] === "tasks" && seg[2] === "coverage-graph") return D.coverageGraph;
|
||||
if (seg[0] === "tasks" && seg[2] === "asset-refs") return D.assetRefsFor(Number(q.get("asset_id") ?? 0));
|
||||
|
||||
// ── 任务测试范围(增删查)──
|
||||
if (seg[0] === "tasks" && seg[2] === "scope" && seg.length === 3 && m === "GET") return { scope: [] };
|
||||
if (seg[0] === "tasks" && seg[2] === "scope" && seg.length === 3 && m === "POST")
|
||||
return { id: Date.now(), task_id: Number(seg[1]), kind: b.kind, domain: b.value, source: "manual" };
|
||||
if (seg[0] === "tasks" && seg[2] === "scope" && seg.length === 4 && m === "DELETE") return { ok: true };
|
||||
|
||||
// ── 全局 llm_usage 聚合(仪表盘新版视图,demo)──
|
||||
if (path === "/tokens/usage")
|
||||
return {
|
||||
by_profile: [
|
||||
{
|
||||
profile_name: "default",
|
||||
calls: 60,
|
||||
tasks: 4,
|
||||
input_tokens: 1570000,
|
||||
output_tokens: 110000,
|
||||
cache_read_tokens: 1120000,
|
||||
cache_write_tokens: 140000,
|
||||
},
|
||||
],
|
||||
daily: [
|
||||
{
|
||||
profile_name: "default",
|
||||
date: "2026-08-18",
|
||||
input_tokens: 520000,
|
||||
output_tokens: 38000,
|
||||
cache_read_tokens: 370000,
|
||||
},
|
||||
{
|
||||
profile_name: "default",
|
||||
date: "2026-08-19",
|
||||
input_tokens: 640000,
|
||||
output_tokens: 45000,
|
||||
cache_read_tokens: 460000,
|
||||
},
|
||||
{
|
||||
profile_name: "default",
|
||||
date: "2026-08-20",
|
||||
input_tokens: 410000,
|
||||
output_tokens: 27000,
|
||||
cache_read_tokens: 290000,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// ── 按模型 token 用量(demo:一条示例)──
|
||||
if (path === "/llm/records/by-model")
|
||||
return {
|
||||
models: [
|
||||
{
|
||||
model: "claude-opus-4-6",
|
||||
calls: 42,
|
||||
input_tokens: 1250000,
|
||||
output_tokens: 86000,
|
||||
cache_read_tokens: 940000,
|
||||
cache_write_tokens: 120000,
|
||||
},
|
||||
{
|
||||
model: "claude-haiku-4-5",
|
||||
calls: 18,
|
||||
input_tokens: 320000,
|
||||
output_tokens: 24000,
|
||||
cache_read_tokens: 180000,
|
||||
cache_write_tokens: 20000,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// ── 工作空间文件管理器(demo:静态示例树;写/建/删走下方写兜底 {ok:true})──
|
||||
if (path === "/workspace/list") return D.workspaceList(q.get("path") ?? "");
|
||||
if (path === "/workspace/read") return D.workspaceRead(q.get("path") ?? "");
|
||||
@@ -200,18 +268,12 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
};
|
||||
}
|
||||
// 单条 finding:GET 详情 / PATCH 改状态/严重度/名称/类别(demo 直接改内存对象)。
|
||||
if (
|
||||
seg[0] === "exploration" &&
|
||||
seg[1] === "findings" &&
|
||||
seg.length === 3 &&
|
||||
seg[2] !== "stats"
|
||||
) {
|
||||
if (seg[0] === "exploration" && seg[1] === "findings" && seg.length === 3 && seg[2] !== "stats") {
|
||||
const f = mockFindings.find((x) => x.id === seg[2]);
|
||||
if (!f) return {};
|
||||
if (m === "PATCH") {
|
||||
if (typeof b.status === "string") f.status = b.status as typeof f.status;
|
||||
if (typeof b.severity === "string")
|
||||
f.severity = b.severity as typeof f.severity;
|
||||
if (typeof b.severity === "string") f.severity = b.severity as typeof f.severity;
|
||||
if (typeof b.name === "string") f.name = b.name;
|
||||
if (typeof b.vulnclass === "string") f.vulnclass = b.vulnclass;
|
||||
}
|
||||
@@ -288,7 +350,9 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
if (path === "/exploration/activity" && seg.length === 2) {
|
||||
const since = Number(q.get("since") ?? 0);
|
||||
const limit = Math.max(1, Number(q.get("limit") ?? 300));
|
||||
const items = D.activityForTask().filter((item) => item.seq > since).slice(0, limit);
|
||||
const items = D.activityForTask()
|
||||
.filter((item) => item.seq > since)
|
||||
.slice(0, limit);
|
||||
return { items, cursor: items.length ? items[items.length - 1].seq : since };
|
||||
}
|
||||
if (seg[0] === "exploration" && seg[1] === "activity" && seg.length === 3) {
|
||||
@@ -309,7 +373,8 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
if (path === "/settings" && m === "PUT") return { ...D.settings, ...b };
|
||||
if (path === "/settings/web-search/test") return { ok: true, count: 5, backend: D.settings.web_search_backend };
|
||||
if (path === "/settings/python/detect") return { python_interpreter: "/usr/bin/python3" };
|
||||
if (path === "/chat") return { reply: "(demo)我已把该建议注入为一条高优意图,work agent 会尽快执行。", mode: "hint" };
|
||||
if (path === "/chat")
|
||||
return { reply: "(demo)我已把该建议注入为一条高优意图,work agent 会尽快执行。", mode: "hint" };
|
||||
if (path === "/gc") return { removed: 0 };
|
||||
|
||||
// ── 工具执行历史 ──
|
||||
@@ -337,11 +402,20 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
if (path === "/llm/pool" && m === "GET") return D.llmPool;
|
||||
if (path === "/llm/pool/reset")
|
||||
return { ...D.llmPool, chain: D.llmPool.chain.map((c) => ({ ...c, state: "ok", fails: 0, cooldown_secs: 0 })) };
|
||||
if (seg[0] === "llm" && seg[1] === "profiles" && seg.length === 3 && m === "DELETE") return { deleted: Number(seg[2]) };
|
||||
if (seg[0] === "llm" && seg[1] === "profiles" && seg.length === 3 && m === "DELETE")
|
||||
return { deleted: Number(seg[2]) };
|
||||
|
||||
// ── agents ──
|
||||
if (path === "/agents" && m === "GET") return { agents: D.agents };
|
||||
if (path === "/agents" && m === "POST") return { id: "9", key: String(b.key ?? "custom"), name: String(b.name ?? ""), role: "custom", builtin: false, enabled: true };
|
||||
if (path === "/agents" && m === "POST")
|
||||
return {
|
||||
id: "9",
|
||||
key: String(b.key ?? "custom"),
|
||||
name: String(b.name ?? ""),
|
||||
role: "custom",
|
||||
builtin: false,
|
||||
enabled: true,
|
||||
};
|
||||
if (seg[0] === "agents" && seg.length === 2 && m === "GET") return D.agentDetail(seg[1]);
|
||||
if (seg[0] === "agents" && seg[2] === "triggers" && m === "GET") return { triggers: [] };
|
||||
if (seg[0] === "agents" && seg[2] === "prompts") return { versions: D.agentDetail(seg[1]).versions };
|
||||
@@ -352,7 +426,14 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
|
||||
// ── conversations ──
|
||||
if (path === "/conversations" && m === "GET") return { conversations: D.conversations };
|
||||
if (path === "/conversations" && m === "POST") return { id: 3, agent_key: String(b.agent_key ?? "mainagent"), title: String(b.title ?? "新会话"), created_at: "2026-07-26T04:00:00Z", updated_at: "2026-07-26T04:00:00Z" };
|
||||
if (path === "/conversations" && m === "POST")
|
||||
return {
|
||||
id: 3,
|
||||
agent_key: String(b.agent_key ?? "mainagent"),
|
||||
title: String(b.title ?? "新会话"),
|
||||
created_at: "2026-07-26T04:00:00Z",
|
||||
updated_at: "2026-07-26T04:00:00Z",
|
||||
};
|
||||
if (seg[0] === "conversations" && seg[2] === "messages" && seg.length === 3 && m === "GET") {
|
||||
const items = D.conversationMessages[Number(seg[1])] ?? [];
|
||||
return { items, cursor: items.length ? items[items.length - 1].seq : 0, running: false };
|
||||
@@ -378,13 +459,16 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
if (seg[0] === "mcp" && seg.length === 2 && m === "DELETE") return { deleted: Number(seg[1]) };
|
||||
|
||||
// ── scopesentry(demo:未配置)──
|
||||
if (path === "/sync/scopesentry/status") return { exists: false, configured: false, enabled: false, reachable: false, tools: [] };
|
||||
if (path === "/sync/scopesentry/status")
|
||||
return { exists: false, configured: false, enabled: false, reachable: false, tools: [] };
|
||||
if (path === "/sync/scopesentry/projects") return { projects: [], tag: {} };
|
||||
if (path === "/sync/scopesentry/tasks") return { tasks: [] };
|
||||
if (path === "/sync/scopesentry/sync") return { synced: {}, companies: null, warnings: null, errors: null };
|
||||
|
||||
// ── skills ──
|
||||
if (path === "/skills" && m === "GET") return { skills: D.skills };
|
||||
if (path === "/skills/missing") return { missing: D.missingSkills };
|
||||
if (seg[0] === "skills" && seg[2] === "usage") return { calls: D.skillCalls };
|
||||
if (path === "/skills" && m === "POST") return { name: String(b.name ?? "new-skill") };
|
||||
if (seg[0] === "skills" && seg[2] === "files" && seg.length === 3) return { files: ["SKILL.md"] };
|
||||
if (seg[0] === "skills" && seg[2] === "files" && seg.length >= 4)
|
||||
@@ -395,7 +479,8 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
|
||||
// ── intercept ──
|
||||
if (path === "/intercept/rules" && m === "GET") return { rules: D.interceptRules };
|
||||
if (seg[0] === "intercept" && seg[1] === "rules" && seg[3] === "toggle") return { ok: true, enabled: b.enabled ?? true };
|
||||
if (seg[0] === "intercept" && seg[1] === "rules" && seg[3] === "toggle")
|
||||
return { ok: true, enabled: b.enabled ?? true };
|
||||
if (path === "/intercept/pending" && m === "GET") return { pending: D.interceptPending };
|
||||
if (seg[0] === "intercept" && seg[1] === "pending" && seg[3] === "decide") return { ok: true };
|
||||
if (seg[0] === "intercept" && seg[1] === "pending" && seg.length === 3 && m === "GET")
|
||||
@@ -409,5 +494,9 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
if (["POST", "PUT", "PATCH", "DELETE"].includes(m)) return { ok: true };
|
||||
|
||||
// ── 读兜底:集合类给 [],其余 {} ──
|
||||
return /(\/(tasks|profiles|conversations|rules|history|projects|tokens|agents|servers|skills|tools|findings|intents)s?$)|s$/.test(path) ? [] : {};
|
||||
return /(\/(tasks|profiles|conversations|rules|history|projects|tokens|agents|servers|skills|tools|findings|intents)s?$)|s$/.test(
|
||||
path,
|
||||
)
|
||||
? []
|
||||
: {};
|
||||
}
|
||||
+95
-22
@@ -225,14 +225,26 @@ export interface WorkspaceFile {
|
||||
content?: string;
|
||||
}
|
||||
|
||||
// 任务测试范围的一条(覆盖度分母 + 授权边界)。
|
||||
export interface TaskScopeRow {
|
||||
id: number;
|
||||
task_id: number;
|
||||
kind: "company" | "root_domain" | "subdomain" | "ip" | "cidr";
|
||||
company_id?: number;
|
||||
domain?: string;
|
||||
net?: string;
|
||||
source: "auto" | "agent" | "manual";
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
// 公司资产范围规则的一条(归属唯一真值来源)。
|
||||
export interface ScopeRow {
|
||||
id: number;
|
||||
company_id: number;
|
||||
kind: "domain" | "ip" | "cidr";
|
||||
domain?: string; // kind=domain 时有值
|
||||
net?: string; // kind=ip|cidr 时有值
|
||||
raw: string; // 原始用户输入,用于显示和回填
|
||||
domain?: string; // kind=domain 时有值
|
||||
net?: string; // kind=ip|cidr 时有值
|
||||
raw: string; // 原始用户输入,用于显示和回填
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
@@ -353,11 +365,11 @@ export type ActivityKind =
|
||||
| "thinking"
|
||||
| "result"
|
||||
| "user"
|
||||
| "intent" // LLM-generated exploration objective leading a worker session (UI-synthesized)
|
||||
| "round" // planner round boundary marker (engine-emitted)
|
||||
| "usage" // live cumulative token usage (per model turn); not rendered
|
||||
| "llm_switch" // automatic/manual task-level LLM switch
|
||||
| "llm_failover" // task-level provider switch / chain exhaustion audit event
|
||||
| "intent" // LLM-generated exploration objective leading a worker session (UI-synthesized)
|
||||
| "round" // planner round boundary marker (engine-emitted)
|
||||
| "usage" // live cumulative token usage (per model turn); not rendered
|
||||
| "llm_switch" // automatic/manual task-level LLM switch
|
||||
| "llm_failover" // task-level provider switch / chain exhaustion audit event
|
||||
| "intercept_request"; // user-approval request from the intercept layer
|
||||
|
||||
// ChatAttachment 是一次上传的文件:path 相对该会话/任务工作目录(即 agent 的 CWD)。
|
||||
@@ -475,6 +487,43 @@ export interface TokenTotal {
|
||||
cache_write_tokens: number;
|
||||
}
|
||||
|
||||
// Global per-profile token spend from the llm_usage ledger (GET /api/tokens/usage).
|
||||
export interface ProfileUsage {
|
||||
profile_name: string;
|
||||
calls: number;
|
||||
tasks: number;
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
cache_read_tokens: number;
|
||||
cache_write_tokens: number;
|
||||
}
|
||||
|
||||
// One (profile, UTC day) token bucket for the dashboard's daily chart (new source).
|
||||
export interface ProfileDayUsage {
|
||||
profile_name: string;
|
||||
date: string; // YYYY-MM-DD
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
cache_read_tokens: number;
|
||||
}
|
||||
|
||||
// Response of GET /api/tokens/usage — the dashboard's "new" (llm_usage) token view.
|
||||
export interface UsageStats {
|
||||
by_profile: ProfileUsage[];
|
||||
daily: ProfileDayUsage[];
|
||||
}
|
||||
|
||||
// Per-model token usage for one task (GET /api/llm/records/by-model), from the
|
||||
// always-on llm_usage metering ledger. calls = number of LLM calls on this model.
|
||||
export interface ModelTokenStat {
|
||||
model: string;
|
||||
calls: number;
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
cache_read_tokens: number;
|
||||
cache_write_tokens: number;
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
id: string;
|
||||
role: SessionRole;
|
||||
@@ -574,7 +623,9 @@ export interface LLMProfile {
|
||||
rate_per_second: number;
|
||||
rate_per_minute: number;
|
||||
context_window_k?: number;
|
||||
// 思考模式: ""=默认(不发送) | "off"=关闭 | "low"/"medium"/"high"/"max"=开启并设强度
|
||||
// 思考开关(thinking.type): ""=不发送(默认) | "disabled"=关闭 | "enabled"=开启
|
||||
thinking_type?: string;
|
||||
// 思考强度: ""=不发送(默认) | "low"/"medium"/"high"/"xhigh"/"max"
|
||||
reasoning_effort?: string;
|
||||
is_default: boolean;
|
||||
// 轮询顺位:越大越先被选中。激活配置恒为链首,与本值无关。
|
||||
@@ -690,12 +741,34 @@ export interface MCPTool {
|
||||
// Fields align with the agentskills.io open specification.
|
||||
// description covers both "what the skill does" and "when to use it".
|
||||
export interface SkillItem {
|
||||
name: string; // unique key = directory name
|
||||
description?: string; // required per spec; covers what + when to use
|
||||
license?: string; // optional: SPDX identifier or free text
|
||||
name: string; // unique key = directory name
|
||||
description?: string; // required per spec; covers what + when to use
|
||||
license?: string; // optional: SPDX identifier or free text
|
||||
compatibility?: string; // optional: environment requirements
|
||||
mcps?: string[]; // MCP server names this skill unlocks on load
|
||||
files: string[]; // files in the skill directory
|
||||
mcps?: string[]; // MCP server names this skill unlocks on load
|
||||
files: string[]; // files in the skill directory
|
||||
// 调用统计(skill_usage 账本)。从未被调用过的 skill:calls=0、last_used 缺省。
|
||||
calls: number;
|
||||
tasks: number; // 加载过它的任务数(chat 会话不计入)
|
||||
usage_agents: string[]; // 加载过它的 agent key
|
||||
last_used?: string;
|
||||
}
|
||||
|
||||
// SkillCall 是一次 Skill() 调用(单个 skill 的最近调用列表)。
|
||||
export interface SkillCall {
|
||||
ts: string;
|
||||
agent_key: string;
|
||||
task_id: number; // 0 = 非任务场景(对话会话)
|
||||
session_id: string;
|
||||
args_len: number;
|
||||
}
|
||||
|
||||
// MissingSkill 是被点名但不存在的 skill —— "想用但没有"的缺口。
|
||||
export interface MissingSkill {
|
||||
skill: string;
|
||||
calls: number;
|
||||
agents: string[];
|
||||
last_used?: string;
|
||||
}
|
||||
|
||||
// ---- Tools (内置工具目录) ----
|
||||
@@ -708,12 +781,12 @@ export interface Tool {
|
||||
description: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
schema: Record<string, any>; // full JSON-Schema (object with properties)
|
||||
agents: string[]; // bound agent keys
|
||||
agents: string[]; // bound agent keys
|
||||
enabled: boolean;
|
||||
kind?: "builtin" | "shell" | "command" | "script" | "http"; // 自定义工具类型
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
exec?: Record<string, any>; // 自定义工具执行规格(kind!=builtin)
|
||||
deferred?: boolean; // schema 延迟(SearchExtraTools/ExecuteExtraTool)
|
||||
exec?: Record<string, any>; // 自定义工具执行规格(kind!=builtin)
|
||||
deferred?: boolean; // schema 延迟(SearchExtraTools/ExecuteExtraTool)
|
||||
}
|
||||
|
||||
// ---- Stats ----
|
||||
@@ -764,14 +837,14 @@ export interface InterceptPending {
|
||||
|
||||
// InterceptApprovalRow enriches InterceptPending with conversation/task and rule context.
|
||||
export interface InterceptApprovalRow extends InterceptPending {
|
||||
conv_title: string; // "" if no linked conversation
|
||||
conv_agent_key: string; // "" if no linked conversation
|
||||
rule_name: string; // "" if rule was deleted
|
||||
conv_title: string; // "" if no linked conversation
|
||||
conv_agent_key: string; // "" if no linked conversation
|
||||
rule_name: string; // "" if rule was deleted
|
||||
}
|
||||
|
||||
// ── 资产同步 (ScopeSentry 数据源) ──────────────────────────────────────────────
|
||||
export interface SSProject {
|
||||
id: string; // MongoDB ObjectID — used as filter.project
|
||||
id: string; // MongoDB ObjectID — used as filter.project
|
||||
name: string;
|
||||
logo?: string;
|
||||
AssetCount?: number;
|
||||
@@ -780,7 +853,7 @@ export interface SSProject {
|
||||
|
||||
export interface SSTask {
|
||||
id: string;
|
||||
name: string; // used as filter.task
|
||||
name: string; // used as filter.task
|
||||
status?: number;
|
||||
progress?: number;
|
||||
creatTime?: string;
|
||||
|
||||
@@ -45,6 +45,35 @@ export function radixOverlayWasOpenAtPointerDown(): boolean {
|
||||
return overlayOpenAtLastPointerDown;
|
||||
}
|
||||
|
||||
// copyText 把文本写入剪贴板,返回是否成功。
|
||||
// 背景:navigator.clipboard 仅在安全上下文(HTTPS / localhost)可用;通过 IP + HTTP
|
||||
// 访问时它为 undefined,此时降级到 execCommand("copy")。
|
||||
export async function copyText(text: string): Promise<boolean> {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
// 继续走降级方案
|
||||
}
|
||||
}
|
||||
try {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.left = "-9999px";
|
||||
textarea.style.top = "0";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
const ok = document.execCommand("copy");
|
||||
document.body.removeChild(textarea);
|
||||
return ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export const getInitials = (str: string): string => {
|
||||
if (typeof str !== "string" || !str.trim()) return "?";
|
||||
|
||||
|
||||
Reference in new issue
Block a user