feat(api): integrate task workflows and mock contracts
This commit is contained in:
1 parent
ef57f3f196
commit
ec971c63d2
9 files changed
+5809
-778
No files matched your search
+210
-10
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
+264
-10
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
+579
-70
@@ -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
@@ -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 ----
|
||||
|
||||
Reference in new issue
Block a user