feat(api): integrate task workflows and mock contracts

This commit is contained in:
pphua committed 2026-08-24 19:31:16 +08:00
1 parent ef57f3f196
commit ec971c63d2
9 files changed
+5809 -778

No files matched your search

+210 -10
View File
@@ -31,6 +31,11 @@ CREATE TABLE IF NOT EXISTS assets (
'root_domain','ip','subdomain','app','service','endpoint'
)),
company_id BIGINT REFERENCES companies(id) ON DELETE SET NULL,
-- explicit: caller/user selected the company; scope: derived from company_scope.
-- Existing installations are conservatively migrated as explicit so a scope
-- rebuild can never erase a historical manual association.
company_source TEXT NOT NULL DEFAULT 'explicit'
CHECK (company_source IN ('explicit','scope')),
task_ids BIGINT[] NOT NULL DEFAULT '{}',
domain TEXT,
root_domain TEXT,
@@ -84,6 +89,13 @@ CREATE INDEX IF NOT EXISTS idx_av2_bound_domains ON assets USING GIN(bound_domai
CREATE INDEX IF NOT EXISTS idx_av2_open_ports ON assets USING GIN(open_ports) WHERE type = 'ip';
CREATE INDEX IF NOT EXISTS idx_av2_last_seen ON assets(last_seen DESC);
CREATE INDEX IF NOT EXISTS idx_av2_type_seen ON assets(type, last_seen DESC);
ALTER TABLE assets ADD COLUMN IF NOT EXISTS company_source TEXT;
UPDATE assets SET company_source = 'explicit' WHERE company_source IS NULL;
ALTER TABLE assets ALTER COLUMN company_source SET DEFAULT 'explicit';
ALTER TABLE assets ALTER COLUMN company_source SET NOT NULL;
ALTER TABLE assets DROP CONSTRAINT IF EXISTS assets_company_source_check;
ALTER TABLE assets ADD CONSTRAINT assets_company_source_check
CHECK (company_source IN ('explicit','scope'));
DROP TRIGGER IF EXISTS trg_av2_upd ON assets;
CREATE TRIGGER trg_av2_upd BEFORE UPDATE ON assets
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
@@ -91,17 +103,36 @@ CREATE TRIGGER trg_av2_upd BEFORE UPDATE ON assets
CREATE TABLE IF NOT EXISTS company_scope (
id BIGSERIAL PRIMARY KEY,
company_id BIGINT NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK (kind IN ('domain','ip','cidr')),
kind TEXT NOT NULL CHECK (kind IN ('domain','ip','cidr','icp','keyword')),
domain TEXT,
net CIDR,
value TEXT,
raw TEXT NOT NULL,
reason TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT uq_sv2_domain UNIQUE (company_id, domain),
CONSTRAINT uq_sv2_net UNIQUE (company_id, net)
CONSTRAINT uq_sv2_net UNIQUE (company_id, net),
CONSTRAINT ck_company_scope_payload CHECK (
(kind = 'domain' AND domain IS NOT NULL AND net IS NULL AND value IS NULL)
OR (kind IN ('ip','cidr') AND domain IS NULL AND net IS NOT NULL AND value IS NULL)
OR (kind IN ('icp','keyword') AND domain IS NULL AND net IS NULL AND value IS NOT NULL)
)
);
-- Existing installations need the new text payload and expanded kind check.
ALTER TABLE company_scope ADD COLUMN IF NOT EXISTS value TEXT;
ALTER TABLE company_scope DROP CONSTRAINT IF EXISTS company_scope_kind_check;
ALTER TABLE company_scope ADD CONSTRAINT company_scope_kind_check
CHECK (kind IN ('domain','ip','cidr','icp','keyword'));
ALTER TABLE company_scope DROP CONSTRAINT IF EXISTS ck_company_scope_payload;
ALTER TABLE company_scope ADD CONSTRAINT ck_company_scope_payload CHECK (
(kind = 'domain' AND domain IS NOT NULL AND net IS NULL AND value IS NULL)
OR (kind IN ('ip','cidr') AND domain IS NULL AND net IS NOT NULL AND value IS NULL)
OR (kind IN ('icp','keyword') AND domain IS NULL AND net IS NULL AND value IS NOT NULL)
);
CREATE INDEX IF NOT EXISTS idx_sv2_domain ON company_scope(domain) WHERE kind = 'domain';
CREATE INDEX IF NOT EXISTS idx_sv2_net ON company_scope USING GIST(net inet_ops) WHERE kind IN ('ip','cidr');
CREATE UNIQUE INDEX IF NOT EXISTS uq_sv2_value ON company_scope(company_id, kind, value) WHERE kind IN ('icp','keyword');
CREATE INDEX IF NOT EXISTS idx_sv2_icp ON company_scope(value) WHERE kind = 'icp';
CREATE INDEX IF NOT EXISTS idx_sv2_company ON company_scope(company_id);
-- =====================================================================
@@ -129,19 +160,40 @@ CREATE TABLE IF NOT EXISTS exploration_nodes (
state TEXT NOT NULL DEFAULT 'open',
origin TEXT,
owner TEXT,
blocked_reason TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
completed_at TIMESTAMPTZ,
CONSTRAINT ck_node_kind CHECK (kind IN ('begin','goal','intent','fact','finding','hint')),
CONSTRAINT ck_node_state CHECK (
(kind='begin' AND state IN ('open')) OR
(kind='intent' AND state IN ('open','running','done','blocked','exhausted','stopped')) OR
(kind='intent' AND state IN ('open','running','paused','done','blocked','exhausted','stopped')) OR
(kind='goal' AND state IN ('open','met','abandoned')) OR
(kind='fact' AND state IN ('confirmed','dismissed','origin')) OR
(kind='finding' AND state IN ('confirmed','dismissed')) OR
(kind='hint' AND state IN ('active','consumed'))
)
);
ALTER TABLE exploration_nodes ADD COLUMN IF NOT EXISTS blocked_reason TEXT;
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM pg_constraint
WHERE conrelid='exploration_nodes'::regclass
AND conname='ck_node_state'
AND pg_get_constraintdef(oid) NOT LIKE '%paused%'
) THEN
ALTER TABLE exploration_nodes DROP CONSTRAINT ck_node_state;
ALTER TABLE exploration_nodes ADD CONSTRAINT ck_node_state CHECK (
(kind='begin' AND state IN ('open')) OR
(kind='intent' AND state IN ('open','running','paused','done','blocked','exhausted','stopped')) OR
(kind='goal' AND state IN ('open','met','abandoned')) OR
(kind='fact' AND state IN ('confirmed','dismissed','origin')) OR
(kind='finding' AND state IN ('confirmed','dismissed')) OR
(kind='hint' AND state IN ('active','consumed'))
);
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_expnodes_part ON exploration_nodes(exploration_id, kind);
CREATE INDEX IF NOT EXISTS idx_expnodes_frontier ON exploration_nodes(exploration_id, priority DESC)
WHERE kind='intent' AND state='open';
@@ -194,12 +246,14 @@ CREATE TABLE IF NOT EXISTS activity (
is_error BOOLEAN NOT NULL DEFAULT false,
summary TEXT,
detail TEXT,
metadata JSONB NOT NULL DEFAULT '{}',
input_tokens INTEGER,
output_tokens INTEGER,
cache_read_tokens INTEGER,
cache_write_tokens INTEGER,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALTER TABLE activity ADD COLUMN IF NOT EXISTS metadata JSONB NOT NULL DEFAULT '{}';
CREATE INDEX IF NOT EXISTS idx_act_node ON activity(exploration_id, node_id, id);
CREATE INDEX IF NOT EXISTS idx_act_since ON activity(exploration_id, id);
-- Main/Plan history pages filter by worker (both carry NULL node_id, so idx_act_node
@@ -227,8 +281,16 @@ 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)永远排链首,与本值无关。
-- pool_exclude true=不作为故障转移目标(仍可被 agent/任务显式绑定使用)。
priority INTEGER NOT NULL DEFAULT 0,
pool_exclude BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
@@ -236,13 +298,63 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_llm_one_default ON llm_profiles(is_default)
DROP TRIGGER IF EXISTS trg_llm_upd ON llm_profiles;
CREATE TRIGGER trg_llm_upd BEFORE UPDATE ON llm_profiles
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
-- 轮询顺位/排除标记;补旧库。默认 0 / false = 全部配置都参与轮询。
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),已到期的自然回到"正常",等下一次调用半开试探。
CREATE TABLE IF NOT EXISTS llm_profile_health (
profile_id BIGINT PRIMARY KEY REFERENCES llm_profiles(id) ON DELETE CASCADE,
fails INTEGER NOT NULL DEFAULT 0, -- 当前连续失败次数(成功即清零)
trips INTEGER NOT NULL DEFAULT 0, -- 累计熔断次数,用于冷却时间指数退避
open_until TIMESTAMPTZ, -- 冷却截止;NULL/过期 = 未熔断
last_error TEXT NOT NULL DEFAULT '',
last_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- =====================================================================
-- D. 任务层
-- =====================================================================
-- Global task categories are intentionally independent from task templates.
-- Deleting a category only moves its tasks back to the uncategorized bucket.
CREATE TABLE IF NOT EXISTS task_categories (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
nkey TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_task_categories_name ON task_categories(name, id);
DROP TRIGGER IF EXISTS trg_task_categories_upd ON task_categories;
CREATE TRIGGER trg_task_categories_upd BEFORE UPDATE ON task_categories
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE TABLE IF NOT EXISTS tasks (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
category_id BIGINT REFERENCES task_categories(id) ON DELETE SET NULL,
description TEXT NOT NULL,
goal TEXT NOT NULL,
exploration_id BIGINT NOT NULL UNIQUE
@@ -250,7 +362,12 @@ CREATE TABLE IF NOT EXISTS tasks (
status TEXT NOT NULL DEFAULT 'created'
CHECK (status IN ('created','running','paused','done','failed','timeout')),
paused BOOLEAN NOT NULL DEFAULT false,
queued BOOLEAN NOT NULL DEFAULT false,
queued_at TIMESTAMPTZ,
queue_mode TEXT NOT NULL DEFAULT '',
llm_profile_id BIGINT REFERENCES llm_profiles(id) ON DELETE SET NULL,
active_llm_profile_id BIGINT REFERENCES llm_profiles(id) ON DELETE SET NULL,
llm_chain_revision BIGINT NOT NULL DEFAULT 0,
company_id BIGINT REFERENCES companies(id) ON DELETE SET NULL,
parent_ref TEXT,
timeout_seconds INTEGER NOT NULL DEFAULT 0,
@@ -270,8 +387,6 @@ CREATE TRIGGER trg_tasks_upd BEFORE UPDATE ON tasks
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
-- planner 心跳触发间隔(秒);补旧库。默认 300s(5min)。见 docs/planner-trigger-impl-plan.md
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS plan_heartbeat_seconds INTEGER NOT NULL DEFAULT 300;
<<<<<<< Updated upstream
=======
-- 并发上限挂起态;补旧库。true=因并发上限排队、等待空位自动启动。
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS queued BOOLEAN NOT NULL DEFAULT false;
-- 资产覆盖度功能开关;补旧库。true(默认)=计算/展示测试覆盖度、自动累积测试范围、
@@ -285,8 +400,11 @@ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS queued_at TIMESTAMPTZ;
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS queue_mode TEXT NOT NULL DEFAULT '';
-- 可选的任务名称;补旧库。空串=未命名,前端展示时回退到描述。
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS name TEXT NOT NULL DEFAULT '';
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS category_id BIGINT REFERENCES task_categories(id) ON DELETE SET NULL;
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS active_llm_profile_id BIGINT REFERENCES llm_profiles(id) ON DELETE SET NULL;
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS llm_chain_revision BIGINT NOT NULL DEFAULT 0;
CREATE INDEX IF NOT EXISTS idx_tasks_category ON tasks(category_id, created_at DESC)
WHERE deleted_at IS NULL AND category_id IS NOT NULL;
-- Reusable task description/goal presets. nkey is the normalized, case-insensitive
-- identity used to reject visually equivalent duplicate names.
@@ -315,6 +433,55 @@ CREATE TABLE IF NOT EXISTS task_relations (
);
CREATE INDEX IF NOT EXISTS idx_task_relations_source ON task_relations(source_task_id);
-- Task/asset provenance supplements the legacy assets.task_ids association. The
-- array remains the compatibility source for existing query and cleanup paths;
-- this relation records how each association was obtained for operator review.
CREATE TABLE IF NOT EXISTS task_asset_links (
task_id BIGINT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
asset_id BIGINT NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
source TEXT NOT NULL DEFAULT 'system',
source_summary TEXT NOT NULL DEFAULT '',
source_node_id BIGINT REFERENCES exploration_nodes(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (task_id, asset_id)
);
CREATE INDEX IF NOT EXISTS idx_task_asset_links_asset ON task_asset_links(asset_id, task_id);
CREATE INDEX IF NOT EXISTS idx_task_asset_links_node ON task_asset_links(source_node_id)
WHERE source_node_id IS NOT NULL;
DROP TRIGGER IF EXISTS trg_task_asset_links_upd ON task_asset_links;
CREATE TRIGGER trg_task_asset_links_upd BEFORE UPDATE ON task_asset_links
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
-- Keep provenance rows synchronized when existing asset upsert paths append or
-- remove task ids. Detailed callers overwrite the generic source after upsert.
CREATE OR REPLACE FUNCTION sync_task_asset_links() RETURNS trigger AS $$
BEGIN
INSERT INTO task_asset_links(task_id, asset_id, source, source_summary)
SELECT task.id, NEW.id, 'system', '任务执行期间自动关联'
FROM unnest(NEW.task_ids) AS requested(task_id)
JOIN tasks task ON task.id=requested.task_id AND task.deleted_at IS NULL
ON CONFLICT (task_id, asset_id) DO NOTHING;
DELETE FROM task_asset_links link
WHERE link.asset_id=NEW.id
AND NOT (link.task_id=ANY(NEW.task_ids));
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_assets_task_links ON assets;
CREATE TRIGGER trg_assets_task_links AFTER INSERT OR UPDATE OF task_ids ON assets
FOR EACH ROW EXECUTE FUNCTION sync_task_asset_links();
-- Existing installations receive an auditable legacy source without rewriting
-- task_ids. Ignore stale array ids that no longer resolve to a live task.
INSERT INTO task_asset_links(task_id, asset_id, source, source_summary)
SELECT task.id, asset.id, 'legacy', '由历史任务资产关联迁移'
FROM assets asset
CROSS JOIN LATERAL unnest(asset.task_ids) AS requested(task_id)
JOIN tasks task ON task.id=requested.task_id AND task.deleted_at IS NULL
ON CONFLICT (task_id, asset_id) DO NOTHING;
-- Ordered task-level LLM failover chain. A quota-exhausted entry is skipped
-- until the user saves/resets the chain, which clears all failure state.
CREATE TABLE IF NOT EXISTS task_llm_profiles (
@@ -352,7 +519,6 @@ SET active_llm_profile_id = t.llm_profile_id
WHERE t.active_llm_profile_id IS NULL
AND t.llm_profile_id IS NOT NULL
AND EXISTS (SELECT 1 FROM task_llm_profiles x WHERE x.task_id=t.id AND x.profile_id=t.llm_profile_id);
>>>>>>> Stashed changes
-- 任务测试范围(资产覆盖度的分母 + 授权边界)。
-- 自动填(source='auto'):insertAssets 顶层按 worker 显式插入的资产类型加保守范围
@@ -363,17 +529,24 @@ WHERE t.active_llm_profile_id IS NULL
CREATE TABLE IF NOT EXISTS task_scope (
id BIGSERIAL PRIMARY KEY,
task_id BIGINT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK (kind IN ('company','root_domain','subdomain','ip','cidr')),
kind TEXT NOT NULL CHECK (kind IN ('company','root_domain','subdomain','ip','cidr','icp','keyword')),
company_id BIGINT REFERENCES companies(id) ON DELETE CASCADE, -- kind='company'
domain TEXT, -- root_domain / subdomain
net CIDR, -- ip / cidr
value TEXT, -- icp / keyword
source TEXT NOT NULL DEFAULT 'auto' CHECK (source IN ('auto','agent','manual')),
reason TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 旧库升级:扩展任务范围,使其与企业范围的单文本框识别能力一致。
ALTER TABLE task_scope ADD COLUMN IF NOT EXISTS value TEXT;
ALTER TABLE task_scope DROP CONSTRAINT IF EXISTS task_scope_kind_check;
ALTER TABLE task_scope ADD CONSTRAINT task_scope_kind_check
CHECK (kind IN ('company','root_domain','subdomain','ip','cidr','icp','keyword'));
-- 去重:同一 task 的同一条范围只存一次(自动填批量插入靠它幂等)。
CREATE UNIQUE INDEX IF NOT EXISTS uq_task_scope ON task_scope(
task_id, kind, COALESCE(domain,''), COALESCE(net::text,''), COALESCE(company_id,0));
DROP INDEX IF EXISTS uq_task_scope;
CREATE UNIQUE INDEX IF NOT EXISTS uq_task_scope_v2 ON task_scope(
task_id, kind, COALESCE(domain,''), COALESCE(net::text,''), COALESCE(company_id,0), COALESCE(value,''));
CREATE INDEX IF NOT EXISTS idx_ts_domain ON task_scope(domain) WHERE kind IN ('root_domain','subdomain');
CREATE INDEX IF NOT EXISTS idx_ts_net ON task_scope USING GIST(net inet_ops) WHERE kind IN ('ip','cidr');
CREATE INDEX IF NOT EXISTS idx_ts_company ON task_scope(company_id) WHERE kind = 'company';
@@ -392,7 +565,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 '',
@@ -412,6 +585,9 @@ 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
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
@@ -501,6 +677,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. 内置工具目录
-- =====================================================================
@@ -528,9 +724,13 @@ CREATE TABLE IF NOT EXISTS conversations (
agent_key TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '',
llm_profile_id BIGINT REFERENCES llm_profiles(id) ON DELETE SET NULL,
pinned_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALTER TABLE conversations ADD COLUMN IF NOT EXISTS pinned_at TIMESTAMPTZ;
CREATE INDEX IF NOT EXISTS idx_conversations_llm_profile ON conversations(llm_profile_id) WHERE llm_profile_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_conversations_pinned ON conversations(pinned_at DESC) WHERE pinned_at IS NOT NULL;
DROP TRIGGER IF EXISTS trg_conversations_upd ON conversations;
CREATE TRIGGER trg_conversations_upd BEFORE UPDATE ON conversations
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
+75 -3
View File
@@ -6,6 +6,8 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"testing"
"time"
@@ -92,15 +94,85 @@ func TestCoreTaskLifecyclePG(t *testing.T) {
t.Fatalf("list tasks: %d", code)
}
// delete → cascade removes the exploration subgraph
code, _ = doRetry("DELETE", "/api/tasks/"+id, nil)
// The task detail header consumes the top-level engine mode while some clients
// read the active-task snapshot. Keep both representations in sync.
code, out = doRetry("GET", "/api/stats?task="+id, nil)
if code != 200 {
t.Fatalf("task stats: %d (%v)", code, out)
}
activeTask, ok := out["active_task"].(map[string]any)
if !ok || activeTask["engine_mode"] != out["engine_mode"] {
t.Fatalf("engine mode mismatch: top=%v active_task=%v", out["engine_mode"], activeTask)
}
// delete → cascade removes the exploration subgraph and selected related data
taskDir := filepath.Join(m.dir, "tasks", id)
if err := os.MkdirAll(taskDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(taskDir, "artifact.txt"), []byte("test"), 0o644); err != nil {
t.Fatal(err)
}
transcriptBase := "exp" + strconv.FormatInt(expID, 10) + "-main"
transcriptPath := filepath.Join(m.dir, "transcripts", transcriptBase+".jsonl")
sidechainPath := filepath.Join(m.dir, "transcripts", transcriptBase)
if err := os.MkdirAll(filepath.Join(sidechainPath, "subagents"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(transcriptPath, []byte("{}\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(sidechainPath, "subagents", "child.jsonl"), []byte("{}\n"), 0o644); err != nil {
t.Fatal(err)
}
nid, _ := strconv.ParseInt(id, 10, 64)
findingID, err := m.pg.AddFinding(nid, 0, "__core_task_delete__", "", db.SeverityHigh, "summary", "evidence", "tester", nil)
if err != nil {
t.Fatal(err)
}
if err := m.pg.EnsureLLMRecordsTable(); err != nil {
t.Fatal(err)
}
if err := m.pg.InsertLLMRecord(&db.LLMRecord{TaskID: id, SessionID: "delete-test", Status: "ok", RequestBody: "secret"}); err != nil {
t.Fatal(err)
}
// A non-canonical path still resolves to the one canonical task key for the
// barrier, workspace, in-memory registry and DB delete.
code, out = doRetry("DELETE", "/api/tasks/000"+id, map[string]bool{
"delete_files": true,
"delete_findings": true,
"delete_llm_records": true,
})
if code != 200 {
t.Fatalf("delete task: %d", code)
}
nid, _ := strconv.ParseInt(id, 10, 64)
if deleted, _ := out["files_deleted"].(bool); !deleted {
t.Fatalf("expected files_deleted response, got %v", out)
}
if deleted, _ := out["findings_deleted"].(float64); deleted != 1 {
t.Fatalf("expected one deleted finding, got %v", out)
}
if deleted, _ := out["llm_records_deleted"].(float64); deleted != 1 {
t.Fatalf("expected one deleted LLM record, got %v", out)
}
if _, err := os.Stat(taskDir); !os.IsNotExist(err) {
t.Fatalf("task directory should be deleted, stat err=%v", err)
}
for _, path := range []string{transcriptPath, sidechainPath} {
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("task transcript should be deleted (%s), stat err=%v", path, err)
}
}
if got, _ := m.pg.GetTask(nid); got != nil {
t.Fatalf("task should be deleted from PG")
}
if finding, err := m.pg.GetFinding(findingID); err != nil || finding != nil {
t.Fatalf("finding should be deleted, got finding=%+v err=%v", finding, err)
}
var llmRecords int
if err := m.pg.QueryRow(`SELECT count(*) FROM llm_records WHERE task_id=$1`, id).Scan(&llmRecords); err != nil || llmRecords != 0 {
t.Fatalf("task LLM records should be deleted, count=%d err=%v", llmRecords, err)
}
var n int
m.pg.QueryRow(`SELECT count(*) FROM exploration_nodes WHERE exploration_id=$1`, expID).Scan(&n)
if n != 0 {
+72 -37
View File
@@ -30,26 +30,11 @@ func rawString(raw json.RawMessage) string {
// ---- Task (frontend "Task") ---- created_at as RFC3339, plus a derived status.
type TaskDTO struct {
<<<<<<< Updated upstream
ID string `json:"id"`
ExplorationID int64 `json:"exploration_id"`
Description string `json:"description"`
Goal string `json:"goal"`
Status string `json:"status"` // created | running | paused | done | failed
CreatedAt string `json:"created_at"`
CreatedUnix int64 `json:"created_unix"` // created_at as unix seconds (for run-duration calc)
CompletedAt string `json:"completed_at"` // RFC3339 finish time (done/failed); "" if unfinished
CompletedUnix int64 `json:"completed_unix"` // completed_at as unix seconds (0 if unfinished)
LastActivity int64 `json:"last_activity_unix"` // unix seconds of the last activity (0 if none)
Paused bool `json:"paused"`
Tokens TokenTotalDTO `json:"tokens"` // whole-task token consumption
GoalsTotal int `json:"goals_total"`
GoalsMet int `json:"goals_met"`
LLMProfileID *int64 `json:"llm_profile_id,omitempty"` // LLM profile used for this task; nil = default
=======
ID string `json:"id"`
ExplorationID int64 `json:"exploration_id"`
Name string `json:"name"` // 可选任务名称;空=未命名
CategoryID *int64 `json:"category_id,omitempty"`
CategoryName string `json:"category_name,omitempty"`
Description string `json:"description"`
Goal string `json:"goal"`
Status string `json:"status"` // created | running | paused | done | failed
@@ -71,7 +56,6 @@ type TaskDTO struct {
SourceTaskIDs []string `json:"source_task_ids"`
CompanyIDs []int64 `json:"company_ids"`
CoverageEnabled bool `json:"coverage_enabled"` // 资产覆盖度功能开关(创建时定)
>>>>>>> Stashed changes
}
// TokenTotalDTO is a whole-task (all agents) token aggregate.
@@ -92,23 +76,19 @@ func tokenTotalDTO(u db.TokenUsage) TokenTotalDTO {
}
func taskDTO(t *Task, status string) TaskDTO {
lifecycle := t.lifecycleSnapshot()
llmState := t.llmStateSnapshot()
sourceIDs := make([]string, 0, len(lifecycle.SourceTaskIDs))
for _, id := range lifecycle.SourceTaskIDs {
sourceIDs = append(sourceIDs, i64s(id))
}
profileIDs := append(make([]int64, 0, len(llmState.ProfileIDs)), llmState.ProfileIDs...)
return TaskDTO{
<<<<<<< Updated upstream
ID: t.ID,
ExplorationID: t.ExpID,
Description: t.Description,
Goal: t.Goal,
Status: status,
CreatedAt: rfc3339(time.Unix(t.CreatedAt, 0)),
CreatedUnix: t.CreatedAt,
CompletedAt: completedRFC(t.CompletedAt),
CompletedUnix: t.CompletedAt,
Paused: t.Paused,
LLMProfileID: t.LLMProfileID,
=======
ID: t.ID,
ExplorationID: t.ExpID,
Name: t.Name,
CategoryID: lifecycle.CategoryID,
CategoryName: lifecycle.CategoryName,
Description: t.Description,
Goal: t.Goal,
Status: status,
@@ -126,7 +106,6 @@ func taskDTO(t *Task, status string) TaskDTO {
SourceTaskIDs: sourceIDs,
CompanyIDs: lifecycle.CompanyIDs,
CoverageEnabled: t.CoverageEnabled,
>>>>>>> Stashed changes
}
}
@@ -177,10 +156,12 @@ type TaskNodeDTO struct {
State string `json:"state"`
Origin string `json:"origin"`
TS string `json:"ts"`
SourceTaskID string `json:"source_task_id,omitempty"`
Inherited bool `json:"inherited,omitempty"`
}
func taskNodeDTO(n *db.Node) TaskNodeDTO {
return TaskNodeDTO{
d := TaskNodeDTO{
ID: i64s(n.ID),
Type: n.Kind,
Payload: rawString(n.Payload),
@@ -189,6 +170,11 @@ func taskNodeDTO(n *db.Node) TaskNodeDTO {
Origin: n.Origin,
TS: rfc3339(n.CreatedAt),
}
if n.SourceTaskID > 0 {
d.SourceTaskID = i64s(n.SourceTaskID)
}
d.Inherited = n.Inherited
return d
}
// GoalDTO is a goal node with its payload unpacked into text/vulnclass — the shape
@@ -263,6 +249,27 @@ func edgeDTOs(in []db.Edge) []EdgeDTO {
return out
}
// ---- Coverage asset references ----
type CoverageAssetRefDTO struct {
ID int64 `json:"id"`
Kind string `json:"kind"`
State string `json:"state"`
Summary string `json:"summary"`
SourceTaskID string `json:"source_task_id,omitempty"`
Inherited bool `json:"inherited,omitempty"`
}
func coverageAssetRefDTO(ref db.AssetRef) CoverageAssetRefDTO {
out := CoverageAssetRefDTO{
ID: ref.ID, Kind: ref.Kind, State: ref.State, Summary: ref.Summary, Inherited: ref.Inherited,
}
if ref.SourceTaskID > 0 {
out.SourceTaskID = i64s(ref.SourceTaskID)
}
return out
}
// ---- Finding (frontend "Finding") ----
type FindingDTO struct {
@@ -280,6 +287,8 @@ type FindingDTO struct {
ParamID string `json:"param_id,omitempty"`
TaskID string `json:"task_id,omitempty"`
TaskDescription string `json:"task_description,omitempty"`
SourceTaskID string `json:"source_task_id,omitempty"`
Inherited bool `json:"inherited,omitempty"`
Assets []FindingAssetDTO `json:"assets,omitempty"`
TS string `json:"ts"`
}
@@ -335,7 +344,7 @@ type findingPayload struct {
func findingDTO(n *db.Node) FindingDTO {
var p findingPayload
_ = json.Unmarshal(n.Payload, &p)
return FindingDTO{
d := FindingDTO{
ID: i64s(n.ID),
VulnClass: p.VulnClass,
Name: p.Name,
@@ -345,6 +354,11 @@ func findingDTO(n *db.Node) FindingDTO {
Evidence: rawString(p.Evidence),
TS: rfc3339(n.CreatedAt),
}
if n.SourceTaskID > 0 {
d.SourceTaskID = i64s(n.SourceTaskID)
}
d.Inherited = n.Inherited
return d
}
// findingDTOsForTask converts a task's finding nodes to DTOs, stamping each with
@@ -354,11 +368,15 @@ func findingDTO(n *db.Node) FindingDTO {
// nodes with no row keep the 'pending' default and no finding_id (not editable).
// assets pre-resolves the anchored asset rows for label rendering.
func findingDTOsForTask(t *Task, in []*db.Node, meta map[int64]db.FindingMeta, assets map[int64]*db.Asset) []FindingDTO {
return findingDTOsForOwner(t.ID, t.Description, in, meta, assets)
}
func findingDTOsForOwner(taskID, description string, in []*db.Node, meta map[int64]db.FindingMeta, assets map[int64]*db.Asset) []FindingDTO {
out := make([]FindingDTO, 0, len(in))
for _, n := range in {
d := findingDTO(n)
d.TaskID = t.ID
d.TaskDescription = t.Description
d.TaskID = taskID
d.TaskDescription = description
if m, ok := meta[n.ID]; ok {
d.FindingID = i64s(m.ID)
d.Status = m.Status
@@ -424,6 +442,9 @@ type ActivityDTO struct {
IsError bool `json:"is_error"`
Summary string `json:"summary"`
Detail string `json:"detail,omitempty"`
Metadata json.RawMessage `json:"metadata,omitempty"`
SourceTaskID string `json:"source_task_id,omitempty"`
Inherited bool `json:"inherited,omitempty"`
// token usage (set only on kind='result'); used for per-session token totals.
InputTokens *int `json:"input_tokens,omitempty"`
OutputTokens *int `json:"output_tokens,omitempty"`
@@ -436,7 +457,7 @@ func activityDTO(a db.Activity) ActivityDTO {
if a.NodeID != nil {
intent = i64s(*a.NodeID)
}
return ActivityDTO{
d := ActivityDTO{
Seq: a.ID,
IntentID: intent,
Worker: a.Worker,
@@ -447,11 +468,17 @@ func activityDTO(a db.Activity) ActivityDTO {
IsError: a.IsError,
Summary: a.Summary,
Detail: a.Detail, // list endpoint leaves this empty (lazy)
Metadata: a.Metadata,
InputTokens: a.InputTokens,
OutputTokens: a.OutputTokens,
CacheReadTokens: a.CacheReadTokens,
CacheWriteTokens: a.CacheWriteTokens,
}
if a.SourceTaskID > 0 {
d.SourceTaskID = i64s(a.SourceTaskID)
}
d.Inherited = a.Inherited
return d
}
func activityDTOs(in []db.Activity) []ActivityDTO {
@@ -528,8 +555,13 @@ 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 越大越先被选中(激活配置恒为链首);
// pool_exclude=true 则不作为故障转移目标,但仍可被 agent/任务显式绑定。
Priority int `json:"priority"`
PoolExclude bool `json:"pool_exclude"`
}
func llmProfileDTO(p *db.LLMProfile) LLMProfileDTO {
@@ -544,8 +576,11 @@ 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,
PoolExclude: p.PoolExclude,
}
}
+968 -172
View File
File diff suppressed because it is too large. Load diff
+264 -10
View File
@@ -5,6 +5,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
@@ -66,14 +67,135 @@ func decode(r *http.Request, v any) error { return json.NewDecoder(r.Body).Decod
// ---------- tasks (delete) ----------
const taskDeleteDrainTimeout = 10 * time.Second
func canonicalTaskID(raw string) (string, bool) {
n, err := strconv.ParseInt(raw, 10, 64)
if err != nil || n <= 0 {
return "", false
}
return strconv.FormatInt(n, 10), true
}
// beginTaskDelete serializes the delete barrier with pause, resume, admission,
// and FIFO reconciliation. Every lifecycle path rechecks deleting after taking
// concMu, so none can commit a contradictory state once this returns.
func (s *Server) beginTaskDelete(taskID string) bool {
s.concMu.Lock()
defer s.concMu.Unlock()
if s.engine.IsDeleting(taskID) {
return false
}
return s.engine.BeginDelete(taskID)
}
// abortTaskDelete restores the execution barrier from the committed task row,
// not from the in-memory state observed before deletion began. Keeping the task
// paused on an unreadable row is conservative: a later explicit resume can
// safely recover it without allowing work to escape an uncertain delete.
func (s *Server) abortTaskDelete(taskID string) {
s.concMu.Lock()
defer s.concMu.Unlock()
keepPaused := true
if id, err := strconv.ParseInt(taskID, 10, 64); err == nil && s.m != nil && s.m.pg != nil {
if persisted, getErr := s.m.pg.GetTask(id); getErr == nil && persisted != nil {
keepPaused = persisted.Paused || persisted.Queued
} else if task, ok := s.m.Task(taskID); ok {
state := task.lifecycleSnapshot()
keepPaused = state.Paused || state.Queued
if getErr != nil {
log.Printf("[task-delete] task %s 读取持久状态失败,使用内存状态恢复屏障: %v", taskID, getErr)
}
} else if getErr == nil {
// The request targeted a task that does not exist. Do not retain a
// synthetic pause entry after releasing its temporary delete barrier.
keepPaused = false
}
}
s.engine.AbortDelete(taskID, keepPaused)
}
func (s *Server) pgDeleteTask(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
s.engine.Pause(id) // cancel running loops before removing
if err := s.m.DeleteTask(id); err != nil {
id, ok := canonicalTaskID(r.PathValue("id"))
if !ok {
writeErr(w, http.StatusBadRequest, "任务 id 无效")
return
}
var opts DeleteTaskOptions
if err := decode(r, &opts); err != nil && err != io.EOF {
writeErr(w, 400, "invalid JSON: "+err.Error())
return
}
if !s.beginTaskDelete(id) {
writeErr(w, http.StatusConflict, "任务正在删除")
return
}
deleted := false
defer func() {
if !deleted {
s.abortTaskDelete(id)
}
}()
// Main-agent runs use a separate context from planner/workers. Cancel it, then
// wait until both execution domains have returned before removing transcripts.
s.cancelTaskChat(id, agent.AbortTaskDeleted)
drainCtx, cancelDrain := context.WithTimeout(r.Context(), taskDeleteDrainTimeout)
defer cancelDrain()
if err := s.waitTaskQuiescent(drainCtx, id); err != nil {
writeErr(w, http.StatusConflict, "任务仍有运行中的 Agent,删除已取消")
return
}
result, err := s.m.DeleteTask(id, opts)
if err != nil {
var committed *taskDeleteCommittedError
if errors.As(err, &committed) {
// PostgreSQL is already gone. Complete runtime teardown and return the
// auditable counts together with the post-commit cleanup warning.
s.engine.StopTask(id)
s.taskAgentMu.Lock()
delete(s.taskAgents, id)
s.taskAgentMu.Unlock()
deleted = true
writeCommittedTaskDelete(w, result, err)
return
}
writeErr(w, 500, err.Error())
return
}
writeJSON(w, 200, map[string]any{"deleted": id})
// Manager has removed the task from the registry, so no new API operation can
// resolve it. Now stop and join every task-owned Engine goroutine and clear its
// lifecycle maps before releasing the delete barrier.
s.engine.StopTask(id)
s.taskAgentMu.Lock()
delete(s.taskAgents, id)
s.taskAgentMu.Unlock()
deleted = true
writeJSON(w, 200, result)
}
func writeCommittedTaskDelete(w http.ResponseWriter, result DeleteTaskResult, err error) {
result.CleanupWarning = err.Error()
writeJSON(w, http.StatusOK, result)
}
func (s *Server) waitTaskQuiescent(ctx context.Context, taskID string) error {
ticker := time.NewTicker(10 * time.Millisecond)
defer ticker.Stop()
for {
s.chatMu.Lock()
chatBusy := s.chatBusy[taskID]
s.chatMu.Unlock()
if s.engine.inflightCount(taskID) == 0 && !chatBusy {
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
}
}
}
// ---------- agents ----------
@@ -305,6 +427,9 @@ func (s *Server) pgSaveAgentConfig(w http.ResponseWriter, r *http.Request) {
if profileChanged {
s.invalidateProfileAgents()
}
// Task bundles capture operational Agent settings (turn/time budgets, tools,
// web search). Rebuild them even when no global profile is configured.
s.invalidateTaskAgents()
// rebuild the live agents so the new max_turns/run_seconds/binding apply without a restart.
s.cfgMu.Lock()
cfg, on := s.llmCfg, s.llmOn
@@ -810,6 +935,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) {
@@ -828,13 +959,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
@@ -850,6 +996,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))
@@ -1560,12 +1742,14 @@ func (s *Server) pgSaveProfile(w http.ResponseWriter, r *http.Request) {
writeErr(w, 500, err.Error())
return
}
// Editing a profile rebuilds any task pinned to it on its next round.
// Editing a profile rebuilds any task pinned to it on its next round. Reapply
// the active profile too: the global fallback and explicit task chains must
// repopulate the same provider-cache entry and therefore share one limiter.
s.invalidateProfileAgents()
// If we just edited the active profile, hot-apply so changes take effect now.
if act, _ := pg.ActiveProfile(); act != nil && act.ID == id {
// Hot-apply. Not just when the ACTIVE profile changed: with failover on, any
// profile's key/model/priority/pool_exclude edit reshapes the chain, so the
// engine's provider has to be rebuilt either way.
s.reapplyActiveProfile()
}
writeJSON(w, 200, map[string]any{"id": id})
}
@@ -1575,14 +1759,50 @@ func (s *Server) pgDeleteProfile(w http.ResponseWriter, r *http.Request) {
return
}
id, _ := pathInt(r, "id")
if err := pg.DeleteProfile(id); err != nil {
if err := pg.DeleteProfileContext(r.Context(), id); err != nil {
switch {
case errors.Is(err, db.ErrActiveLLMProfileDelete):
writeErr(w, 409, "当前激活的 LLM 配置不能删除,请先激活其他配置")
case errors.Is(err, db.ErrLLMProfileReferencesChanged):
writeErr(w, 409, "LLM 配置正在被任务或会话修改,请重试")
case errors.Is(err, context.DeadlineExceeded):
writeErr(w, 409, "等待 LLM 配置引用释放超时,请重试")
case errors.Is(err, db.ErrLLMProfileNotFound):
writeErr(w, 404, err.Error())
default:
writeErr(w, 500, err.Error())
}
return
}
s.invalidateProfileAgents() // drop cached agents for the removed profile
s.llmHealth.Reset(id) // its breaker state is meaningless now (row is FK-cascaded away)
s.restoreTasksAfterProfileDelete(pg)
// Cache invalidation also removed the active profile's shared provider entry.
// Reapply after task-state sync so the wake-up observes the post-delete chain.
s.reapplyActiveProfile()
writeJSON(w, 200, map[string]any{"deleted": id})
}
func (s *Server) restoreTasksAfterProfileDelete(pg *db.DB) {
for _, task := range s.m.List() {
if taskID, err := strconv.ParseInt(task.ID, 10, 64); err == nil {
if pt, err := pg.GetTask(taskID); err == nil && pt != nil {
s.syncTaskLLMState(pt)
// Deleting the active entry may advance the task to a ready successor,
// or clear the explicit chain and restore its Agent/global fallback.
// Resume only intents that were blocked by the exhausted chain; a
// paused task remains paused and terminal tasks remain immutable.
if !isTerminalStatus(task.lifecycleSnapshot().Status) && s.taskRuntimeAvailable(task, "planner", "worker") {
if _, reopenErr := task.Store.ReopenIntentsByBlockedReason(db.IntentBlockedLLMQuota); reopenErr != nil {
log.Printf("[llm-profile] task %s reopen quota-blocked intents after profile delete: %v", task.ID, reopenErr)
}
}
task.Notify()
}
}
}
}
func (s *Server) pgActivateProfile(w http.ResponseWriter, r *http.Request) {
pg := s.pg(w)
if pg == nil {
@@ -1604,6 +1824,40 @@ func (s *Server) pgActivateProfile(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{"ok": true})
}
// pgLLMPoolStatus reports the failover ("轮询") switches, the resolved chain order
// and every profile's circuit-breaker state — what the LLM page renders as the
// "轮询顺序" strip and the per-card health badges.
func (s *Server) pgLLMPoolStatus(w http.ResponseWriter, r *http.Request) {
if s.pg(w) == nil {
return
}
writeJSON(w, 200, s.llmPoolStatus())
}
// pgLLMPoolReset clears a tripped profile's circuit breaker so the next call
// tries it again immediately ("立即恢复"). id=0 clears every profile.
func (s *Server) pgLLMPoolReset(w http.ResponseWriter, r *http.Request) {
pg := s.pg(w)
if pg == nil {
return
}
var body struct {
ID int64 `json:"id"` // 0 / omitted = all
}
if err := decode(r, &body); err != nil {
writeErr(w, 400, err.Error())
return
}
if body.ID > 0 {
s.llmHealth.Reset(body.ID)
} else {
for id := range s.llmHealth.Snapshot() {
s.llmHealth.Reset(id)
}
}
writeJSON(w, 200, s.llmPoolStatus())
}
// pgListModels fetches available models from the provider's API endpoint.
// Supports OpenAI-format (GET /models) and Anthropic-format (GET /v1/models); for
// Anthropic-compatible third parties (e.g. DeepSeek) whose model list lives only on
+253 -123
View File
@@ -6,17 +6,6 @@
import { MOCK } from "@/lib/mock/enabled";
import { mockHandle } from "@/lib/mock/handler";
import type {
<<<<<<< Updated upstream
Task, 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,
=======
Activity,
Agent,
AgentDetail,
@@ -43,6 +32,7 @@ import type {
FindingStats,
FindingStatus,
FindingsPage,
IntentAsset,
InterceptApprovalRow,
InterceptPending,
InterceptRule,
@@ -67,6 +57,9 @@ import type {
SSTask,
Stats,
Task,
TaskAssetMutation,
TaskAssetScopeMutation,
TaskCategory,
TaskConstraint,
TaskGoal,
TaskLLMResolutions,
@@ -82,7 +75,6 @@ import type {
UsageStats,
WorkspaceFile,
WorkspaceListing,
>>>>>>> Stashed changes
} from "@/lib/types";
function getToken(): string | null {
@@ -109,7 +101,19 @@ async function http<T>(path: string, init?: RequestInit): Promise<T> {
}
throw new Error("未授权");
}
if (!r.ok) throw new Error(`${init?.method ?? "GET"} ${path}: ${r.status}`);
if (!r.ok) {
const fallback = `${init?.method ?? "GET"} ${path}: ${r.status}`;
let message = fallback;
try {
const payload = (await r.json()) as { error?: unknown };
if (typeof payload.error === "string" && payload.error.trim()) {
message = payload.error.trim();
}
} catch {
// Keep the status-based fallback for empty or non-JSON error responses.
}
throw new Error(message);
}
if (r.status === 204) return undefined as T;
return r.json();
}
@@ -148,19 +152,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 ?? [];
@@ -172,32 +178,17 @@ 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 ----
<<<<<<< Updated upstream
tasks: () => get<{ tasks: Task[]; active: string }>("/tasks").then((r) => ({ tasks: arr(r.tasks), active: r.active ?? "" })),
createTask: (description: string, goal: string, llmProfileId?: number, timeoutSeconds?: number, seedFirstIntent?: boolean, planHeartbeatSeconds?: number) =>
post<Task>("/tasks", {
description,
goal,
llm_profile_id: llmProfileId ?? null,
timeout_seconds: timeoutSeconds ?? 0,
seed_first_intent: seedFirstIntent ?? false,
plan_heartbeat_seconds: planHeartbeatSeconds ?? 0, // 0 = 后端归一到默认 600(10min)
}),
deleteTask: (id: string) => del<{ deleted: number }>(`/tasks/${id}`),
controlTask: (id: string, action: "pause" | "resume") => post<{ id: string; paused: boolean }>(`/tasks/${id}/control`, { action }),
=======
tasks: () =>
get<{ tasks: Task[]; active: string }>("/tasks").then((r) => ({ tasks: arr(r.tasks), active: r.active ?? "" })),
createTask: (input: {
name?: string;
categoryId?: number;
description: string;
goal: string;
llmProfileIds?: number[];
@@ -210,6 +201,7 @@ export const api = {
}) =>
post<Task>("/tasks", {
name: input.name ?? "",
category_id: input.categoryId ?? null,
description: input.description,
goal: input.goal,
llm_profile_ids: input.llmProfileIds ?? [],
@@ -220,6 +212,12 @@ export const api = {
plan_heartbeat_seconds: input.planHeartbeatSeconds ?? 0, // 0 = 后端归一到默认 600(10min)
coverage_enabled: input.coverageEnabled ?? true, // 默认开;false=关闭资产覆盖度功能
}),
taskCategories: () => get<{ categories: TaskCategory[] }>("/task-categories").then((r) => arr(r.categories)),
createTaskCategory: (name: string) => post<TaskCategory>("/task-categories", { name }),
renameTaskCategory: (id: number, name: string) => patch<TaskCategory>(`/task-categories/${id}`, { name }),
deleteTaskCategory: (id: number) => del<{ deleted: number }>(`/task-categories/${id}`),
updateTaskCategory: (taskId: string, categoryId?: number) =>
patch<Task>(`/tasks/${taskId}/category`, { category_id: categoryId ?? null }),
taskTemplates: () => get<{ templates: TaskTemplate[] }>("/task-templates").then((r) => arr(r.templates)),
createTaskTemplate: (input: Pick<TaskTemplate, "name" | "description" | "goal">) =>
post<TaskTemplate>("/task-templates", input),
@@ -250,9 +248,9 @@ export const api = {
deleted?: { intents: number; facts: number; findings: number; activities: number };
}>(`/tasks/${taskId}/intents/${intentId}/control`, { action, reason: reason ?? "" }),
taskLLMResolution: (id: string) => get<TaskLLMResolutions>(`/tasks/${id}/llm/resolution`),
>>>>>>> Stashed changes
// 重跑一条没跑成功的意图(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 }),
@@ -269,10 +267,6 @@ export const api = {
by_type: { type: string; total: number; tested: number }[];
}>(`/tasks/${id}/coverage`),
// 资产覆盖图:范围内全部资产 + 连接用的根域名/公司节点,含 tested/in_scope。
<<<<<<< Updated upstream
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}`),
@@ -308,22 +302,16 @@ export const api = {
post<TaskScopeRow>(`/tasks/${id}/scope`, { kind, value, reason }),
// 删除本任务的一条测试范围。
deleteTaskScope: (id: string, scopeId: number) => del<{ ok: boolean }>(`/tasks/${id}/scope/${scopeId}`),
>>>>>>> Stashed changes
// 某资产在本任务里关联的意图 / 事实 / 发现(覆盖图节点抽屉用)。
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();
@@ -362,37 +350,54 @@ 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}`,
).then((r) => ({ assets: r?.assets ?? [], total: r?.total ?? r?.count ?? 0 })),
assetCounts: () => get<Record<string, number>>("/assets/counts"),
assetCounts: (taskId = "") =>
get<Record<string, number>>(`/assets/counts${taskId ? `?task_id=${encodeURIComponent(taskId)}` : ""}`),
deleteAssets: (ids: number[]) =>
http<{ deleted: number }>("/assets", { method: "DELETE", body: JSON.stringify({ ids }) }),
// legacy — kept for task-specific views; hits the same endpoint with task_id filter
taskAssets: (taskId: string, type = "") =>
get<{ count: number; assets: Asset[] }>(`/assets?task_id=${taskId}&type=${type}`)
.then((r) => r?.assets ?? []),
// task-scoped view of the same endpoint — server-side paginated like `assets`
taskAssets: (taskId: string, type = "", limit = 50, offset = 0) =>
get<{ count: number; total: number; assets: Asset[] }>(
`/assets?task_id=${encodeURIComponent(taskId)}&type=${encodeURIComponent(type)}&limit=${limit}&offset=${offset}`,
).then((r) => ({ assets: r?.assets ?? [], total: r?.total ?? r?.count ?? 0 })),
attachTaskAssets: (taskId: string, assetIds: number[], sourceSummary: string) =>
post<TaskAssetMutation>(`/tasks/${taskId}/assets`, {
asset_ids: assetIds,
source_summary: sourceSummary,
}),
registerTaskAssetScopes: (taskId: string, scope: CompanyScopeRule[]) =>
post<TaskAssetScopeMutation>(`/tasks/${taskId}/assets`, { scope }),
detachTaskAsset: (taskId: string, assetId: number) => del<{ detached: number }>(`/tasks/${taskId}/assets/${assetId}`),
taskIntentAssets: (taskId: string) =>
get<{ assets: IntentAsset[] }>(`/tasks/${taskId}/intent-assets`).then((r) => arr(r.assets)),
// ---- companies (企业 + 资产范围;归属唯一来源) ----
companies: () => get<Company[]>("/companies").then(arr),
createCompany: (name: string, logo: string, scope: string) =>
createCompany: (name: string, scope: CompanyScopeRule[]) =>
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) : [] },
),
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 },
),
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 },
{
name,
scope,
},
),
addCompanyScope: (id: number, scope: CompanyScopeRule[], reason = "") =>
post<{ added: number; skipped: number; invalid: number; errors?: string[] }>(`/companies/${id}/scope`, {
scope,
reason,
}),
updateCompanyScope: (id: number, scope: CompanyScopeRule[], reason = "") =>
post<{ added: number; skipped: number; invalid: number; errors?: string[] }>(`/companies/${id}/scope`, {
scope,
reason,
reset: true,
}),
deleteCompany: (id: number, deleteAssets = false) =>
http<{ deleted: number; assets_deleted: number }>(`/companies/${id}`, {
method: "DELETE",
@@ -411,30 +416,85 @@ export const api = {
if (q.sort) p.set("sort", q.sort);
return get<FindingsPage>(`/exploration/findings?${p.toString()}`);
},
findingGroups: (q: FindingQuery) => {
const p = new URLSearchParams({ page: String(q.page), limit: String(q.pageSize) });
if (q.severity && q.severity !== "all") p.set("severity", q.severity);
if (q.status && q.status !== "all") p.set("status", q.status);
if (q.vulnclass && q.vulnclass !== "all") p.set("vulnclass", q.vulnclass);
if (q.task && q.task !== "all") p.set("task_id", q.task);
if (q.sort) p.set("sort", q.sort);
return get<FindingGroupsPage>(`/exploration/findings/groups?${p.toString()}`);
},
findingStats: () => get<FindingStats>("/exploration/findings/stats"),
getFinding: (id: string) => get<Finding>(`/exploration/findings/${id}`),
// 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,
fields: { name?: string; vulnclass?: string; severity?: Severity; status?: FindingStatus },
) => patch<Finding>(`/exploration/findings/${id}`, fields),
// 删除漏洞:移除 findings 记录 + 来源探索节点(从发现列表/任务发现 Tab/探索图一并消失)。
deleteFinding: (id: string) => del<{ deleted: boolean; id: number }>(`/exploration/findings/${id}`),
deepenFinding: (id: string, description: string) =>
post<FindingDeepenResponse>(`/exploration/findings/${id}/deepen`, { description }),
intents: (task?: string) => get<TaskNode[]>(`/exploration/intents${tq(task)}`).then(arr),
tokenStats: (task?: string) =>
get<{ workers: TokenUsage[]; total: TokenTotal }>(`/exploration/tokens${tq(task)}`).then((r) => ({
get<{ workers: TokenUsage[]; sessions: SessionTokenUsage[]; total: TokenTotal }>(
`/exploration/tokens${tq(task)}`,
).then((r) => ({
workers: arr(r.workers),
sessions: arr(r.sessions),
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();
@@ -442,7 +502,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>".
@@ -485,20 +548,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();
@@ -510,9 +574,18 @@ export const api = {
},
chat: (message: string, task?: string, attachments?: ChatAttachment[]) =>
post<{ reply: string; mode: string }>(`/chat${tq(task)}`, { message, attachments }),
chatStatus: (taskId: string) => get<{ running: boolean }>(`/tasks/${taskId}/chat/status`),
// 方式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();
@@ -528,15 +601,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,
) =>
@@ -546,6 +641,7 @@ export const api = {
base_url,
proxy,
api_key,
thinking_type,
reasoning_effort,
profile_id,
}),
@@ -561,10 +657,17 @@ 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),
deleteLLMProfile: (id: string) => del<{ deleted: number }>(`/llm/profiles/${id}`),
activateLLMProfile: (id: string) => post<{ ok: boolean }>("/llm/profiles/active", { id: Number(id) }),
// 轮询链的实际顺序 + 各配置的熔断状态。
llmPool: () => get<LLMPoolStatus>("/llm/pool"),
// 清除熔断,让下一次调用立刻重试该配置;不传 id = 全部清除。
resetLLMPool: (id?: string) => post<LLMPoolStatus>("/llm/pool/reset", { id: id ? Number(id) : 0 }),
fetchLLMModels: (provider: string, base_url: string, api_key: string, proxy = "", profile_id?: number) =>
post<{ ok: boolean; error?: string; models?: string[] }>("/llm/models", {
provider,
@@ -577,8 +680,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}`),
@@ -587,7 +689,10 @@ export const api = {
conversations: () => get<{ conversations: Conversation[] }>("/conversations").then((r) => arr(r.conversations)),
createConversation: (agent_key: string, title = "", llm_profile_id?: number | null) =>
post<Conversation>("/conversations", { agent_key, title, llm_profile_id: llm_profile_id ?? null }),
renameConversation: (id: number, title: string) => patch<{ ok: boolean }>(`/conversations/${id}`, { title }),
updateConversation: (id: number, input: { title?: string; pinned?: boolean }) =>
patch<Conversation>(`/conversations/${id}`, input),
renameConversation: (id: number, title: string) => patch<Conversation>(`/conversations/${id}`, { title }),
pinConversation: (id: number, pinned: boolean) => patch<Conversation>(`/conversations/${id}`, { pinned }),
updateConversationProfile: (id: number, llm_profile_id: number | null) =>
patch<{ ok: boolean }>(`/conversations/${id}/profile`, { llm_profile_id }),
deleteConversation: (id: number) => del<{ deleted: number }>(`/conversations/${id}`),
@@ -610,7 +715,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=用内置默认轮数
@@ -622,7 +728,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)),
@@ -644,12 +753,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)),
@@ -657,10 +769,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),
@@ -704,8 +819,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 }> => {
@@ -723,20 +844,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 }),
@@ -761,7 +887,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[] }> => {
@@ -813,6 +940,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)}`),
};
+3182 -329
View File
File diff suppressed because it is too large. Load diff
+579 -70
View File
@@ -2,31 +2,83 @@
// 未命中的一律返回安全默认([] / {} / {ok:true}),保证任何页面都不崩。
// 只在 NEXT_PUBLIC_MOCK=1 时经由 api.ts 的 http() 短路进入这里。
import {
classifyCompanyScopeLine,
companyScopeRuleError,
isCompanyScopeKind,
MAX_COMPANY_SCOPE_RULES,
normalizeCompanyScopeValue,
} from "../company-scope";
import type {
Asset,
BatchControlItem,
Company,
CompanyScopeRule,
Conversation,
IntentAsset,
ScopeRow,
Task,
TaskAssetMutation,
TaskAssetScopeMutation,
TaskCategory,
TaskLLMResolution,
TaskScopeRow,
TaskTemplate,
} from "../types";
import * as D from "./data";
const delay = (ms = 120) => new Promise((r) => setTimeout(r, ms));
<<<<<<< Updated upstream
=======
// Requests mutate a runtime copy, never the exported fixtures. This keeps module
// initialization deterministic for tests/HMR while preserving state across mock calls.
const mockTasks = structuredClone(D.tasks);
const mockFindings = structuredClone(D.findings);
const mockLLMRecords = structuredClone(D.llmRecords);
const mockTaskTemplates = structuredClone(D.taskTemplates);
const mockTaskCategories = structuredClone(D.taskCategories);
const mockConversations = structuredClone(D.conversations);
const mockIntents = structuredClone(D.intents);
const mockCompanies = structuredClone(D.companies);
const mockAssets = structuredClone(D.assets);
const mockTaskAssetIDs = new Map(D.tasks.map((task, index) => [task.id, index + 1]));
const mockTaskScopes = new Map<string, TaskScopeRow[]>();
let nextMockTaskAssetID = D.tasks.length + 1;
let mockActiveTask = D.ACTIVE_TASK;
function mockAssetCounts(): Record<string, number> {
function mockTaskAssetID(taskID: string): number | undefined {
const numeric = Number(taskID);
if (Number.isInteger(numeric) && numeric > 0) return numeric;
return mockTaskAssetIDs.get(taskID);
}
function mockAssetCounts(taskID?: string | null): Record<string, number> {
const numericTaskID = taskID ? mockTaskAssetID(taskID) : undefined;
return mockAssets.reduce<Record<string, number>>((counts, asset) => {
if (taskID && (numericTaskID === undefined || !asset.task_ids.includes(numericTaskID))) return counts;
counts[asset.type] = (counts[asset.type] ?? 0) + 1;
return counts;
}, {});
}
function mockAssetMatchesDSL(asset: Asset, dsl: string): boolean {
const query = dsl
.replaceAll(/[()"]/g, " ")
.replaceAll(/\b(?:AND|OR)\b/gi, " ")
.replaceAll(/\b[a-z_][a-z0-9_]*(?:==|!=|>=|<=|=|>|<)/gi, " ")
.trim()
.toLowerCase();
if (!query) return true;
const haystack = JSON.stringify(asset).toLowerCase();
return query.split(/\s+/).every((term) => haystack.includes(term));
}
function mockTaskCategorySnapshot(): TaskCategory[] {
return mockTaskCategories.map((category) => ({
...category,
task_count: mockTasks.filter((task) => task.category_id === category.id).length,
}));
}
function mockScopeRows(
companyID: number,
input: unknown,
@@ -188,7 +240,6 @@ function normalizedTemplateName(value: unknown): string {
.join(" ");
}
>>>>>>> Stashed changes
function parseBody(body?: BodyInit | null): Record<string, unknown> {
if (typeof body !== "string") return {};
try {
@@ -217,13 +268,6 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
if (path === "/auth/change-password") return { ok: true };
// ── tasks ──
<<<<<<< Updated upstream
if (path === "/tasks" && m === "GET") return { tasks: D.tasks, active: D.ACTIVE_TASK };
if (path === "/tasks" && m === "POST")
return { ...D.tasks[0], id: "t-new", description: String(b.description ?? "新任务"), goal: String(b.goal ?? ""), status: "created" };
if (seg[0] === "tasks" && seg.length === 2 && m === "DELETE") return { deleted: 1 };
if (seg[0] === "tasks" && seg[2] === "control") return { id: seg[1], paused: b.action === "pause" };
=======
if (path === "/tasks" && m === "GET") return { tasks: mockTasks, active: mockActiveTask };
if (path === "/tasks" && m === "POST") {
let suffix = 1;
@@ -233,9 +277,14 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
const profileIDs = [...((b.llm_profile_ids as number[] | undefined) ?? [])];
const sourceTaskIDs = [...((b.source_task_ids as string[] | undefined) ?? [])];
const companyIDs = [...((b.company_ids as number[] | undefined) ?? [])];
const categoryID = typeof b.category_id === "number" ? b.category_id : undefined;
const category = categoryID === undefined ? undefined : mockTaskCategories.find((item) => item.id === categoryID);
if (categoryID !== undefined && !category) throw new Error("任务分类不存在");
const created: Task = {
id,
name: String(b.name ?? ""),
category_id: category?.id,
category_name: category?.name,
description: String(b.description ?? "新任务"),
goal: String(b.goal ?? ""),
status: "created",
@@ -256,11 +305,67 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
source_task_ids: sourceTaskIDs,
company_ids: companyIDs,
};
mockTaskAssetIDs.set(id, nextMockTaskAssetID++);
for (const item of mockTasks) item.active = false;
mockTasks.unshift(created);
mockActiveTask = id;
return created;
}
if (path === "/task-categories" && m === "GET") return { categories: mockTaskCategorySnapshot() };
if (path === "/task-categories" && m === "POST") {
const name = normalizedTemplateName(b.name);
if (!name) throw new Error("分类名称不能为空");
if (mockTaskCategories.some((category) => category.name.toLowerCase() === name.toLowerCase())) {
throw new Error("分类名称已存在");
}
const now = new Date().toISOString();
const category: TaskCategory = {
id: mockTaskCategories.reduce((maximum, item) => Math.max(maximum, item.id), 0) + 1,
name,
task_count: 0,
created_at: now,
updated_at: now,
};
mockTaskCategories.push(category);
return category;
}
if (seg[0] === "task-categories" && seg.length === 2 && m === "PATCH") {
const category = mockTaskCategories.find((item) => item.id === Number(seg[1]));
if (!category) throw new Error("任务分类不存在");
const name = normalizedTemplateName(b.name);
if (!name) throw new Error("分类名称不能为空");
if (mockTaskCategories.some((item) => item.id !== category.id && item.name.toLowerCase() === name.toLowerCase())) {
throw new Error("分类名称已存在");
}
category.name = name;
category.updated_at = new Date().toISOString();
for (const task of mockTasks) {
if (task.category_id === category.id) task.category_name = name;
}
return { ...category, task_count: mockTasks.filter((task) => task.category_id === category.id).length };
}
if (seg[0] === "task-categories" && seg.length === 2 && m === "DELETE") {
const categoryID = Number(seg[1]);
const index = mockTaskCategories.findIndex((item) => item.id === categoryID);
if (index < 0) throw new Error("任务分类不存在");
mockTaskCategories.splice(index, 1);
for (const task of mockTasks) {
if (task.category_id !== categoryID) continue;
task.category_id = undefined;
task.category_name = undefined;
}
return { deleted: categoryID };
}
if (seg[0] === "tasks" && seg[2] === "category" && seg.length === 3 && m === "PATCH") {
const task = mockTasks.find((item) => item.id === seg[1]);
if (!task) throw new Error("任务不存在");
const categoryID = typeof b.category_id === "number" ? b.category_id : undefined;
const category = categoryID === undefined ? undefined : mockTaskCategories.find((item) => item.id === categoryID);
if (categoryID !== undefined && !category) throw new Error("任务分类不存在");
task.category_id = category?.id;
task.category_name = category?.name;
return task;
}
if (path === "/task-templates" && m === "GET") return { templates: mockTaskTemplates };
if (path === "/task-templates" && m === "POST") {
const now = new Date().toISOString();
@@ -316,6 +421,7 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
const id = seg[1];
const index = mockTasks.findIndex((item) => item.id === id);
if (index >= 0) mockTasks.splice(index, 1);
mockTaskAssetIDs.delete(id);
let findingsDeleted = 0;
if (b.delete_findings) {
@@ -423,71 +529,373 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
return { id: seg[1], paused: Boolean(task?.paused), queued: Boolean(task?.queued), status: task?.status ?? "" };
}
if (seg[0] === "tasks" && seg[2] === "chat" && seg[3] === "status") return { running: false };
>>>>>>> Stashed changes
if (seg[0] === "tasks" && seg[2] === "chat" && seg[3] === "stop") return { status: "stopped" };
if (path === "/active") return { active: String(b.id ?? D.ACTIVE_TASK) };
if (path === "/active") {
const id = String(b.id ?? mockActiveTask);
if (mockTasks.some((item) => item.id === id)) {
mockActiveTask = id;
for (const item of mockTasks) item.active = item.id === id;
}
return { active: mockActiveTask };
}
// ── 覆盖度 / 覆盖图 / 资产关联(任务维度)──
if (seg[0] === "tasks" && seg[2] === "coverage" && seg.length === 3) return D.coverage;
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: mockTaskScopes.get(seg[1]) ?? [] };
}
if (seg[0] === "tasks" && seg[2] === "scope" && seg.length === 3 && m === "POST") {
const current = mockTaskScopes.get(seg[1]) ?? [];
const kind = String(b.kind ?? "") as TaskScopeRow["kind"];
const value = String(b.value ?? "").trim();
const row: TaskScopeRow = {
id: Date.now(),
task_id: mockTaskAssetID(seg[1]) ?? Number(seg[1]),
kind,
source: "manual",
};
if (kind === "root_domain" || kind === "subdomain") row.domain = value;
else if (kind === "ip" || kind === "cidr") row.net = value;
else row.value = value;
mockTaskScopes.set(seg[1], [...current, row]);
return row;
}
if (seg[0] === "tasks" && seg[2] === "scope" && seg.length === 4 && m === "DELETE") {
const id = Number(seg[3]);
mockTaskScopes.set(
seg[1],
(mockTaskScopes.get(seg[1]) ?? []).filter((row) => row.id !== id),
);
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") ?? "");
// ── stats ──
if (path === "/stats") return D.stats(task);
if (path === "/stats") {
return D.stats(task, { tasks: mockTasks, findings: mockFindings, activeTask: mockActiveTask });
}
// ── assets ──
if (path === "/assets/counts") return D.assetCounts;
if (path === "/assets/counts") return mockAssetCounts(q.get("task_id"));
if (path === "/assets" && m === "GET") {
const type = q.get("type") ?? "";
const list = type ? D.assets.filter((a) => a.type === type) : D.assets;
const taskID = q.get("task_id");
const numericTaskID = taskID ? mockTaskAssetID(taskID) : undefined;
const dsl = q.get("dsl") ?? "";
const list = mockAssets.filter((asset) => {
if (type && asset.type !== type) return false;
if (taskID && (numericTaskID === undefined || !asset.task_ids.includes(numericTaskID))) return false;
return !dsl || mockAssetMatchesDSL(asset, dsl);
});
const limit = Number(q.get("limit") ?? 50);
const offset = Number(q.get("offset") ?? 0);
return { count: list.length, total: list.length, assets: list.slice(offset, offset + limit) };
const page = list.slice(offset, offset + limit);
return { count: page.length, total: list.length, assets: page };
}
if (path === "/assets" && m === "DELETE") {
const ids = new Set(Array.isArray(b.ids) ? b.ids.map(Number) : []);
let deleted = 0;
for (let index = mockAssets.length - 1; index >= 0; index--) {
if (!ids.has(mockAssets[index].id)) continue;
mockAssets.splice(index, 1);
deleted++;
}
return { deleted };
}
if (seg[0] === "tasks" && seg[2] === "assets" && seg.length === 3 && m === "POST") {
const task = mockTasks.find((item) => item.id === seg[1]);
const numericTaskID = mockTaskAssetID(seg[1]);
if (!task || numericTaskID === undefined) throw new Error("任务不存在");
if (Array.isArray(b.scope)) {
if (b.scope.length === 0 || b.scope.length > MAX_COMPANY_SCOPE_RULES) throw new Error("请填写有效测试范围");
const rules: CompanyScopeRule[] = b.scope.map((candidate, index) => {
if (typeof candidate === "string") {
const issue = classifyCompanyScopeLine(candidate, index + 1);
if (!issue.rule || issue.error) throw new Error(`第 ${index + 1} 条范围无效:${issue.error ?? "无法识别"}`);
return issue.rule;
}
const item = candidate as { kind?: unknown; value?: unknown };
const value = String(item?.value ?? "").trim();
const rule =
item?.kind && isCompanyScopeKind(item.kind)
? { kind: item.kind, value }
: classifyCompanyScopeLine(value, index + 1).rule;
const error = rule ? companyScopeRuleError(rule) : "无法识别";
if (!rule || error) throw new Error(`第 ${index + 1} 条范围无效:${error}`);
return rule;
});
const mutation: TaskAssetScopeMutation = {
requested: rules.length,
assets_linked: 0,
assets_existing: 0,
scopes_added: 0,
scopes_existing: 0,
};
const currentScopes = mockTaskScopes.get(seg[1]) ?? [];
const scopeKeys = new Set(
currentScopes.map((row) => `${row.kind}|${row.domain ?? row.net ?? row.value ?? row.company_id ?? ""}`),
);
for (const rule of rules) {
const normalized = normalizeCompanyScopeValue(rule);
const scope: TaskScopeRow = {
id: Date.now() + currentScopes.length,
task_id: numericTaskID,
kind: rule.kind === "domain" ? "root_domain" : rule.kind,
source: "manual",
reason: "用户在测试资产页手工新增",
};
if (rule.kind === "domain") scope.domain = normalized;
else if (rule.kind === "ip") scope.net = `${normalized}/${normalized.includes(":") ? 128 : 32}`;
else if (rule.kind === "cidr") scope.net = normalized;
else scope.value = normalized;
const scopeKey = `${scope.kind}|${scope.domain ?? scope.net ?? scope.value ?? ""}`;
if (scopeKeys.has(scopeKey)) mutation.scopes_existing++;
else {
scopeKeys.add(scopeKey);
currentScopes.push(scope);
mutation.scopes_added++;
}
if (rule.kind !== "domain" && rule.kind !== "ip") continue;
const type = rule.kind === "domain" ? "root_domain" : "ip";
let asset = mockAssets.find((item) =>
type === "root_domain"
? item.type === type && item.domain === normalized
: item.type === type && item.ip === normalized,
);
const alreadyLinked = asset?.task_ids.includes(numericTaskID) ?? false;
if (!asset) {
const nextID = mockAssets.reduce((max, item) => Math.max(max, item.id), 0) + 1;
asset = {
id: nextID,
type,
task_ids: [],
...(type === "root_domain" ? { domain: normalized, root_domain: normalized } : { ip: normalized }),
last_seen: new Date().toISOString(),
};
mockAssets.push(asset);
}
if (alreadyLinked) mutation.assets_existing++;
else {
asset.task_ids.push(numericTaskID);
mutation.assets_linked++;
}
asset.task_source = "manual";
asset.task_source_summary = "用户在测试资产页手工新增";
asset.task_source_node_id = undefined;
}
mockTaskScopes.set(seg[1], currentScopes);
return mutation;
}
const ids = [...new Set(Array.isArray(b.asset_ids) ? b.asset_ids.map(Number) : [])];
const sourceSummary = String(b.source_summary ?? "").trim();
if (ids.length === 0 || ids.length > 100 || !sourceSummary) throw new Error("请选择资产并填写来源说明");
const requestedAssets = ids.map((id) => mockAssets.find((asset) => asset.id === id));
if (requestedAssets.some((asset) => !asset)) throw new Error("资产不存在");
const mutation: TaskAssetMutation = { requested: ids.length, attached: 0, existing: 0 };
for (const asset of requestedAssets) {
if (!asset) continue;
if (asset.task_ids.includes(numericTaskID)) mutation.existing++;
else {
asset.task_ids.push(numericTaskID);
mutation.attached++;
}
asset.task_source = "manual";
asset.task_source_summary = sourceSummary;
asset.task_source_node_id = undefined;
}
return mutation;
}
if (seg[0] === "tasks" && seg[2] === "assets" && seg.length === 4 && m === "DELETE") {
const numericTaskID = mockTaskAssetID(seg[1]);
const asset = mockAssets.find((item) => item.id === Number(seg[3]));
if (numericTaskID === undefined || !asset) throw new Error("任务或资产不存在");
if (!asset.task_ids.includes(numericTaskID)) throw new Error("资产未关联当前任务");
asset.task_ids = asset.task_ids.filter((id) => id !== numericTaskID);
if (asset.task_ids.length === 0) {
asset.task_source = undefined;
asset.task_source_summary = undefined;
asset.task_source_node_id = undefined;
}
return { detached: asset.id };
}
if (seg[0] === "tasks" && seg[2] === "intent-assets" && seg.length === 3 && m === "GET") {
let mappings: Array<{ intentID: string; assetID: number; summary: string }> = [];
if (seg[1] === "t-acme-web") {
mappings = [
{ intentID: "i3", assetID: 6, summary: "后台功能枚举意图从前序子域发现中选定" },
{ intentID: "i5", assetID: 3, summary: "订单接口测试意图从 API 任务目标中选定" },
];
} else if (seg[1] === "t-acme-api") {
mappings = [{ intentID: "i5", assetID: 3, summary: "订单接口测试意图从 API 任务目标中选定" }];
}
const sourceTaskID = mockTaskAssetID(seg[1]) ?? 0;
const assets: IntentAsset[] = mappings.flatMap((mapping) => {
const asset = mockAssets.find((item) => item.id === mapping.assetID);
if (!asset) return [];
return [
{
intent_id: mapping.intentID,
asset_id: asset.id,
type: asset.type,
label: asset.domain ?? asset.ip ?? asset.app_name ?? asset.url ?? asset.service_name ?? `#${asset.id}`,
source: asset.task_source ?? "agent",
source_summary: asset.task_source_summary ?? mapping.summary,
source_node_id: asset.task_source_node_id,
source_task_id: sourceTaskID,
inherited: false,
},
];
});
return { assets };
}
if (path === "/assets" && m === "DELETE") return { deleted: (b.ids as unknown[])?.length ?? 0 };
// ── companies ──
if (path === "/companies" && m === "GET") return D.companies;
if (path === "/companies" && m === "POST") return { id: 2, created: true, scope_added: 0 };
if (seg[0] === "companies" && seg[2] === "scope") return { added: 0, skipped: 0, invalid: 0 };
if (seg[0] === "companies" && seg.length === 2 && m === "DELETE") return { deleted: 1, assets_deleted: 0 };
if (path === "/companies" && m === "GET") return structuredClone(mockCompanies);
if (path === "/companies" && m === "POST") {
const name = String(b.name ?? "").trim();
if (!name) throw new Error("企业名称不能为空");
if (mockCompanies.some((company) => company.name.toLowerCase() === name.toLowerCase())) {
throw new Error("企业已存在");
}
const id = mockCompanies.reduce((max, company) => Math.max(max, company.id), 0) + 1;
const scopeResult = mockScopeRows(id, b.scope);
const company: Company = { id, name, asset_count: 0, scope: scopeResult.rows };
mockCompanies.push(company);
return {
id,
created: true,
scope_added: scopeResult.rows.length,
scope_skipped: scopeResult.skipped,
scope_invalid: scopeResult.invalid,
};
}
if (seg[0] === "companies" && seg[2] === "scope" && m === "POST") {
const company = mockCompanies.find((item) => item.id === Number(seg[1]));
if (!company) throw new Error("企业不存在");
const reset = b.reset === true;
const scopeResult = mockScopeRows(company.id, b.scope, reset ? [] : (company.scope ?? []));
if (reset && scopeResult.invalid > 0) throw new Error("企业范围包含无效规则,未覆盖原有范围");
company.scope = reset ? scopeResult.rows : [...(company.scope ?? []), ...scopeResult.rows];
return { added: scopeResult.rows.length, skipped: scopeResult.skipped, invalid: scopeResult.invalid };
}
if (seg[0] === "companies" && seg.length === 2 && m === "DELETE") {
const id = Number(seg[1]);
const index = mockCompanies.findIndex((item) => item.id === id);
if (index < 0) throw new Error("企业不存在");
mockCompanies.splice(index, 1);
let assetsDeleted = 0;
for (let assetIndex = mockAssets.length - 1; assetIndex >= 0; assetIndex--) {
const asset: Asset = mockAssets[assetIndex];
if (asset.company_id !== id) continue;
if (b.delete_assets === true) {
mockAssets.splice(assetIndex, 1);
assetsDeleted++;
} else {
delete asset.company_id;
}
}
return { deleted: 1, assets_deleted: assetsDeleted };
}
// ── exploration ──
if (path === "/exploration/frontier") return D.frontier;
if (path === "/exploration/findings/stats") {
const vulnclasses = Array.from(new Set(D.findings.map((f) => f.vulnclass))).sort();
const vulnclasses = Array.from(new Set(mockFindings.map((f) => f.vulnclass))).sort();
// 「按任务」下拉:有漏洞的任务 + 描述 + 条数(mock 任务 id 是字符串,直接当 id 用)。
<<<<<<< Updated upstream
const taskMap = new Map<string, { description: string; count: number }>();
for (const f of D.findings) {
=======
const taskMap = new Map<string, { name: string; description: string; count: number }>();
for (const f of mockFindings) {
>>>>>>> Stashed changes
if (!f.task_id) continue;
const owner = mockTasks.find((candidate) => candidate.id === f.task_id);
const cur = taskMap.get(f.task_id) ?? { name: owner?.name ?? "", description: f.task_description ?? "", count: 0 };
const cur = taskMap.get(f.task_id) ?? {
name: owner?.name ?? "",
description: f.task_description ?? "",
count: 0,
};
cur.count++;
taskMap.set(f.task_id, cur);
}
const tasks = Array.from(taskMap, ([id, v]) => ({ id, name: v.name, description: v.description, count: v.count }));
return {
total: D.findings.length,
pending: D.findings.filter((f) => f.status === "pending").length,
critical: D.findings.filter((f) => f.severity === "critical").length,
high: D.findings.filter((f) => f.severity === "high").length,
medium: D.findings.filter((f) => f.severity === "medium").length,
low: D.findings.filter((f) => f.severity === "low").length,
total: mockFindings.length,
pending: mockFindings.filter((f) => f.status === "pending").length,
critical: mockFindings.filter((f) => f.severity === "critical").length,
high: mockFindings.filter((f) => f.severity === "high").length,
medium: mockFindings.filter((f) => f.severity === "medium").length,
low: mockFindings.filter((f) => f.severity === "low").length,
vulnclasses,
tasks,
};
}
<<<<<<< Updated upstream
=======
if (path === "/exploration/findings/groups") {
const severityOrder = { critical: 4, high: 3, medium: 2, low: 1 } as const;
let list = mockFindings.slice();
@@ -555,38 +963,52 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
queued: false,
};
}
>>>>>>> Stashed changes
// 单条 finding:GET 详情 / PATCH 改状态/严重度/名称/类别(demo 直接改内存对象)。
if (
seg[0] === "exploration" &&
seg[1] === "findings" &&
seg.length === 3 &&
seg[2] !== "stats"
) {
const f = D.findings.find((x) => x.id === seg[2]);
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;
}
return { ...f, finding_id: f.id };
const contextTaskId = q.get("context_task");
const contextTask = contextTaskId ? mockTasks.find((item) => item.id === contextTaskId) : undefined;
const inherited = !!(
contextTask &&
f.task_id &&
f.task_id !== contextTask.id &&
contextTask.source_task_ids?.includes(f.task_id)
);
return {
...f,
finding_id: f.id,
...(inherited ? { inherited: true, source_task_id: f.task_id } : {}),
};
}
if (path === "/exploration/findings") {
// finding_id=id:真后端用独立表行 id 作为状态/详情句柄,mock 里用自身 id 顶上。
// report 仅详情接口返回,列表剥掉(与后端一致)。
const withFid = (f: (typeof D.findings)[number]) => ({
const withFid = (f: (typeof mockFindings)[number]) => ({
...f,
report: undefined,
finding_id: f.id,
});
if (task) return D.findings.filter((f) => f.task_id === task).map(withFid);
if (task) {
const owner = mockTasks.find((item) => item.id === task);
const sources = new Set(owner?.source_task_ids ?? []);
return mockFindings
.filter((f) => f.task_id === task || (!!f.task_id && sources.has(f.task_id)))
.map((f) => ({
...withFid(f),
...(f.task_id !== task ? { inherited: true, source_task_id: f.task_id } : {}),
}));
}
// 全局:带 page/limit → 分页对象;否则裸数组(dashboard)。
if (!q.has("page") && !q.has("limit")) return D.findings.map(withFid);
if (!q.has("page") && !q.has("limit")) return mockFindings.map(withFid);
const sev = { critical: 4, high: 3, medium: 2, low: 1 } as const;
let list = D.findings.slice();
let list = mockFindings.slice();
const fSev = q.get("severity");
const fStatus = q.get("status");
const fVuln = q.get("vulnclass");
@@ -594,14 +1016,17 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
if (fSev) list = list.filter((f) => f.severity === fSev);
if (fStatus) list = list.filter((f) => f.status === fStatus);
if (fVuln) list = list.filter((f) => f.vulnclass === fVuln);
if (fTask) list = list.filter((f) => f.task_id === fTask);
if (fTask === "__unassigned__") list = list.filter((f) => !f.task_id);
else if (fTask) list = list.filter((f) => f.task_id === fTask);
list.sort((a, b) =>
q.get("sort") === "severity"
? sev[b.severity] - sev[a.severity] || +new Date(b.ts) - +new Date(a.ts)
: +new Date(b.ts) - +new Date(a.ts),
);
const page = Number(q.get("page") ?? 1);
const pageSize = Number(q.get("limit") ?? 20);
const rawPage = Number(q.get("page") ?? 1);
const rawPageSize = Number(q.get("limit") ?? 20);
const page = Number.isFinite(rawPage) && rawPage > 0 ? Math.floor(rawPage) : 1;
const pageSize = Number.isFinite(rawPageSize) && rawPageSize > 0 ? Math.min(200, Math.floor(rawPageSize)) : 20;
return {
items: list.slice((page - 1) * pageSize, page * pageSize).map(withFid),
total: list.length,
@@ -609,12 +1034,32 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
page_size: pageSize,
};
}
if (path === "/exploration/intents") return D.intents;
if (path === "/exploration/tokens") return { workers: D.tokenWorkers, total: D.tokenTotal };
if (path === "/exploration/intents") {
if (q.has("page")) {
const before = Number(q.get("before") ?? 0);
const limit = Math.max(1, Number(q.get("limit") ?? 300));
let list = mockIntents;
if (before > 0) list = list.filter((intent) => Number(intent.id.replace(/\D/g, "") || intent.id) < before);
return { items: list.slice(0, limit), has_more: list.length > limit };
}
return mockIntents;
}
if (path === "/exploration/tokens") {
const selectedTask = task ? mockTasks.find((item) => item.id === task) : undefined;
return {
workers: D.tokenWorkers,
sessions: D.tokenSessions,
total: selectedTask?.tokens ?? D.tokenTotal,
};
}
if (path === "/exploration/graph") return D.explorationGraph;
if (path === "/exploration/activity" && seg.length === 2) {
const items = D.activityForTask();
return { items, cursor: items.length ? items[items.length - 1].seq : 0 };
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);
return { items, cursor: items.length ? items[items.length - 1].seq : since };
}
if (seg[0] === "exploration" && seg[1] === "activity" && seg.length === 3) {
const a = D.activity.find((x) => x.seq === Number(seg[2]));
@@ -634,28 +1079,49 @@ 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 };
// ── 工具执行历史 ──
if (path === "/commands" && m === "GET") return { commands: D.commandRecords, total: D.commandRecords.length };
// ── LLM ──
if (path === "/llm/records" && m === "GET") return { records: D.llmRecords, total: D.llmRecords.length };
if (path === "/llm/records" && m === "GET") return { records: mockLLMRecords, total: mockLLMRecords.length };
if (path === "/llm/records" && m === "DELETE") return { deleted: 0 };
if (path === "/llm/records/tasks") return { tasks: D.llmTasks };
if (seg[0] === "llm" && seg[1] === "records" && seg.length === 3 && m === "GET") return D.llmRecordDetail(Number(seg[2]));
if (path === "/llm/records/tasks") {
const counts = new Map<string, number>();
for (const record of mockLLMRecords) {
if (record.task_id) counts.set(record.task_id, (counts.get(record.task_id) ?? 0) + 1);
}
return { tasks: [...counts].map(([task_id, count]) => ({ task_id, count })) };
}
if (seg[0] === "llm" && seg[1] === "records" && seg.length === 3 && m === "GET") {
return D.llmRecordDetail(Number(seg[2]), mockLLMRecords);
}
if (path === "/llm" && m === "GET") return D.llmConfig;
if (path === "/llm" && m === "POST") return { ok: true };
if (path === "/llm/test") return { ok: true, latency_ms: 128, model: String(b.model ?? "claude-opus-4-8") };
if (path === "/llm/profiles" && m === "GET") return { profiles: D.llmProfiles };
if (path === "/llm/profiles" && m === "POST") return { id: Number(b.id) || 3 };
if (path === "/llm/profiles/active") return { ok: true };
if (seg[0] === "llm" && seg[1] === "profiles" && seg.length === 3 && m === "DELETE") return { deleted: Number(seg[2]) };
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]) };
// ── 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 };
@@ -665,8 +1131,43 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
if (seg[0] === "agents" && seg[2] === "visibility" && m === "GET") return D.agentDetail(seg[1]).visibility;
// ── 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 === "GET") {
sortMockConversations();
return { conversations: structuredClone(mockConversations) };
}
if (path === "/conversations" && m === "POST") {
const now = new Date().toISOString();
const title = String(b.title ?? "").trim() || "新对话";
const conversation: Conversation = {
id: mockConversations.reduce((max, item) => Math.max(max, item.id), 0) + 1,
agent_key: String(b.agent_key ?? "mainagent"),
title,
llm_profile_id: typeof b.llm_profile_id === "number" ? b.llm_profile_id : undefined,
pinned: false,
created_at: now,
updated_at: now,
};
mockConversations.unshift(conversation);
return structuredClone(conversation);
}
if (seg[0] === "conversations" && seg.length === 2 && m === "PATCH") {
const conversation = mockConversations.find((item) => item.id === Number(seg[1]));
if (!conversation) return {};
if (typeof b.title === "string") conversation.title = b.title.trim();
if (typeof b.pinned === "boolean") {
conversation.pinned = b.pinned;
conversation.pinned_at = b.pinned ? (conversation.pinned_at ?? new Date().toISOString()) : null;
}
conversation.updated_at = new Date().toISOString();
sortMockConversations();
return structuredClone(conversation);
}
if (seg[0] === "conversations" && seg.length === 2 && m === "DELETE") {
const id = Number(seg[1]);
const index = mockConversations.findIndex((item) => item.id === id);
if (index >= 0) mockConversations.splice(index, 1);
return { deleted: id };
}
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 };
@@ -692,13 +1193,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)
@@ -709,7 +1213,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")
@@ -734,5 +1239,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,
)
? []
: {};
}
+206 -24
View File
@@ -1,12 +1,14 @@
// ARTEX domain model — types used across the UI.
// Derived from the functional spec (section 7: 关键数据形状).
export type TaskStatus = "created" | "running" | "paused" | "done" | "failed" | "timeout";
export type TaskStatus = "created" | "queued" | "running" | "paused" | "done" | "failed" | "timeout";
export type EngineMode = "exploring" | "paused" | "stalled" | "idle";
export interface Task {
id: string;
name?: string; // 可选任务名称;空/缺省=未命名,展示时回退到描述
category_id?: number;
category_name?: string;
description: string;
goal: string;
status: TaskStatus;
@@ -16,6 +18,7 @@ export interface Task {
completed_unix?: number; // completed_at as unix seconds (0/undef if unfinished)
last_activity_unix?: number; // unix seconds of the last activity (0/undef if none)
paused?: boolean;
queued?: boolean;
active?: boolean;
in_flight?: number;
last_activity?: string;
@@ -25,8 +28,6 @@ export interface Task {
engine_mode?: EngineMode;
tokens?: TokenTotal; // whole-task token consumption
llm_profile_id?: number; // LLM profile used; absent = default profile
<<<<<<< Updated upstream
=======
llm_profile_ids?: number[]; // ordered task-level failover chain
active_llm_profile_id?: number; // profile used by the next LLM call
llm_failover_state?: "default" | "ready" | "chain_exhausted" | string;
@@ -36,6 +37,14 @@ export interface Task {
coverage_enabled?: boolean; // 资产覆盖度功能开关(创建时定,默认开);false=不计算/不展示覆盖度
}
export interface TaskCategory {
id: number;
name: string;
task_count: number;
created_at: string;
updated_at: string;
}
export interface TaskTemplate {
id: number;
name: string;
@@ -62,7 +71,6 @@ export interface DeleteTaskResult {
findings_deleted: number;
llm_records_deleted: number;
cleanup_warning?: string;
>>>>>>> Stashed changes
}
// ---- Asset graph (global, shared across tasks) ----
@@ -170,6 +178,35 @@ export interface Asset {
params?: Record<string, unknown>[];
extra?: Record<string, unknown>;
last_seen: string;
task_source?: string;
task_source_summary?: string;
task_source_node_id?: number;
}
export interface IntentAsset {
intent_id: number | string;
asset_id: number;
type: NewAssetType;
label: string;
source: string;
source_summary: string;
source_node_id?: number;
source_task_id: number;
inherited: boolean;
}
export interface TaskAssetMutation {
requested: number;
attached: number;
existing: number;
}
export interface TaskAssetScopeMutation {
requested: number;
assets_linked: number;
assets_existing: number;
scopes_added: number;
scopes_existing: number;
}
// ---- Asset coverage graph (per task) ----
@@ -210,6 +247,8 @@ export interface CoverageAssetRef {
kind: string;
state: string;
summary: string;
source_task_id?: string;
inherited?: boolean;
}
export interface CoverageAssetRefs {
intents: CoverageAssetRef[];
@@ -237,17 +276,16 @@ export interface WorkspaceFile {
content?: string;
}
<<<<<<< Updated upstream
=======
// 任务测试范围的一条(覆盖度分母 + 授权边界)。
export interface TaskScopeRow {
id: number;
task_id: number;
kind: "company" | "root_domain" | "subdomain" | "ip" | "cidr";
kind: "company" | "root_domain" | "subdomain" | "ip" | "cidr" | "icp" | "keyword";
company_id?: number;
company_name?: string; // 后端 JOIN companies 解析,仅 kind=company 有值
domain?: string;
net?: string;
value?: string;
source: "auto" | "agent" | "manual";
reason?: string;
}
@@ -260,14 +298,14 @@ export interface CompanyScopeRule {
value: string;
}
>>>>>>> Stashed changes
// 公司资产范围规则的一条(归属唯一真值来源)。
export interface ScopeRow {
id: number;
company_id: number;
kind: "domain" | "ip" | "cidr";
kind: CompanyScopeKind;
domain?: string; // kind=domain 时有值
net?: string; // kind=ip|cidr 时有值
value?: string; // kind=icp|keyword 时可能由后端直接返回
raw: string; // 原始用户输入,用于显示和回填
reason?: string;
}
@@ -278,13 +316,13 @@ export interface Company {
name: string;
logo?: string; // 远程图标 URL;空则前端用名称首字母
asset_count: number;
scope: ScopeRow[];
scope?: ScopeRow[];
}
// ---- Exploration graph (per task) ----
export type ExploreKind = "task" | "begin" | "goal" | "intent" | "fact" | "finding" | "hint";
export type GoalState = "open" | "met" | "abandoned";
export type IntentState = "open" | "running" | "done" | "blocked" | "exhausted" | "stopped";
export type IntentState = "open" | "running" | "paused" | "done" | "blocked" | "exhausted" | "stopped";
export type FindingState = "confirmed" | "dismissed";
export type HintState = "active" | "consumed";
export type ExploreRel = "spawns" | "derived_from" | "yields" | "proves";
@@ -297,6 +335,8 @@ export interface TaskNode {
state: string; // GoalState | IntentState | FindingState | HintState
origin: string;
ts: string;
source_task_id?: string;
inherited?: boolean;
}
// 目标管理卡片用的目标(后端已把 payload 拆成 text/vulnclass)。
@@ -354,6 +394,8 @@ export interface Finding {
param_id?: string;
task_id?: string;
task_description?: string;
source_task_id?: string;
inherited?: boolean;
assets?: FindingAsset[];
ts: string;
}
@@ -366,8 +408,6 @@ export interface FindingsPage {
page_size: number;
}
<<<<<<< Updated upstream
=======
export interface FindingGroup {
task_id: string | number | null;
task_name?: string; // 可选任务名称;空/缺省=未命名
@@ -396,7 +436,6 @@ export interface FindingDeepenResponse {
queued: boolean;
}
>>>>>>> Stashed changes
// FindingStats 是发现全表聚合(统计卡 + 漏洞类型下拉),服务端计算,不受分页影响。
export interface FindingStats {
total: number;
@@ -412,12 +451,8 @@ export interface FindingStats {
// FindingTaskOption 是发现页「按任务」筛选下拉的一项:有漏洞的任务(描述为空表示任务已删除,
// 前端回退展示 id)及其漏洞条数。
export interface FindingTaskOption {
<<<<<<< Updated upstream
id: number;
=======
id: string | number;
name?: string; // 可选任务名称;空/缺省=未命名
>>>>>>> Stashed changes
description: string;
count: number;
}
@@ -444,6 +479,8 @@ export type ActivityKind =
| "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)。
@@ -465,6 +502,11 @@ export interface Activity {
is_error?: boolean;
summary: string;
detail?: string;
metadata?: {
llm_transition?: LLMTransition;
};
source_task_id?: string;
inherited?: boolean;
// token usage (present only on kind='result')
input_tokens?: number;
output_tokens?: number;
@@ -472,6 +514,36 @@ export interface Activity {
cache_write_tokens?: number;
}
export interface LLMAuditProfile {
id: number;
name: string;
format: string;
model: string;
}
export interface LLMTransition {
mode: "automatic" | "manual" | "exhausted";
reason: string;
previous?: LLMAuditProfile;
next?: LLMAuditProfile;
}
export interface TaskLLMResolution {
profile_id?: number;
name: string;
format: string;
model: string;
source: "task_chain" | "agent_binding" | "global_profile" | "environment" | "global";
available: boolean;
reason?: string;
}
export interface TaskLLMResolutions {
mainagent: TaskLLMResolution;
planner: TaskLLMResolution;
worker: TaskLLMResolution;
}
// ---- Agent triggers (P3 调度,仅自定义 agent) ----
export interface AgentTrigger {
id: number;
@@ -499,6 +571,8 @@ export interface Conversation {
agent_key: string;
title: string;
llm_profile_id?: number;
pinned?: boolean;
pinned_at?: string | null;
created_at: string;
updated_at: string;
}
@@ -513,8 +587,8 @@ export interface LogLine {
text: string;
}
export type SessionRole = "mainagent" | "planner" | "worker";
export type SessionStatus = "running" | "done" | "blocked" | "exhausted" | "pending" | "stopped";
export type SessionRole = "mainagent" | "planner" | "worker" | "system";
export type SessionStatus = "running" | "paused" | "done" | "blocked" | "exhausted" | "pending" | "stopped";
// Daily token aggregate bucket (GET /api/tokens/daily).
export interface DailyTokenBucket {
@@ -534,6 +608,22 @@ export interface TokenUsage {
cache_write_tokens: number;
}
export interface SessionTokenUsage {
session: string;
input_tokens: number;
output_tokens: number;
cache_read_tokens: number;
cache_write_tokens: number;
}
export interface BatchControlItem {
id: string;
ok: boolean;
status?: string;
queued?: boolean;
error?: string;
}
// Whole-task (all agents) token aggregate.
export interface TokenTotal {
input_tokens: number;
@@ -542,6 +632,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;
@@ -550,6 +677,8 @@ export interface Session {
live: boolean;
last_activity: string;
intent_id?: string;
source_task_id?: string;
inherited?: boolean;
}
// ---- Security ----
@@ -617,8 +746,6 @@ export interface Settings {
web_search_proxy?: string;
python_interpreter?: string; // 自定义脚本工具的 python 解释器路径(空=运行时检测)
workers?: number; // 并发工作 agent 数(默认3);对之后启动的任务生效
<<<<<<< Updated upstream
=======
// 任务并发上限:同时「运行中」的任务数上限。关闭=不限;开启后新建任务超限则排队,有空位自动启动。
task_concurrency_enabled?: boolean; // 默认 false
task_concurrency_limit?: number; // 开启后默认 5
@@ -630,7 +757,6 @@ export interface Settings {
// 操作约束注入范围(默认都开):把任务的 allow/deny 约束拼进对应 agent 的系统提示。
constraints_inject_planner?: boolean;
constraints_inject_worker?: boolean;
>>>>>>> Stashed changes
}
// ---- LLM config ----
@@ -645,9 +771,42 @@ 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;
// 轮询顺位:越大越先被选中。激活配置恒为链首,与本值无关。
priority?: number;
// true = 不作为故障转移目标(仍可被 agent/任务显式绑定使用)。
pool_exclude?: boolean;
}
// ---- LLM 轮询(故障转移)----
// 一个配置在轮询链中的位置与健康状态。state:
// ok 正常
// degraded 有连续失败但未达熔断阈值
// tripped 已熔断,冷却期内被跳过(cooldown_secs 为剩余秒数)
export interface LLMPoolMember {
profile_id: string;
name: string;
model: string;
format: string;
priority: number;
active: boolean; // 是否为当前激活配置(恒为链首)
excluded: boolean; // pool_exclude:不参与轮询
state: "ok" | "degraded" | "tripped";
fails: number;
trips: number;
cooldown_secs: number;
last_error?: string;
last_at?: string;
}
export interface LLMPoolStatus {
enabled: boolean;
bind_fallback: boolean;
chain: LLMPoolMember[];
}
// ---- Agents ----
@@ -736,6 +895,28 @@ export interface SkillItem {
compatibility?: string; // optional: environment requirements
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 (内置工具目录) ----
@@ -763,6 +944,7 @@ export interface Stats {
llm_configured: boolean;
roe_enabled: boolean;
findings_confirmed: number;
active_task?: Partial<Task>;
}
// ---- Intercept Rules ----