This commit is contained in:
pphua committed 2026-08-21 11:16:19 +08:00
1 parent 9793b522d9
commit b7e0b93023
46 files changed
+3632 -899

No files matched your search

+12 -2
View File
@@ -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
+1
View File
@@ -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()
+1
View File
@@ -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>,先建好。
+1
View File
@@ -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
View File
@@ -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}
}
+47
View File
@@ -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{}
}
+1 -1
View File
@@ -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
+13
View File
@@ -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
+12
View File
@@ -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
View File
@@ -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
}
+2 -2
View File
@@ -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
View File
@@ -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)
+3 -3
View File
@@ -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 {
+51
View File
@@ -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
View File
@@ -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
View File
@@ -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. 内置工具目录
-- =====================================================================
+200
View File
@@ -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()
}
+116
View File
@@ -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
View File
@@ -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(`
+2
View File
@@ -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
View File
@@ -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))
+165
View File
@@ -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
View File
@@ -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;
+49
View File
@@ -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) {
+2
View File
@@ -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
View File
@@ -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
+3
View File
@@ -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
View File
@@ -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
View File
@@ -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))
+88
View File
@@ -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)
}
}
+8 -9
View File
@@ -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}
+288 -306
View File
@@ -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 ? (
+196 -8
View File
@@ -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));
}
+341 -97
View File
@@ -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>
+50 -50
View File
@@ -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>
+425 -62
View File
@@ -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">
+67
View File
@@ -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
View File
@@ -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)}`),
};
+17 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+29
View File
@@ -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 "?";