feat(api): integrate task workflows and mock contracts
This commit is contained in:
1 parent
ef57f3f196
commit
ec971c63d2
9 files changed
+5867
-836
No files matched your search
+210
-10
@@ -31,6 +31,11 @@ CREATE TABLE IF NOT EXISTS assets (
|
|||||||
'root_domain','ip','subdomain','app','service','endpoint'
|
'root_domain','ip','subdomain','app','service','endpoint'
|
||||||
)),
|
)),
|
||||||
company_id BIGINT REFERENCES companies(id) ON DELETE SET NULL,
|
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 '{}',
|
task_ids BIGINT[] NOT NULL DEFAULT '{}',
|
||||||
domain TEXT,
|
domain TEXT,
|
||||||
root_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_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_last_seen ON assets(last_seen DESC);
|
||||||
CREATE INDEX IF NOT EXISTS idx_av2_type_seen ON assets(type, 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;
|
DROP TRIGGER IF EXISTS trg_av2_upd ON assets;
|
||||||
CREATE TRIGGER trg_av2_upd BEFORE UPDATE ON assets
|
CREATE TRIGGER trg_av2_upd BEFORE UPDATE ON assets
|
||||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
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 (
|
CREATE TABLE IF NOT EXISTS company_scope (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
company_id BIGINT NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
|
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,
|
domain TEXT,
|
||||||
net CIDR,
|
net CIDR,
|
||||||
|
value TEXT,
|
||||||
raw TEXT NOT NULL,
|
raw TEXT NOT NULL,
|
||||||
reason TEXT,
|
reason TEXT,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
CONSTRAINT uq_sv2_domain UNIQUE (company_id, domain),
|
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_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 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);
|
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',
|
state TEXT NOT NULL DEFAULT 'open',
|
||||||
origin TEXT,
|
origin TEXT,
|
||||||
owner TEXT,
|
owner TEXT,
|
||||||
|
blocked_reason TEXT,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
completed_at TIMESTAMPTZ,
|
completed_at TIMESTAMPTZ,
|
||||||
CONSTRAINT ck_node_kind CHECK (kind IN ('begin','goal','intent','fact','finding','hint')),
|
CONSTRAINT ck_node_kind CHECK (kind IN ('begin','goal','intent','fact','finding','hint')),
|
||||||
CONSTRAINT ck_node_state CHECK (
|
CONSTRAINT ck_node_state CHECK (
|
||||||
(kind='begin' AND state IN ('open')) OR
|
(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='goal' AND state IN ('open','met','abandoned')) OR
|
||||||
(kind='fact' AND state IN ('confirmed','dismissed','origin')) OR
|
(kind='fact' AND state IN ('confirmed','dismissed','origin')) OR
|
||||||
(kind='finding' AND state IN ('confirmed','dismissed')) OR
|
(kind='finding' AND state IN ('confirmed','dismissed')) OR
|
||||||
(kind='hint' AND state IN ('active','consumed'))
|
(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_part ON exploration_nodes(exploration_id, kind);
|
||||||
CREATE INDEX IF NOT EXISTS idx_expnodes_frontier ON exploration_nodes(exploration_id, priority DESC)
|
CREATE INDEX IF NOT EXISTS idx_expnodes_frontier ON exploration_nodes(exploration_id, priority DESC)
|
||||||
WHERE kind='intent' AND state='open';
|
WHERE kind='intent' AND state='open';
|
||||||
@@ -194,12 +246,14 @@ CREATE TABLE IF NOT EXISTS activity (
|
|||||||
is_error BOOLEAN NOT NULL DEFAULT false,
|
is_error BOOLEAN NOT NULL DEFAULT false,
|
||||||
summary TEXT,
|
summary TEXT,
|
||||||
detail TEXT,
|
detail TEXT,
|
||||||
|
metadata JSONB NOT NULL DEFAULT '{}',
|
||||||
input_tokens INTEGER,
|
input_tokens INTEGER,
|
||||||
output_tokens INTEGER,
|
output_tokens INTEGER,
|
||||||
cache_read_tokens INTEGER,
|
cache_read_tokens INTEGER,
|
||||||
cache_write_tokens INTEGER,
|
cache_write_tokens INTEGER,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
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_node ON activity(exploration_id, node_id, id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_act_since ON activity(exploration_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
|
-- 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_second DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||||
rate_per_minute DOUBLE PRECISION NOT NULL DEFAULT 0,
|
rate_per_minute DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||||
context_window_k INTEGER 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 '',
|
reasoning_effort TEXT NOT NULL DEFAULT '',
|
||||||
|
thinking_type TEXT NOT NULL DEFAULT '',
|
||||||
is_default BOOLEAN NOT NULL DEFAULT false,
|
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(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
updated_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;
|
DROP TRIGGER IF EXISTS trg_llm_upd ON llm_profiles;
|
||||||
CREATE TRIGGER trg_llm_upd BEFORE UPDATE ON llm_profiles
|
CREATE TRIGGER trg_llm_upd BEFORE UPDATE ON llm_profiles
|
||||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
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. 任务层
|
-- 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 (
|
CREATE TABLE IF NOT EXISTS tasks (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
name TEXT NOT NULL DEFAULT '',
|
name TEXT NOT NULL DEFAULT '',
|
||||||
|
category_id BIGINT REFERENCES task_categories(id) ON DELETE SET NULL,
|
||||||
description TEXT NOT NULL,
|
description TEXT NOT NULL,
|
||||||
goal TEXT NOT NULL,
|
goal TEXT NOT NULL,
|
||||||
exploration_id BIGINT NOT NULL UNIQUE
|
exploration_id BIGINT NOT NULL UNIQUE
|
||||||
@@ -250,7 +362,12 @@ CREATE TABLE IF NOT EXISTS tasks (
|
|||||||
status TEXT NOT NULL DEFAULT 'created'
|
status TEXT NOT NULL DEFAULT 'created'
|
||||||
CHECK (status IN ('created','running','paused','done','failed','timeout')),
|
CHECK (status IN ('created','running','paused','done','failed','timeout')),
|
||||||
paused BOOLEAN NOT NULL DEFAULT false,
|
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,
|
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,
|
company_id BIGINT REFERENCES companies(id) ON DELETE SET NULL,
|
||||||
parent_ref TEXT,
|
parent_ref TEXT,
|
||||||
timeout_seconds INTEGER NOT NULL DEFAULT 0,
|
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();
|
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||||
-- planner 心跳触发间隔(秒);补旧库。默认 300s(5min)。见 docs/planner-trigger-impl-plan.md
|
-- 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;
|
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS plan_heartbeat_seconds INTEGER NOT NULL DEFAULT 300;
|
||||||
<<<<<<< Updated upstream
|
|
||||||
=======
|
|
||||||
-- 并发上限挂起态;补旧库。true=因并发上限排队、等待空位自动启动。
|
-- 并发上限挂起态;补旧库。true=因并发上限排队、等待空位自动启动。
|
||||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS queued BOOLEAN NOT NULL DEFAULT false;
|
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS queued BOOLEAN NOT NULL DEFAULT false;
|
||||||
-- 资产覆盖度功能开关;补旧库。true(默认)=计算/展示测试覆盖度、自动累积测试范围、
|
-- 资产覆盖度功能开关;补旧库。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 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 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 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;
|
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
|
-- Reusable task description/goal presets. nkey is the normalized, case-insensitive
|
||||||
-- identity used to reject visually equivalent duplicate names.
|
-- 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);
|
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
|
-- Ordered task-level LLM failover chain. A quota-exhausted entry is skipped
|
||||||
-- until the user saves/resets the chain, which clears all failure state.
|
-- until the user saves/resets the chain, which clears all failure state.
|
||||||
CREATE TABLE IF NOT EXISTS task_llm_profiles (
|
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
|
WHERE t.active_llm_profile_id IS NULL
|
||||||
AND t.llm_profile_id IS NOT 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);
|
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 显式插入的资产类型加保守范围
|
-- 自动填(source='auto'):insertAssets 顶层按 worker 显式插入的资产类型加保守范围
|
||||||
@@ -363,17 +529,24 @@ WHERE t.active_llm_profile_id IS NULL
|
|||||||
CREATE TABLE IF NOT EXISTS task_scope (
|
CREATE TABLE IF NOT EXISTS task_scope (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
task_id BIGINT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
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'
|
company_id BIGINT REFERENCES companies(id) ON DELETE CASCADE, -- kind='company'
|
||||||
domain TEXT, -- root_domain / subdomain
|
domain TEXT, -- root_domain / subdomain
|
||||||
net CIDR, -- ip / cidr
|
net CIDR, -- ip / cidr
|
||||||
|
value TEXT, -- icp / keyword
|
||||||
source TEXT NOT NULL DEFAULT 'auto' CHECK (source IN ('auto','agent','manual')),
|
source TEXT NOT NULL DEFAULT 'auto' CHECK (source IN ('auto','agent','manual')),
|
||||||
reason TEXT,
|
reason TEXT,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
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 的同一条范围只存一次(自动填批量插入靠它幂等)。
|
-- 去重:同一 task 的同一条范围只存一次(自动填批量插入靠它幂等)。
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_task_scope ON task_scope(
|
DROP INDEX IF EXISTS uq_task_scope;
|
||||||
task_id, kind, COALESCE(domain,''), COALESCE(net::text,''), COALESCE(company_id,0));
|
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_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_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';
|
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,
|
llm_profile_id BIGINT REFERENCES llm_profiles(id) ON DELETE SET NULL,
|
||||||
current_prompt_id BIGINT,
|
current_prompt_id BIGINT,
|
||||||
max_turns INTEGER NOT NULL DEFAULT 0,
|
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,
|
web_search BOOLEAN NOT NULL DEFAULT false,
|
||||||
interactive_shell BOOLEAN NOT NULL DEFAULT false,
|
interactive_shell BOOLEAN NOT NULL DEFAULT false,
|
||||||
wrapup_prompt TEXT NOT NULL DEFAULT '',
|
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;
|
ALTER TABLE agents ADD COLUMN IF NOT EXISTS trigger_max_parallel INTEGER NOT NULL DEFAULT 5;
|
||||||
-- per-agent LLM 绑定(agent 级默认模型):列自初版即在上方 CREATE 中,此 ALTER 仅为极旧库兜底(幂等)。
|
-- 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;
|
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;
|
DROP TRIGGER IF EXISTS trg_agents_upd ON agents;
|
||||||
CREATE TRIGGER trg_agents_upd BEFORE UPDATE ON agents
|
CREATE TRIGGER trg_agents_upd BEFORE UPDATE ON agents
|
||||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
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);
|
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. 内置工具目录
|
-- H. 内置工具目录
|
||||||
-- =====================================================================
|
-- =====================================================================
|
||||||
@@ -528,9 +724,13 @@ CREATE TABLE IF NOT EXISTS conversations (
|
|||||||
agent_key TEXT NOT NULL,
|
agent_key TEXT NOT NULL,
|
||||||
title TEXT NOT NULL DEFAULT '',
|
title TEXT NOT NULL DEFAULT '',
|
||||||
llm_profile_id BIGINT REFERENCES llm_profiles(id) ON DELETE SET NULL,
|
llm_profile_id BIGINT REFERENCES llm_profiles(id) ON DELETE SET NULL,
|
||||||
|
pinned_at TIMESTAMPTZ,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
updated_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;
|
DROP TRIGGER IF EXISTS trg_conversations_upd ON conversations;
|
||||||
CREATE TRIGGER trg_conversations_upd BEFORE UPDATE ON conversations
|
CREATE TRIGGER trg_conversations_upd BEFORE UPDATE ON conversations
|
||||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||||
|
|||||||
+75
-3
@@ -6,6 +6,8 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -92,15 +94,85 @@ func TestCoreTaskLifecyclePG(t *testing.T) {
|
|||||||
t.Fatalf("list tasks: %d", code)
|
t.Fatalf("list tasks: %d", code)
|
||||||
}
|
}
|
||||||
|
|
||||||
// delete → cascade removes the exploration subgraph
|
// The task detail header consumes the top-level engine mode while some clients
|
||||||
code, _ = doRetry("DELETE", "/api/tasks/"+id, nil)
|
// 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 {
|
if code != 200 {
|
||||||
t.Fatalf("delete task: %d", code)
|
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 {
|
if got, _ := m.pg.GetTask(nid); got != nil {
|
||||||
t.Fatalf("task should be deleted from PG")
|
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
|
var n int
|
||||||
m.pg.QueryRow(`SELECT count(*) FROM exploration_nodes WHERE exploration_id=$1`, expID).Scan(&n)
|
m.pg.QueryRow(`SELECT count(*) FROM exploration_nodes WHERE exploration_id=$1`, expID).Scan(&n)
|
||||||
if n != 0 {
|
if n != 0 {
|
||||||
|
|||||||
+89
-54
@@ -30,26 +30,11 @@ func rawString(raw json.RawMessage) string {
|
|||||||
|
|
||||||
// ---- Task (frontend "Task") ---- created_at as RFC3339, plus a derived status.
|
// ---- Task (frontend "Task") ---- created_at as RFC3339, plus a derived status.
|
||||||
type TaskDTO struct {
|
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"`
|
ID string `json:"id"`
|
||||||
ExplorationID int64 `json:"exploration_id"`
|
ExplorationID int64 `json:"exploration_id"`
|
||||||
Name string `json:"name"` // 可选任务名称;空=未命名
|
Name string `json:"name"` // 可选任务名称;空=未命名
|
||||||
|
CategoryID *int64 `json:"category_id,omitempty"`
|
||||||
|
CategoryName string `json:"category_name,omitempty"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Goal string `json:"goal"`
|
Goal string `json:"goal"`
|
||||||
Status string `json:"status"` // created | running | paused | done | failed
|
Status string `json:"status"` // created | running | paused | done | failed
|
||||||
@@ -71,7 +56,6 @@ type TaskDTO struct {
|
|||||||
SourceTaskIDs []string `json:"source_task_ids"`
|
SourceTaskIDs []string `json:"source_task_ids"`
|
||||||
CompanyIDs []int64 `json:"company_ids"`
|
CompanyIDs []int64 `json:"company_ids"`
|
||||||
CoverageEnabled bool `json:"coverage_enabled"` // 资产覆盖度功能开关(创建时定)
|
CoverageEnabled bool `json:"coverage_enabled"` // 资产覆盖度功能开关(创建时定)
|
||||||
>>>>>>> Stashed changes
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TokenTotalDTO is a whole-task (all agents) token aggregate.
|
// 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 {
|
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{
|
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,
|
ID: t.ID,
|
||||||
ExplorationID: t.ExpID,
|
ExplorationID: t.ExpID,
|
||||||
Name: t.Name,
|
Name: t.Name,
|
||||||
|
CategoryID: lifecycle.CategoryID,
|
||||||
|
CategoryName: lifecycle.CategoryName,
|
||||||
Description: t.Description,
|
Description: t.Description,
|
||||||
Goal: t.Goal,
|
Goal: t.Goal,
|
||||||
Status: status,
|
Status: status,
|
||||||
@@ -126,7 +106,6 @@ func taskDTO(t *Task, status string) TaskDTO {
|
|||||||
SourceTaskIDs: sourceIDs,
|
SourceTaskIDs: sourceIDs,
|
||||||
CompanyIDs: lifecycle.CompanyIDs,
|
CompanyIDs: lifecycle.CompanyIDs,
|
||||||
CoverageEnabled: t.CoverageEnabled,
|
CoverageEnabled: t.CoverageEnabled,
|
||||||
>>>>>>> Stashed changes
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,17 +149,19 @@ func trafficDTOs(ex []traffic.ExchangeMeta) []TrafficExchangeDTO {
|
|||||||
// ---- TaskNode (frontend "TaskNode") — frontier, intents, exploration graph nodes ----
|
// ---- TaskNode (frontend "TaskNode") — frontier, intents, exploration graph nodes ----
|
||||||
|
|
||||||
type TaskNodeDTO struct {
|
type TaskNodeDTO struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Type string `json:"type"` // db Node.Kind
|
Type string `json:"type"` // db Node.Kind
|
||||||
Payload string `json:"payload,omitempty"`
|
Payload string `json:"payload,omitempty"`
|
||||||
Priority int `json:"priority"`
|
Priority int `json:"priority"`
|
||||||
State string `json:"state"`
|
State string `json:"state"`
|
||||||
Origin string `json:"origin"`
|
Origin string `json:"origin"`
|
||||||
TS string `json:"ts"`
|
TS string `json:"ts"`
|
||||||
|
SourceTaskID string `json:"source_task_id,omitempty"`
|
||||||
|
Inherited bool `json:"inherited,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func taskNodeDTO(n *db.Node) TaskNodeDTO {
|
func taskNodeDTO(n *db.Node) TaskNodeDTO {
|
||||||
return TaskNodeDTO{
|
d := TaskNodeDTO{
|
||||||
ID: i64s(n.ID),
|
ID: i64s(n.ID),
|
||||||
Type: n.Kind,
|
Type: n.Kind,
|
||||||
Payload: rawString(n.Payload),
|
Payload: rawString(n.Payload),
|
||||||
@@ -189,6 +170,11 @@ func taskNodeDTO(n *db.Node) TaskNodeDTO {
|
|||||||
Origin: n.Origin,
|
Origin: n.Origin,
|
||||||
TS: rfc3339(n.CreatedAt),
|
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
|
// 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
|
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") ----
|
// ---- Finding (frontend "Finding") ----
|
||||||
|
|
||||||
type FindingDTO struct {
|
type FindingDTO struct {
|
||||||
@@ -280,6 +287,8 @@ type FindingDTO struct {
|
|||||||
ParamID string `json:"param_id,omitempty"`
|
ParamID string `json:"param_id,omitempty"`
|
||||||
TaskID string `json:"task_id,omitempty"`
|
TaskID string `json:"task_id,omitempty"`
|
||||||
TaskDescription string `json:"task_description,omitempty"`
|
TaskDescription string `json:"task_description,omitempty"`
|
||||||
|
SourceTaskID string `json:"source_task_id,omitempty"`
|
||||||
|
Inherited bool `json:"inherited,omitempty"`
|
||||||
Assets []FindingAssetDTO `json:"assets,omitempty"`
|
Assets []FindingAssetDTO `json:"assets,omitempty"`
|
||||||
TS string `json:"ts"`
|
TS string `json:"ts"`
|
||||||
}
|
}
|
||||||
@@ -335,7 +344,7 @@ type findingPayload struct {
|
|||||||
func findingDTO(n *db.Node) FindingDTO {
|
func findingDTO(n *db.Node) FindingDTO {
|
||||||
var p findingPayload
|
var p findingPayload
|
||||||
_ = json.Unmarshal(n.Payload, &p)
|
_ = json.Unmarshal(n.Payload, &p)
|
||||||
return FindingDTO{
|
d := FindingDTO{
|
||||||
ID: i64s(n.ID),
|
ID: i64s(n.ID),
|
||||||
VulnClass: p.VulnClass,
|
VulnClass: p.VulnClass,
|
||||||
Name: p.Name,
|
Name: p.Name,
|
||||||
@@ -345,6 +354,11 @@ func findingDTO(n *db.Node) FindingDTO {
|
|||||||
Evidence: rawString(p.Evidence),
|
Evidence: rawString(p.Evidence),
|
||||||
TS: rfc3339(n.CreatedAt),
|
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
|
// 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).
|
// nodes with no row keep the 'pending' default and no finding_id (not editable).
|
||||||
// assets pre-resolves the anchored asset rows for label rendering.
|
// 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 {
|
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))
|
out := make([]FindingDTO, 0, len(in))
|
||||||
for _, n := range in {
|
for _, n := range in {
|
||||||
d := findingDTO(n)
|
d := findingDTO(n)
|
||||||
d.TaskID = t.ID
|
d.TaskID = taskID
|
||||||
d.TaskDescription = t.Description
|
d.TaskDescription = description
|
||||||
if m, ok := meta[n.ID]; ok {
|
if m, ok := meta[n.ID]; ok {
|
||||||
d.FindingID = i64s(m.ID)
|
d.FindingID = i64s(m.ID)
|
||||||
d.Status = m.Status
|
d.Status = m.Status
|
||||||
@@ -414,16 +432,19 @@ func findingFromDB(f *db.DBFinding, assets map[int64]*db.Asset) FindingDTO {
|
|||||||
// ---- Activity (frontend "Activity") ----
|
// ---- Activity (frontend "Activity") ----
|
||||||
|
|
||||||
type ActivityDTO struct {
|
type ActivityDTO struct {
|
||||||
Seq int64 `json:"seq"` // db Activity.ID
|
Seq int64 `json:"seq"` // db Activity.ID
|
||||||
IntentID string `json:"intent_id,omitempty"` // db NodeID
|
IntentID string `json:"intent_id,omitempty"` // db NodeID
|
||||||
Worker string `json:"worker"`
|
Worker string `json:"worker"`
|
||||||
TS string `json:"ts"` // db CreatedAt
|
TS string `json:"ts"` // db CreatedAt
|
||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
Tool string `json:"tool,omitempty"`
|
Tool string `json:"tool,omitempty"`
|
||||||
ToolUseID string `json:"tool_use_id,omitempty"`
|
ToolUseID string `json:"tool_use_id,omitempty"`
|
||||||
IsError bool `json:"is_error"`
|
IsError bool `json:"is_error"`
|
||||||
Summary string `json:"summary"`
|
Summary string `json:"summary"`
|
||||||
Detail string `json:"detail,omitempty"`
|
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.
|
// token usage (set only on kind='result'); used for per-session token totals.
|
||||||
InputTokens *int `json:"input_tokens,omitempty"`
|
InputTokens *int `json:"input_tokens,omitempty"`
|
||||||
OutputTokens *int `json:"output_tokens,omitempty"`
|
OutputTokens *int `json:"output_tokens,omitempty"`
|
||||||
@@ -436,7 +457,7 @@ func activityDTO(a db.Activity) ActivityDTO {
|
|||||||
if a.NodeID != nil {
|
if a.NodeID != nil {
|
||||||
intent = i64s(*a.NodeID)
|
intent = i64s(*a.NodeID)
|
||||||
}
|
}
|
||||||
return ActivityDTO{
|
d := ActivityDTO{
|
||||||
Seq: a.ID,
|
Seq: a.ID,
|
||||||
IntentID: intent,
|
IntentID: intent,
|
||||||
Worker: a.Worker,
|
Worker: a.Worker,
|
||||||
@@ -447,11 +468,17 @@ func activityDTO(a db.Activity) ActivityDTO {
|
|||||||
IsError: a.IsError,
|
IsError: a.IsError,
|
||||||
Summary: a.Summary,
|
Summary: a.Summary,
|
||||||
Detail: a.Detail, // list endpoint leaves this empty (lazy)
|
Detail: a.Detail, // list endpoint leaves this empty (lazy)
|
||||||
|
Metadata: a.Metadata,
|
||||||
InputTokens: a.InputTokens,
|
InputTokens: a.InputTokens,
|
||||||
OutputTokens: a.OutputTokens,
|
OutputTokens: a.OutputTokens,
|
||||||
CacheReadTokens: a.CacheReadTokens,
|
CacheReadTokens: a.CacheReadTokens,
|
||||||
CacheWriteTokens: a.CacheWriteTokens,
|
CacheWriteTokens: a.CacheWriteTokens,
|
||||||
}
|
}
|
||||||
|
if a.SourceTaskID > 0 {
|
||||||
|
d.SourceTaskID = i64s(a.SourceTaskID)
|
||||||
|
}
|
||||||
|
d.Inherited = a.Inherited
|
||||||
|
return d
|
||||||
}
|
}
|
||||||
|
|
||||||
func activityDTOs(in []db.Activity) []ActivityDTO {
|
func activityDTOs(in []db.Activity) []ActivityDTO {
|
||||||
@@ -528,8 +555,13 @@ type LLMProfileDTO struct {
|
|||||||
RatePerSecond float64 `json:"rate_per_second"`
|
RatePerSecond float64 `json:"rate_per_second"`
|
||||||
RatePerMinute float64 `json:"rate_per_minute"`
|
RatePerMinute float64 `json:"rate_per_minute"`
|
||||||
ContextWindowK int `json:"context_window_k"`
|
ContextWindowK int `json:"context_window_k"`
|
||||||
|
ThinkingType string `json:"thinking_type"`
|
||||||
ReasoningEffort string `json:"reasoning_effort"`
|
ReasoningEffort string `json:"reasoning_effort"`
|
||||||
IsDefault bool `json:"is_default"`
|
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 {
|
func llmProfileDTO(p *db.LLMProfile) LLMProfileDTO {
|
||||||
@@ -544,8 +576,11 @@ func llmProfileDTO(p *db.LLMProfile) LLMProfileDTO {
|
|||||||
RatePerSecond: p.RatePerSecond,
|
RatePerSecond: p.RatePerSecond,
|
||||||
RatePerMinute: p.RatePerMinute,
|
RatePerMinute: p.RatePerMinute,
|
||||||
ContextWindowK: p.ContextWindowK,
|
ContextWindowK: p.ContextWindowK,
|
||||||
|
ThinkingType: p.ThinkingType,
|
||||||
ReasoningEffort: p.ReasoningEffort,
|
ReasoningEffort: p.ReasoningEffort,
|
||||||
IsDefault: p.IsDefault,
|
IsDefault: p.IsDefault,
|
||||||
|
Priority: p.Priority,
|
||||||
|
PoolExclude: p.PoolExclude,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+986
-190
File diff suppressed because it is too large.
Load diff
+268
-14
@@ -5,6 +5,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
@@ -66,14 +67,135 @@ func decode(r *http.Request, v any) error { return json.NewDecoder(r.Body).Decod
|
|||||||
|
|
||||||
// ---------- tasks (delete) ----------
|
// ---------- 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) {
|
func (s *Server) pgDeleteTask(w http.ResponseWriter, r *http.Request) {
|
||||||
id := r.PathValue("id")
|
id, ok := canonicalTaskID(r.PathValue("id"))
|
||||||
s.engine.Pause(id) // cancel running loops before removing
|
if !ok {
|
||||||
if err := s.m.DeleteTask(id); err != nil {
|
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())
|
writeErr(w, 500, err.Error())
|
||||||
return
|
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 ----------
|
// ---------- agents ----------
|
||||||
@@ -305,6 +427,9 @@ func (s *Server) pgSaveAgentConfig(w http.ResponseWriter, r *http.Request) {
|
|||||||
if profileChanged {
|
if profileChanged {
|
||||||
s.invalidateProfileAgents()
|
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.
|
// rebuild the live agents so the new max_turns/run_seconds/binding apply without a restart.
|
||||||
s.cfgMu.Lock()
|
s.cfgMu.Lock()
|
||||||
cfg, on := s.llmCfg, s.llmOn
|
cfg, on := s.llmCfg, s.llmOn
|
||||||
@@ -349,8 +474,8 @@ func (s *Server) pgGetAgent(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
writeJSON(w, 200, map[string]any{
|
writeJSON(w, 200, map[string]any{
|
||||||
"agent": agentDTO(a), "prompt": cur, "variables": vars, "versions": vers,
|
"agent": agentDTO(a), "prompt": cur, "variables": vars, "versions": vers,
|
||||||
"visibility": map[string]any{"mcp": mcp, "skill": sk},
|
"visibility": map[string]any{"mcp": mcp, "skill": sk},
|
||||||
"llm_profiles": llmProfiles, // 可绑定的 LLM 配置候选
|
"llm_profiles": llmProfiles, // 可绑定的 LLM 配置候选
|
||||||
|
|
||||||
"wrapup_prompt": a.WrapupPrompt, // 已保存的收尾提示词(空=用内置默认)
|
"wrapup_prompt": a.WrapupPrompt, // 已保存的收尾提示词(空=用内置默认)
|
||||||
"wrapup_default": agent.WrapupDefault(a.Key), // 内置默认(供占位/恢复默认)
|
"wrapup_default": agent.WrapupDefault(a.Key), // 内置默认(供占位/恢复默认)
|
||||||
@@ -810,6 +935,12 @@ type skillFileNode struct {
|
|||||||
Compatibility string `json:"compatibility,omitempty"`
|
Compatibility string `json:"compatibility,omitempty"`
|
||||||
MCPs []string `json:"mcps,omitempty"`
|
MCPs []string `json:"mcps,omitempty"`
|
||||||
Files []string `json:"files"`
|
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) {
|
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())
|
writeErr(w, 500, err.Error())
|
||||||
return
|
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{}
|
nodes := []skillFileNode{}
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
if !e.IsDir() {
|
if !e.IsDir() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
dirName := e.Name()
|
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 {
|
if meta, ok := metaByDir[dirName]; ok {
|
||||||
node.Description = meta.Description
|
node.Description = meta.Description
|
||||||
node.License = meta.License
|
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})
|
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.
|
// cleanStrs trims each string and drops empties.
|
||||||
func cleanStrs(in []string) []string {
|
func cleanStrs(in []string) []string {
|
||||||
out := make([]string, 0, len(in))
|
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())
|
writeErr(w, 500, err.Error())
|
||||||
return
|
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()
|
s.invalidateProfileAgents()
|
||||||
// If we just edited the active profile, hot-apply so changes take effect now.
|
// Hot-apply. Not just when the ACTIVE profile changed: with failover on, any
|
||||||
if act, _ := pg.ActiveProfile(); act != nil && act.ID == id {
|
// profile's key/model/priority/pool_exclude edit reshapes the chain, so the
|
||||||
s.reapplyActiveProfile()
|
// engine's provider has to be rebuilt either way.
|
||||||
}
|
s.reapplyActiveProfile()
|
||||||
writeJSON(w, 200, map[string]any{"id": id})
|
writeJSON(w, 200, map[string]any{"id": id})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1575,14 +1759,50 @@ func (s *Server) pgDeleteProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
id, _ := pathInt(r, "id")
|
id, _ := pathInt(r, "id")
|
||||||
if err := pg.DeleteProfile(id); err != nil {
|
if err := pg.DeleteProfileContext(r.Context(), id); err != nil {
|
||||||
writeErr(w, 500, err.Error())
|
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
|
return
|
||||||
}
|
}
|
||||||
s.invalidateProfileAgents() // drop cached agents for the removed profile
|
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})
|
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) {
|
func (s *Server) pgActivateProfile(w http.ResponseWriter, r *http.Request) {
|
||||||
pg := s.pg(w)
|
pg := s.pg(w)
|
||||||
if pg == nil {
|
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})
|
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.
|
// pgListModels fetches available models from the provider's API endpoint.
|
||||||
// Supports OpenAI-format (GET /models) and Anthropic-format (GET /v1/models); for
|
// 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
|
// 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 { MOCK } from "@/lib/mock/enabled";
|
||||||
import { mockHandle } from "@/lib/mock/handler";
|
import { mockHandle } from "@/lib/mock/handler";
|
||||||
import type {
|
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,
|
Activity,
|
||||||
Agent,
|
Agent,
|
||||||
AgentDetail,
|
AgentDetail,
|
||||||
@@ -43,6 +32,7 @@ import type {
|
|||||||
FindingStats,
|
FindingStats,
|
||||||
FindingStatus,
|
FindingStatus,
|
||||||
FindingsPage,
|
FindingsPage,
|
||||||
|
IntentAsset,
|
||||||
InterceptApprovalRow,
|
InterceptApprovalRow,
|
||||||
InterceptPending,
|
InterceptPending,
|
||||||
InterceptRule,
|
InterceptRule,
|
||||||
@@ -67,6 +57,9 @@ import type {
|
|||||||
SSTask,
|
SSTask,
|
||||||
Stats,
|
Stats,
|
||||||
Task,
|
Task,
|
||||||
|
TaskAssetMutation,
|
||||||
|
TaskAssetScopeMutation,
|
||||||
|
TaskCategory,
|
||||||
TaskConstraint,
|
TaskConstraint,
|
||||||
TaskGoal,
|
TaskGoal,
|
||||||
TaskLLMResolutions,
|
TaskLLMResolutions,
|
||||||
@@ -82,7 +75,6 @@ import type {
|
|||||||
UsageStats,
|
UsageStats,
|
||||||
WorkspaceFile,
|
WorkspaceFile,
|
||||||
WorkspaceListing,
|
WorkspaceListing,
|
||||||
>>>>>>> Stashed changes
|
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
|
|
||||||
function getToken(): string | null {
|
function getToken(): string | null {
|
||||||
@@ -109,7 +101,19 @@ async function http<T>(path: string, init?: RequestInit): Promise<T> {
|
|||||||
}
|
}
|
||||||
throw new Error("未授权");
|
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;
|
if (r.status === 204) return undefined as T;
|
||||||
return r.json();
|
return r.json();
|
||||||
}
|
}
|
||||||
@@ -148,19 +152,21 @@ function mockReport(_task?: string): string {
|
|||||||
export function sseUrl(path: string): string {
|
export function sseUrl(path: string): string {
|
||||||
const base =
|
const base =
|
||||||
process.env.NEXT_PUBLIC_SSE_BASE ??
|
process.env.NEXT_PUBLIC_SSE_BASE ??
|
||||||
(typeof window !== "undefined"
|
(typeof window !== "undefined" ? `${window.location.protocol}//${window.location.hostname}:8787` : "");
|
||||||
? `${window.location.protocol}//${window.location.hostname}:8787`
|
|
||||||
: "");
|
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
const sep = path.includes("?") ? "&" : "?";
|
const sep = path.includes("?") ? "&" : "?";
|
||||||
return token ? `${base}${path}${sep}token=${encodeURIComponent(token)}` : `${base}${path}`;
|
return token ? `${base}${path}${sep}token=${encodeURIComponent(token)}` : `${base}${path}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const get = <T>(p: string) => http<T>(p);
|
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 post = <T>(p: string, body?: unknown) =>
|
||||||
const put = <T>(p: string, body?: unknown) => http<T>(p, { method: "PUT", body: body ? JSON.stringify(body) : undefined });
|
http<T>(p, { method: "POST", 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 put = <T>(p: string, body?: unknown) =>
|
||||||
const del = <T>(p: string, body?: unknown) => http<T>(p, { method: "DELETE", body: body ? JSON.stringify(body) : undefined });
|
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 [].
|
// Go serializes nil slices as JSON null — coerce to [].
|
||||||
const arr = <T>(x: T[] | null | undefined): T[] => x ?? [];
|
const arr = <T>(x: T[] | null | undefined): T[] => x ?? [];
|
||||||
@@ -172,32 +178,17 @@ export const api = {
|
|||||||
|
|
||||||
// ---- auth ----
|
// ---- auth ----
|
||||||
authStatus: () => get<{ initialized: boolean }>("/auth/status"),
|
authStatus: () => get<{ initialized: boolean }>("/auth/status"),
|
||||||
login: (username: string, password: string) =>
|
login: (username: string, password: string) => post<{ token: string }>("/auth/login", { username, password }),
|
||||||
post<{ token: string }>("/auth/login", { username, password }),
|
initPassword: (password: string) => post<{ token: string }>("/auth/init", { password }),
|
||||||
initPassword: (password: string) =>
|
|
||||||
post<{ token: string }>("/auth/init", { password }),
|
|
||||||
changePassword: (oldPassword: string, newPassword: string) =>
|
changePassword: (oldPassword: string, newPassword: string) =>
|
||||||
post<{ ok: boolean }>("/auth/change-password", { old_password: oldPassword, new_password: newPassword }),
|
post<{ ok: boolean }>("/auth/change-password", { old_password: oldPassword, new_password: newPassword }),
|
||||||
|
|
||||||
// ---- tasks ----
|
// ---- 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: () =>
|
tasks: () =>
|
||||||
get<{ tasks: Task[]; active: string }>("/tasks").then((r) => ({ tasks: arr(r.tasks), active: r.active ?? "" })),
|
get<{ tasks: Task[]; active: string }>("/tasks").then((r) => ({ tasks: arr(r.tasks), active: r.active ?? "" })),
|
||||||
createTask: (input: {
|
createTask: (input: {
|
||||||
name?: string;
|
name?: string;
|
||||||
|
categoryId?: number;
|
||||||
description: string;
|
description: string;
|
||||||
goal: string;
|
goal: string;
|
||||||
llmProfileIds?: number[];
|
llmProfileIds?: number[];
|
||||||
@@ -210,6 +201,7 @@ export const api = {
|
|||||||
}) =>
|
}) =>
|
||||||
post<Task>("/tasks", {
|
post<Task>("/tasks", {
|
||||||
name: input.name ?? "",
|
name: input.name ?? "",
|
||||||
|
category_id: input.categoryId ?? null,
|
||||||
description: input.description,
|
description: input.description,
|
||||||
goal: input.goal,
|
goal: input.goal,
|
||||||
llm_profile_ids: input.llmProfileIds ?? [],
|
llm_profile_ids: input.llmProfileIds ?? [],
|
||||||
@@ -220,6 +212,12 @@ export const api = {
|
|||||||
plan_heartbeat_seconds: input.planHeartbeatSeconds ?? 0, // 0 = 后端归一到默认 600(10min)
|
plan_heartbeat_seconds: input.planHeartbeatSeconds ?? 0, // 0 = 后端归一到默认 600(10min)
|
||||||
coverage_enabled: input.coverageEnabled ?? true, // 默认开;false=关闭资产覆盖度功能
|
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)),
|
taskTemplates: () => get<{ templates: TaskTemplate[] }>("/task-templates").then((r) => arr(r.templates)),
|
||||||
createTaskTemplate: (input: Pick<TaskTemplate, "name" | "description" | "goal">) =>
|
createTaskTemplate: (input: Pick<TaskTemplate, "name" | "description" | "goal">) =>
|
||||||
post<TaskTemplate>("/task-templates", input),
|
post<TaskTemplate>("/task-templates", input),
|
||||||
@@ -250,9 +248,9 @@ export const api = {
|
|||||||
deleted?: { intents: number; facts: number; findings: number; activities: number };
|
deleted?: { intents: number; facts: number; findings: number; activities: number };
|
||||||
}>(`/tasks/${taskId}/intents/${intentId}/control`, { action, reason: reason ?? "" }),
|
}>(`/tasks/${taskId}/intents/${intentId}/control`, { action, reason: reason ?? "" }),
|
||||||
taskLLMResolution: (id: string) => get<TaskLLMResolutions>(`/tasks/${id}/llm/resolution`),
|
taskLLMResolution: (id: string) => get<TaskLLMResolutions>(`/tasks/${id}/llm/resolution`),
|
||||||
>>>>>>> Stashed changes
|
|
||||||
// 重跑一条没跑成功的意图(blocked/exhausted/stopped):置回 open,worker 会重新认领、从头再跑。
|
// 重跑一条没跑成功的意图(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 时一键全部重试)。
|
// 批量重跑本任务全部 blocked 意图(一次网络/LLM 断连导致多条 blocked 时一键全部重试)。
|
||||||
rerunBlocked: (taskId: string) => post<{ id: string; reopened: number }>(`/tasks/${taskId}/intents/rerun-blocked`),
|
rerunBlocked: (taskId: string) => post<{ id: string; reopened: number }>(`/tasks/${taskId}/intents/rerun-blocked`),
|
||||||
setActive: (id: string) => post<{ active: string }>("/active", { id }),
|
setActive: (id: string) => post<{ active: string }>("/active", { id }),
|
||||||
@@ -269,10 +267,6 @@ export const api = {
|
|||||||
by_type: { type: string; total: number; tested: number }[];
|
by_type: { type: string; total: number; tested: number }[];
|
||||||
}>(`/tasks/${id}/coverage`),
|
}>(`/tasks/${id}/coverage`),
|
||||||
// 资产覆盖图:范围内全部资产 + 连接用的根域名/公司节点,含 tested/in_scope。
|
// 资产覆盖图:范围内全部资产 + 连接用的根域名/公司节点,含 tested/in_scope。
|
||||||
<<<<<<< Updated upstream
|
|
||||||
taskCoverageGraph: (id: string) =>
|
|
||||||
get<CoverageGraphData>(`/tasks/${id}/coverage-graph`),
|
|
||||||
=======
|
|
||||||
taskCoverageGraph: (id: string) => get<CoverageGraphData>(`/tasks/${id}/coverage-graph`),
|
taskCoverageGraph: (id: string) => get<CoverageGraphData>(`/tasks/${id}/coverage-graph`),
|
||||||
// 全局 llm_usage 聚合(仪表盘新版 token 视图):按 profile 总量 + 按天分桶。
|
// 全局 llm_usage 聚合(仪表盘新版 token 视图):按 profile 总量 + 按天分桶。
|
||||||
usageStats: (days = 365) => get<UsageStats>(`/tokens/usage?days=${days}`),
|
usageStats: (days = 365) => get<UsageStats>(`/tokens/usage?days=${days}`),
|
||||||
@@ -308,22 +302,16 @@ export const api = {
|
|||||||
post<TaskScopeRow>(`/tasks/${id}/scope`, { kind, value, reason }),
|
post<TaskScopeRow>(`/tasks/${id}/scope`, { kind, value, reason }),
|
||||||
// 删除本任务的一条测试范围。
|
// 删除本任务的一条测试范围。
|
||||||
deleteTaskScope: (id: string, scopeId: number) => del<{ ok: boolean }>(`/tasks/${id}/scope/${scopeId}`),
|
deleteTaskScope: (id: string, scopeId: number) => del<{ ok: boolean }>(`/tasks/${id}/scope/${scopeId}`),
|
||||||
>>>>>>> Stashed changes
|
|
||||||
// 某资产在本任务里关联的意图 / 事实 / 发现(覆盖图节点抽屉用)。
|
// 某资产在本任务里关联的意图 / 事实 / 发现(覆盖图节点抽屉用)。
|
||||||
taskAssetRefs: (id: string, assetId: number) =>
|
taskAssetRefs: (id: string, assetId: number) => get<CoverageAssetRefs>(`/tasks/${id}/asset-refs?asset_id=${assetId}`),
|
||||||
get<CoverageAssetRefs>(`/tasks/${id}/asset-refs?asset_id=${assetId}`),
|
|
||||||
|
|
||||||
// ---- workspace file manager (workDir) ----
|
// ---- workspace file manager (workDir) ----
|
||||||
workspaceList: (path = "") =>
|
workspaceList: (path = "") => get<WorkspaceListing>(`/workspace/list?path=${encodeURIComponent(path)}`),
|
||||||
get<WorkspaceListing>(`/workspace/list?path=${encodeURIComponent(path)}`),
|
workspaceRead: (path: string) => get<WorkspaceFile>(`/workspace/read?path=${encodeURIComponent(path)}`),
|
||||||
workspaceRead: (path: string) =>
|
|
||||||
get<WorkspaceFile>(`/workspace/read?path=${encodeURIComponent(path)}`),
|
|
||||||
workspaceWrite: (path: string, content: string) =>
|
workspaceWrite: (path: string, content: string) =>
|
||||||
post<{ ok: boolean; path: string }>(`/workspace/write`, { path, content }),
|
post<{ ok: boolean; path: string }>(`/workspace/write`, { path, content }),
|
||||||
workspaceMkdir: (path: string) =>
|
workspaceMkdir: (path: string) => post<{ ok: boolean; path: string }>(`/workspace/mkdir`, { path }),
|
||||||
post<{ ok: boolean; path: string }>(`/workspace/mkdir`, { path }),
|
workspaceDelete: (path: string) => del<{ ok: boolean }>(`/workspace/delete?path=${encodeURIComponent(path)}`),
|
||||||
workspaceDelete: (path: string) =>
|
|
||||||
del<{ ok: boolean }>(`/workspace/delete?path=${encodeURIComponent(path)}`),
|
|
||||||
workspaceUpload: async (dir: string, files: File[]) => {
|
workspaceUpload: async (dir: string, files: File[]) => {
|
||||||
if (MOCK) return { uploaded: files.length };
|
if (MOCK) return { uploaded: files.length };
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
@@ -362,37 +350,54 @@ export const api = {
|
|||||||
// ---- assets ----
|
// ---- assets ----
|
||||||
// Server-side paginated: pass limit/offset, get back the page + full match total.
|
// Server-side paginated: pass limit/offset, get back the page + full match total.
|
||||||
assets: (type = "", limit = 50, offset = 0) =>
|
assets: (type = "", limit = 50, offset = 0) =>
|
||||||
get<{ count: number; total: number; assets: Asset[] }>(`/assets?type=${type}&limit=${limit}&offset=${offset}`)
|
get<{ count: number; total: number; assets: Asset[] }>(`/assets?type=${type}&limit=${limit}&offset=${offset}`).then(
|
||||||
.then((r) => ({ assets: r?.assets ?? [], total: r?.total ?? r?.count ?? 0 })),
|
(r) => ({ assets: r?.assets ?? [], total: r?.total ?? r?.count ?? 0 }),
|
||||||
|
),
|
||||||
searchAssets: (dsl: string, type = "", limit = 50, offset = 0) =>
|
searchAssets: (dsl: string, type = "", limit = 50, offset = 0) =>
|
||||||
get<{ count: number; total: number; assets: Asset[] }>(
|
get<{ count: number; total: number; assets: Asset[] }>(
|
||||||
`/assets?dsl=${encodeURIComponent(dsl)}${type ? `&type=${encodeURIComponent(type)}` : ""}&limit=${limit}&offset=${offset}`,
|
`/assets?dsl=${encodeURIComponent(dsl)}${type ? `&type=${encodeURIComponent(type)}` : ""}&limit=${limit}&offset=${offset}`,
|
||||||
).then((r) => ({ assets: r?.assets ?? [], total: r?.total ?? r?.count ?? 0 })),
|
).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[]) =>
|
deleteAssets: (ids: number[]) =>
|
||||||
http<{ deleted: number }>("/assets", { method: "DELETE", body: JSON.stringify({ ids }) }),
|
http<{ deleted: number }>("/assets", { method: "DELETE", body: JSON.stringify({ ids }) }),
|
||||||
// legacy — kept for task-specific views; hits the same endpoint with task_id filter
|
// task-scoped view of the same endpoint — server-side paginated like `assets`
|
||||||
taskAssets: (taskId: string, type = "") =>
|
taskAssets: (taskId: string, type = "", limit = 50, offset = 0) =>
|
||||||
get<{ count: number; assets: Asset[] }>(`/assets?task_id=${taskId}&type=${type}`)
|
get<{ count: number; total: number; assets: Asset[] }>(
|
||||||
.then((r) => r?.assets ?? []),
|
`/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 (企业 + 资产范围;归属唯一来源) ----
|
||||||
companies: () => get<Company[]>("/companies").then(arr),
|
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[] }>(
|
post<{ id: number; created: boolean; scope_added?: number; scope_invalid?: number; scope_errors?: string[] }>(
|
||||||
"/companies",
|
"/companies",
|
||||||
{ name, logo, scope: scope.trim() ? scope.split("\n").map((s) => s.trim()).filter(Boolean) : [] },
|
{
|
||||||
),
|
name,
|
||||||
addCompanyScope: (id: number, scope: string, reason = "") =>
|
scope,
|
||||||
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 },
|
|
||||||
),
|
),
|
||||||
|
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) =>
|
deleteCompany: (id: number, deleteAssets = false) =>
|
||||||
http<{ deleted: number; assets_deleted: number }>(`/companies/${id}`, {
|
http<{ deleted: number; assets_deleted: number }>(`/companies/${id}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
@@ -411,30 +416,85 @@ export const api = {
|
|||||||
if (q.sort) p.set("sort", q.sort);
|
if (q.sort) p.set("sort", q.sort);
|
||||||
return get<FindingsPage>(`/exploration/findings?${p.toString()}`);
|
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"),
|
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) =>
|
findingLineage: (id: string) => get<{ nodes: TaskNode[]; edges: Edge[] }>(`/exploration/findings/${id}/lineage`),
|
||||||
get<{ nodes: TaskNode[]; edges: Edge[] }>(`/exploration/findings/${id}/lineage`),
|
setFindingStatus: (id: string, status: FindingStatus) => patch<Finding>(`/exploration/findings/${id}`, { status }),
|
||||||
setFindingStatus: (id: string, status: FindingStatus) =>
|
setFindingSeverity: (id: string, severity: Severity) => patch<Finding>(`/exploration/findings/${id}`, { severity }),
|
||||||
patch<Finding>(`/exploration/findings/${id}`, { status }),
|
|
||||||
setFindingSeverity: (id: string, severity: Severity) =>
|
|
||||||
patch<Finding>(`/exploration/findings/${id}`, { severity }),
|
|
||||||
// 一次保存漏洞的名称/类别/严重等级(发现列表行内编辑用),只传出现的字段。
|
// 一次保存漏洞的名称/类别/严重等级(发现列表行内编辑用),只传出现的字段。
|
||||||
updateFinding: (
|
updateFinding: (
|
||||||
id: string,
|
id: string,
|
||||||
fields: { name?: string; vulnclass?: string; severity?: Severity; status?: FindingStatus },
|
fields: { name?: string; vulnclass?: string; severity?: Severity; status?: FindingStatus },
|
||||||
) => patch<Finding>(`/exploration/findings/${id}`, fields),
|
) => 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),
|
intents: (task?: string) => get<TaskNode[]>(`/exploration/intents${tq(task)}`).then(arr),
|
||||||
tokenStats: (task?: string) =>
|
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),
|
workers: arr(r.workers),
|
||||||
|
sessions: arr(r.sessions),
|
||||||
total: r.total,
|
total: r.total,
|
||||||
})),
|
})),
|
||||||
tokenDaily: (days = 30) =>
|
tokenDaily: (days = 30) => get<DailyTokenBucket[]>(`/tokens/daily?days=${days}`).then(arr),
|
||||||
get<DailyTokenBucket[]>(`/tokens/daily?days=${days}`).then(arr),
|
|
||||||
conversationTokens: () =>
|
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)}`),
|
explorationGraph: (task?: string) => get<{ nodes: TaskNode[]; edges: Edge[] }>(`/exploration/graph${tq(task)}`),
|
||||||
activity: (task?: string, opts?: { intent?: string; since?: number; limit?: number }) => {
|
activity: (task?: string, opts?: { intent?: string; since?: number; limit?: number }) => {
|
||||||
const q = new URLSearchParams();
|
const q = new URLSearchParams();
|
||||||
@@ -442,7 +502,10 @@ export const api = {
|
|||||||
if (opts?.intent) q.set("intent", opts.intent);
|
if (opts?.intent) q.set("intent", opts.intent);
|
||||||
if (opts?.since) q.set("since", String(opts.since));
|
if (opts?.since) q.set("since", String(opts.since));
|
||||||
if (opts?.limit) q.set("limit", String(opts.limit));
|
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)}`),
|
activityDetail: (id: number, task?: string) => get<{ detail: string }>(`/exploration/activity/${id}${tq(task)}`),
|
||||||
// Reverse-paginated session history. session = "main" | "plan" | "intent:<id>".
|
// Reverse-paginated session history. session = "main" | "plan" | "intent:<id>".
|
||||||
@@ -485,20 +548,21 @@ export const api = {
|
|||||||
(method && method !== "all" ? `&method=${encodeURIComponent(method)}` : "") +
|
(method && method !== "all" ? `&method=${encodeURIComponent(method)}` : "") +
|
||||||
(q ? `&q=${encodeURIComponent(q)}` : ""),
|
(q ? `&q=${encodeURIComponent(q)}` : ""),
|
||||||
),
|
),
|
||||||
trafficExchange: (id: string) =>
|
trafficExchange: (id: string) => get<TrafficDetail>(`/traffic/exchange?id=${encodeURIComponent(id)}`),
|
||||||
get<TrafficDetail>(`/traffic/exchange?id=${encodeURIComponent(id)}`),
|
|
||||||
trafficHosts: () => get<{ hosts: TrafficHost[] }>(`/traffic/hosts`),
|
trafficHosts: () => get<{ hosts: TrafficHost[] }>(`/traffic/hosts`),
|
||||||
trafficDeleteHost: (host: string) =>
|
trafficDeleteHost: (host: string) => del<{ deleted: number }>(`/traffic?host=${encodeURIComponent(host)}`),
|
||||||
del<{ deleted: number }>(`/traffic?host=${encodeURIComponent(host)}`),
|
trafficDeleteHosts: (hosts: string[]) => del<{ deleted: number }>(`/traffic/hosts`, { hosts }),
|
||||||
trafficDeleteHosts: (hosts: string[]) =>
|
|
||||||
del<{ deleted: number }>(`/traffic/hosts`, { hosts }),
|
|
||||||
|
|
||||||
// ---- app settings (runtime toggles) ----
|
// ---- app settings (runtime toggles) ----
|
||||||
settings: () => get<Settings>(`/settings`),
|
settings: () => get<Settings>(`/settings`),
|
||||||
setSettings: (patch: Partial<Settings>) => put<Settings>(`/settings`, patch),
|
setSettings: (patch: Partial<Settings>) => put<Settings>(`/settings`, patch),
|
||||||
// Run a real "test" search with the given (or saved) config to verify it works.
|
// 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 }) =>
|
testWebSearch: (patch: {
|
||||||
post<{ ok: boolean; error?: string; count?: number; backend?: string }>(`/settings/web-search/test`, 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) => {
|
report: async (task?: string) => {
|
||||||
if (MOCK) return mockReport(task);
|
if (MOCK) return mockReport(task);
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
@@ -510,9 +574,18 @@ export const api = {
|
|||||||
},
|
},
|
||||||
chat: (message: string, task?: string, attachments?: ChatAttachment[]) =>
|
chat: (message: string, task?: string, attachments?: ChatAttachment[]) =>
|
||||||
post<{ reply: string; mode: string }>(`/chat${tq(task)}`, { message, attachments }),
|
post<{ reply: string; mode: string }>(`/chat${tq(task)}`, { message, attachments }),
|
||||||
|
chatStatus: (taskId: string) => get<{ running: boolean }>(`/tasks/${taskId}/chat/status`),
|
||||||
// 方式1 文件上传:落到会话/任务工作目录 uploads/,返回可供 agent Read 的相对路径。
|
// 方式1 文件上传:落到会话/任务工作目录 uploads/,返回可供 agent Read 的相对路径。
|
||||||
chatUpload: async (scope: "task" | "session" | "staging", id: string, files: File[]) => {
|
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();
|
const fd = new FormData();
|
||||||
for (const f of files) fd.append("file", f);
|
for (const f of files) fd.append("file", f);
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
@@ -528,15 +601,37 @@ export const api = {
|
|||||||
gc: (ttl = 86400) => post<{ removed: number }>(`/gc?ttl=${ttl}`, {}),
|
gc: (ttl = 86400) => post<{ removed: number }>(`/gc?ttl=${ttl}`, {}),
|
||||||
|
|
||||||
// ---- LLM ----
|
// ---- 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"),
|
getLLM: () =>
|
||||||
setLLM: (provider: string, model: string, base_url: string, api_key: string, rate_per_second = 0, rate_per_minute = 0, proxy = "", context_window_k = 0) =>
|
get<{
|
||||||
post("/llm", { provider, model, base_url, proxy, api_key, rate_per_second, rate_per_minute, context_window_k }),
|
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: (
|
testLLM: (
|
||||||
provider: string,
|
provider: string,
|
||||||
model: string,
|
model: string,
|
||||||
base_url: string,
|
base_url: string,
|
||||||
api_key: string,
|
api_key: string,
|
||||||
proxy = "",
|
proxy = "",
|
||||||
|
thinking_type = "",
|
||||||
reasoning_effort = "",
|
reasoning_effort = "",
|
||||||
profile_id?: number,
|
profile_id?: number,
|
||||||
) =>
|
) =>
|
||||||
@@ -546,6 +641,7 @@ export const api = {
|
|||||||
base_url,
|
base_url,
|
||||||
proxy,
|
proxy,
|
||||||
api_key,
|
api_key,
|
||||||
|
thinking_type,
|
||||||
reasoning_effort,
|
reasoning_effort,
|
||||||
profile_id,
|
profile_id,
|
||||||
}),
|
}),
|
||||||
@@ -561,10 +657,17 @@ export const api = {
|
|||||||
rate_per_second?: number;
|
rate_per_second?: number;
|
||||||
rate_per_minute?: number;
|
rate_per_minute?: number;
|
||||||
context_window_k?: 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),
|
}) => post<{ id: number }>("/llm/profiles", p),
|
||||||
deleteLLMProfile: (id: string) => del<{ deleted: number }>(`/llm/profiles/${id}`),
|
deleteLLMProfile: (id: string) => del<{ deleted: number }>(`/llm/profiles/${id}`),
|
||||||
activateLLMProfile: (id: string) => post<{ ok: boolean }>("/llm/profiles/active", { id: Number(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) =>
|
fetchLLMModels: (provider: string, base_url: string, api_key: string, proxy = "", profile_id?: number) =>
|
||||||
post<{ ok: boolean; error?: string; models?: string[] }>("/llm/models", {
|
post<{ ok: boolean; error?: string; models?: string[] }>("/llm/models", {
|
||||||
provider,
|
provider,
|
||||||
@@ -577,8 +680,7 @@ export const api = {
|
|||||||
// ---- agents ----
|
// ---- agents ----
|
||||||
agents: () => get<{ agents: Agent[] }>("/agents").then((r) => arr(r.agents)),
|
agents: () => get<{ agents: Agent[] }>("/agents").then((r) => arr(r.agents)),
|
||||||
getAgent: (key: string) => get<AgentDetail>(`/agents/${key}`),
|
getAgent: (key: string) => get<AgentDetail>(`/agents/${key}`),
|
||||||
createAgent: (key: string, name: string, description = "") =>
|
createAgent: (key: string, name: string, description = "") => post<Agent>("/agents", { key, name, description }),
|
||||||
post<Agent>("/agents", { key, name, description }),
|
|
||||||
updateAgent: (key: string, name: string, description = "") =>
|
updateAgent: (key: string, name: string, description = "") =>
|
||||||
patch<{ ok: boolean }>(`/agents/${key}`, { name, description }),
|
patch<{ ok: boolean }>(`/agents/${key}`, { name, description }),
|
||||||
deleteAgent: (key: string) => del<{ deleted: string }>(`/agents/${key}`),
|
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)),
|
conversations: () => get<{ conversations: Conversation[] }>("/conversations").then((r) => arr(r.conversations)),
|
||||||
createConversation: (agent_key: string, title = "", llm_profile_id?: number | null) =>
|
createConversation: (agent_key: string, title = "", llm_profile_id?: number | null) =>
|
||||||
post<Conversation>("/conversations", { agent_key, title, llm_profile_id: llm_profile_id ?? 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) =>
|
updateConversationProfile: (id: number, llm_profile_id: number | null) =>
|
||||||
patch<{ ok: boolean }>(`/conversations/${id}/profile`, { llm_profile_id }),
|
patch<{ ok: boolean }>(`/conversations/${id}/profile`, { llm_profile_id }),
|
||||||
deleteConversation: (id: number) => del<{ deleted: number }>(`/conversations/${id}`),
|
deleteConversation: (id: number) => del<{ deleted: number }>(`/conversations/${id}`),
|
||||||
@@ -610,7 +715,8 @@ export const api = {
|
|||||||
sendConversationMessage: (id: number, message: string, attachments?: ChatAttachment[]) =>
|
sendConversationMessage: (id: number, message: string, attachments?: ChatAttachment[]) =>
|
||||||
post<{ status: string }>(`/conversations/${id}/messages`, { message, attachments }),
|
post<{ status: string }>(`/conversations/${id}/messages`, { message, attachments }),
|
||||||
stopConversation: (id: number) => post<{ status: string }>(`/conversations/${id}/stop`, {}),
|
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`, {}),
|
resetAgentPrompt: (key: string) => post<{ version: number }>(`/agents/${key}/prompt/reset`, {}),
|
||||||
// 收尾提示词(超时/步数耗尽的 settlement 提示);prompt 空串=清除覆盖、用内置默认;
|
// 收尾提示词(超时/步数耗尽的 settlement 提示);prompt 空串=清除覆盖、用内置默认;
|
||||||
// max_turns 省略则不动、传 0=用内置默认轮数
|
// max_turns 省略则不动、传 0=用内置默认轮数
|
||||||
@@ -622,7 +728,10 @@ export const api = {
|
|||||||
saveAgentTaskTimeoutWrapup: (key: string, prompt: string, maxTurns?: number) =>
|
saveAgentTaskTimeoutWrapup: (key: string, prompt: string, maxTurns?: number) =>
|
||||||
put<{ ok: boolean }>(`/agents/${key}/wrapup/task-timeout`, { prompt, max_turns: maxTurns }),
|
put<{ ok: boolean }>(`/agents/${key}/wrapup/task-timeout`, { prompt, max_turns: maxTurns }),
|
||||||
resetAgentTaskTimeoutWrapup: (key: string) =>
|
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)
|
// P3 triggers (仅自定义 agent)
|
||||||
agentTriggers: (key: string) =>
|
agentTriggers: (key: string) =>
|
||||||
get<{ triggers: AgentTrigger[] }>(`/agents/${key}/triggers`).then((r) => arr(r.triggers)),
|
get<{ triggers: AgentTrigger[] }>(`/agents/${key}/triggers`).then((r) => arr(r.triggers)),
|
||||||
@@ -644,12 +753,15 @@ export const api = {
|
|||||||
trigger_max_parallel?: number;
|
trigger_max_parallel?: number;
|
||||||
},
|
},
|
||||||
) => put<{ ok: boolean }>(`/agents/${key}/config`, patch),
|
) => put<{ ok: boolean }>(`/agents/${key}/config`, patch),
|
||||||
agentPromptVersions: (key: string) => get<{ versions: PromptVersion[] }>(`/agents/${key}/prompts`).then((r) => arr(r.versions)),
|
agentPromptVersions: (key: string) =>
|
||||||
agentVariables: (key: string) => get<{ variables: PromptVar[] }>(`/agents/${key}/variables`).then((r) => arr(r.variables)),
|
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>) =>
|
previewAgentPrompt: (key: string, template: string, sample?: Record<string, string>) =>
|
||||||
post<{ rendered: string; error?: string }>(`/agents/${key}/prompt/preview`, { template, sample }),
|
post<{ rendered: string; error?: string }>(`/agents/${key}/prompt/preview`, { template, sample }),
|
||||||
getAgentVisibility: (key: string) => get<{ mcp: number[]; skill: string[] }>(`/agents/${key}/visibility`),
|
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 (内置工具目录) ----
|
||||||
tools: () => get<{ tools: Tool[] }>("/tools").then((r) => arr(r.tools)),
|
tools: () => get<{ tools: Tool[] }>("/tools").then((r) => arr(r.tools)),
|
||||||
@@ -657,10 +769,13 @@ export const api = {
|
|||||||
put<{ ok: boolean }>(`/tools/${key}`, patch),
|
put<{ ok: boolean }>(`/tools/${key}`, patch),
|
||||||
resetTool: (key: string) => post<{ ok: boolean }>(`/tools/${key}/reset`, {}),
|
resetTool: (key: string) => post<{ ok: boolean }>(`/tools/${key}/reset`, {}),
|
||||||
// custom tools (自定义工具)
|
// custom tools (自定义工具)
|
||||||
createCustomTool: (t: Pick<Tool, "key" | "description" | "schema" | "agents" | "enabled" | "kind" | "exec" | "deferred">) =>
|
createCustomTool: (
|
||||||
post<{ key: string }>("/tools/custom", t),
|
t: Pick<Tool, "key" | "description" | "schema" | "agents" | "enabled" | "kind" | "exec" | "deferred">,
|
||||||
updateCustomTool: (key: string, t: Pick<Tool, "description" | "schema" | "agents" | "enabled" | "kind" | "exec" | "deferred">) =>
|
) => post<{ key: string }>("/tools/custom", t),
|
||||||
put<{ ok: boolean }>(`/tools/custom/${key}`, 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}`),
|
deleteCustomTool: (key: string) => del<{ deleted: string }>(`/tools/custom/${key}`),
|
||||||
testCustomTool: (body: { kind: string; exec: Record<string, unknown>; params: Record<string, unknown> }) =>
|
testCustomTool: (body: { kind: string; exec: Record<string, unknown>; params: Record<string, unknown> }) =>
|
||||||
post<{ output: string; is_error: boolean }>("/tools/custom/test", body),
|
post<{ output: string; is_error: boolean }>("/tools/custom/test", body),
|
||||||
@@ -704,8 +819,14 @@ export const api = {
|
|||||||
|
|
||||||
// ---- skills (文件系统) ----
|
// ---- skills (文件系统) ----
|
||||||
skills: () => get<{ skills: SkillItem[] }>("/skills").then((r) => arr(r.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 }) =>
|
createSkill: (s: {
|
||||||
post<{ name: string }>("/skills", 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
|
// 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.
|
// 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 }> => {
|
uploadSkill: async (file: File, overwrite = false): Promise<{ name: string; files: number }> => {
|
||||||
@@ -723,20 +844,25 @@ export const api = {
|
|||||||
return body;
|
return body;
|
||||||
},
|
},
|
||||||
deleteSkill: (name: string) => del<{ deleted: string }>(`/skills/${name}`),
|
deleteSkill: (name: string) => del<{ deleted: string }>(`/skills/${name}`),
|
||||||
updateSkillMeta: (name: string, meta: { mcps?: string[]; description?: string; license?: string; compatibility?: string }) =>
|
updateSkillMeta: (
|
||||||
put<{ ok: boolean }>(`/skills/${name}/meta`, meta),
|
name: string,
|
||||||
createSkillDir: (skill: string, path: string) =>
|
meta: { mcps?: string[]; description?: string; license?: string; compatibility?: string },
|
||||||
post<{ dir: string }>(`/skills/${skill}/dirs`, { path }),
|
) => 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),
|
skillFiles: (name: string) => get<{ files: string[] }>(`/skills/${name}/files`).then((r) => r.files),
|
||||||
readSkillFile: (name: string, file: string) =>
|
readSkillFile: (name: string, file: string) =>
|
||||||
get<{ content: string; file: string }>(`/skills/${name}/files/${file}`).then((r) => r.content),
|
get<{ content: string; file: string }>(`/skills/${name}/files/${file}`).then((r) => r.content),
|
||||||
writeSkillFile: (name: string, file: string, content: string) =>
|
writeSkillFile: (name: string, file: string, content: string) =>
|
||||||
put<{ ok: boolean }>(`/skills/${name}/files/${file}`, { content }),
|
put<{ ok: boolean }>(`/skills/${name}/files/${file}`, { content }),
|
||||||
deleteSkillPath: (skill: string, path: string) =>
|
deleteSkillPath: (skill: string, path: string) => del<{ deleted: string }>(`/skills/${skill}/files/${path}`),
|
||||||
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)
|
// ---- 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) =>
|
toggleVisibility: (agentId: string, kind: string, resourceId: number, visible: boolean) =>
|
||||||
post<{ ok: boolean }>("/visibility/toggle", { agent_id: agentId, kind, resource_id: resourceId, visible }),
|
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") =>
|
interceptDecide: (id: number, decision: "allowed" | "denied") =>
|
||||||
post<{ ok: boolean }>(`/intercept/pending/${id}/decide`, { decision }),
|
post<{ ok: boolean }>(`/intercept/pending/${id}/decide`, { decision }),
|
||||||
interceptHistory: () => get<{ items: InterceptApprovalRow[] }>("/intercept/history").then((r) => arr(r.items)),
|
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 (全局工具拦截范围) ----
|
// ---- intercept tool-config (全局工具拦截范围) ----
|
||||||
interceptGetToolConfig: async (): Promise<{ enabled_tools: string[] }> => {
|
interceptGetToolConfig: async (): Promise<{ enabled_tools: string[] }> => {
|
||||||
@@ -813,6 +940,9 @@ export const api = {
|
|||||||
},
|
},
|
||||||
llmRecordDetail: (id: number) => get<LLMRecordDetail>(`/llm/records/${id}`),
|
llmRecordDetail: (id: number) => get<LLMRecordDetail>(`/llm/records/${id}`),
|
||||||
llmTasks: () => get<{ tasks: LLMTask[] }>(`/llm/records/tasks`),
|
llmTasks: () => get<{ tasks: LLMTask[] }>(`/llm/records/tasks`),
|
||||||
llmRecordsDeleteTask: (task: string) =>
|
llmRecordsDeleteTask: (task: string) => del<{ deleted: number }>(`/llm/records?task=${encodeURIComponent(task)}`),
|
||||||
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}),保证任何页面都不崩。
|
// 未命中的一律返回安全默认([] / {} / {ok:true}),保证任何页面都不崩。
|
||||||
// 只在 NEXT_PUBLIC_MOCK=1 时经由 api.ts 的 http() 短路进入这里。
|
// 只在 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";
|
import * as D from "./data";
|
||||||
|
|
||||||
const delay = (ms = 120) => new Promise((r) => setTimeout(r, ms));
|
const delay = (ms = 120) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
<<<<<<< Updated upstream
|
|
||||||
=======
|
|
||||||
// Requests mutate a runtime copy, never the exported fixtures. This keeps module
|
// Requests mutate a runtime copy, never the exported fixtures. This keeps module
|
||||||
// initialization deterministic for tests/HMR while preserving state across mock calls.
|
// initialization deterministic for tests/HMR while preserving state across mock calls.
|
||||||
const mockTasks = structuredClone(D.tasks);
|
const mockTasks = structuredClone(D.tasks);
|
||||||
const mockFindings = structuredClone(D.findings);
|
const mockFindings = structuredClone(D.findings);
|
||||||
const mockLLMRecords = structuredClone(D.llmRecords);
|
const mockLLMRecords = structuredClone(D.llmRecords);
|
||||||
const mockTaskTemplates = structuredClone(D.taskTemplates);
|
const mockTaskTemplates = structuredClone(D.taskTemplates);
|
||||||
|
const mockTaskCategories = structuredClone(D.taskCategories);
|
||||||
const mockConversations = structuredClone(D.conversations);
|
const mockConversations = structuredClone(D.conversations);
|
||||||
const mockIntents = structuredClone(D.intents);
|
const mockIntents = structuredClone(D.intents);
|
||||||
const mockCompanies = structuredClone(D.companies);
|
const mockCompanies = structuredClone(D.companies);
|
||||||
const mockAssets = structuredClone(D.assets);
|
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;
|
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) => {
|
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;
|
counts[asset.type] = (counts[asset.type] ?? 0) + 1;
|
||||||
return counts;
|
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(
|
function mockScopeRows(
|
||||||
companyID: number,
|
companyID: number,
|
||||||
input: unknown,
|
input: unknown,
|
||||||
@@ -188,7 +240,6 @@ function normalizedTemplateName(value: unknown): string {
|
|||||||
.join(" ");
|
.join(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
>>>>>>> Stashed changes
|
|
||||||
function parseBody(body?: BodyInit | null): Record<string, unknown> {
|
function parseBody(body?: BodyInit | null): Record<string, unknown> {
|
||||||
if (typeof body !== "string") return {};
|
if (typeof body !== "string") return {};
|
||||||
try {
|
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 };
|
if (path === "/auth/change-password") return { ok: true };
|
||||||
|
|
||||||
// ── tasks ──
|
// ── 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 === "GET") return { tasks: mockTasks, active: mockActiveTask };
|
||||||
if (path === "/tasks" && m === "POST") {
|
if (path === "/tasks" && m === "POST") {
|
||||||
let suffix = 1;
|
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 profileIDs = [...((b.llm_profile_ids as number[] | undefined) ?? [])];
|
||||||
const sourceTaskIDs = [...((b.source_task_ids as string[] | undefined) ?? [])];
|
const sourceTaskIDs = [...((b.source_task_ids as string[] | undefined) ?? [])];
|
||||||
const companyIDs = [...((b.company_ids as number[] | 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 = {
|
const created: Task = {
|
||||||
id,
|
id,
|
||||||
name: String(b.name ?? ""),
|
name: String(b.name ?? ""),
|
||||||
|
category_id: category?.id,
|
||||||
|
category_name: category?.name,
|
||||||
description: String(b.description ?? "新任务"),
|
description: String(b.description ?? "新任务"),
|
||||||
goal: String(b.goal ?? ""),
|
goal: String(b.goal ?? ""),
|
||||||
status: "created",
|
status: "created",
|
||||||
@@ -256,11 +305,67 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
|||||||
source_task_ids: sourceTaskIDs,
|
source_task_ids: sourceTaskIDs,
|
||||||
company_ids: companyIDs,
|
company_ids: companyIDs,
|
||||||
};
|
};
|
||||||
|
mockTaskAssetIDs.set(id, nextMockTaskAssetID++);
|
||||||
for (const item of mockTasks) item.active = false;
|
for (const item of mockTasks) item.active = false;
|
||||||
mockTasks.unshift(created);
|
mockTasks.unshift(created);
|
||||||
mockActiveTask = id;
|
mockActiveTask = id;
|
||||||
return created;
|
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 === "GET") return { templates: mockTaskTemplates };
|
||||||
if (path === "/task-templates" && m === "POST") {
|
if (path === "/task-templates" && m === "POST") {
|
||||||
const now = new Date().toISOString();
|
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 id = seg[1];
|
||||||
const index = mockTasks.findIndex((item) => item.id === id);
|
const index = mockTasks.findIndex((item) => item.id === id);
|
||||||
if (index >= 0) mockTasks.splice(index, 1);
|
if (index >= 0) mockTasks.splice(index, 1);
|
||||||
|
mockTaskAssetIDs.delete(id);
|
||||||
|
|
||||||
let findingsDeleted = 0;
|
let findingsDeleted = 0;
|
||||||
if (b.delete_findings) {
|
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 ?? "" };
|
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 };
|
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 (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" && seg.length === 3) return D.coverage;
|
||||||
if (seg[0] === "tasks" && seg[2] === "coverage-graph") return D.coverageGraph;
|
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] === "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})──
|
// ── 工作空间文件管理器(demo:静态示例树;写/建/删走下方写兜底 {ok:true})──
|
||||||
if (path === "/workspace/list") return D.workspaceList(q.get("path") ?? "");
|
if (path === "/workspace/list") return D.workspaceList(q.get("path") ?? "");
|
||||||
if (path === "/workspace/read") return D.workspaceRead(q.get("path") ?? "");
|
if (path === "/workspace/read") return D.workspaceRead(q.get("path") ?? "");
|
||||||
|
|
||||||
// ── stats ──
|
// ── stats ──
|
||||||
if (path === "/stats") return D.stats(task);
|
if (path === "/stats") {
|
||||||
|
return D.stats(task, { tasks: mockTasks, findings: mockFindings, activeTask: mockActiveTask });
|
||||||
|
}
|
||||||
|
|
||||||
// ── assets ──
|
// ── assets ──
|
||||||
if (path === "/assets/counts") return D.assetCounts;
|
if (path === "/assets/counts") return mockAssetCounts(q.get("task_id"));
|
||||||
if (path === "/assets" && m === "GET") {
|
if (path === "/assets" && m === "GET") {
|
||||||
const type = q.get("type") ?? "";
|
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 limit = Number(q.get("limit") ?? 50);
|
||||||
const offset = Number(q.get("offset") ?? 0);
|
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 ──
|
// ── companies ──
|
||||||
if (path === "/companies" && m === "GET") return D.companies;
|
if (path === "/companies" && m === "GET") return structuredClone(mockCompanies);
|
||||||
if (path === "/companies" && m === "POST") return { id: 2, created: true, scope_added: 0 };
|
if (path === "/companies" && m === "POST") {
|
||||||
if (seg[0] === "companies" && seg[2] === "scope") return { added: 0, skipped: 0, invalid: 0 };
|
const name = String(b.name ?? "").trim();
|
||||||
if (seg[0] === "companies" && seg.length === 2 && m === "DELETE") return { deleted: 1, assets_deleted: 0 };
|
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 ──
|
// ── exploration ──
|
||||||
if (path === "/exploration/frontier") return D.frontier;
|
if (path === "/exploration/frontier") return D.frontier;
|
||||||
if (path === "/exploration/findings/stats") {
|
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 用)。
|
// 「按任务」下拉:有漏洞的任务 + 描述 + 条数(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 }>();
|
const taskMap = new Map<string, { name: string; description: string; count: number }>();
|
||||||
for (const f of mockFindings) {
|
for (const f of mockFindings) {
|
||||||
>>>>>>> Stashed changes
|
|
||||||
if (!f.task_id) continue;
|
if (!f.task_id) continue;
|
||||||
const owner = mockTasks.find((candidate) => candidate.id === f.task_id);
|
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++;
|
cur.count++;
|
||||||
taskMap.set(f.task_id, cur);
|
taskMap.set(f.task_id, cur);
|
||||||
}
|
}
|
||||||
const tasks = Array.from(taskMap, ([id, v]) => ({ id, name: v.name, description: v.description, count: v.count }));
|
const tasks = Array.from(taskMap, ([id, v]) => ({ id, name: v.name, description: v.description, count: v.count }));
|
||||||
return {
|
return {
|
||||||
total: D.findings.length,
|
total: mockFindings.length,
|
||||||
pending: D.findings.filter((f) => f.status === "pending").length,
|
pending: mockFindings.filter((f) => f.status === "pending").length,
|
||||||
critical: D.findings.filter((f) => f.severity === "critical").length,
|
critical: mockFindings.filter((f) => f.severity === "critical").length,
|
||||||
high: D.findings.filter((f) => f.severity === "high").length,
|
high: mockFindings.filter((f) => f.severity === "high").length,
|
||||||
medium: D.findings.filter((f) => f.severity === "medium").length,
|
medium: mockFindings.filter((f) => f.severity === "medium").length,
|
||||||
low: D.findings.filter((f) => f.severity === "low").length,
|
low: mockFindings.filter((f) => f.severity === "low").length,
|
||||||
vulnclasses,
|
vulnclasses,
|
||||||
tasks,
|
tasks,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
<<<<<<< Updated upstream
|
|
||||||
=======
|
|
||||||
if (path === "/exploration/findings/groups") {
|
if (path === "/exploration/findings/groups") {
|
||||||
const severityOrder = { critical: 4, high: 3, medium: 2, low: 1 } as const;
|
const severityOrder = { critical: 4, high: 3, medium: 2, low: 1 } as const;
|
||||||
let list = mockFindings.slice();
|
let list = mockFindings.slice();
|
||||||
@@ -555,38 +963,52 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
|||||||
queued: false,
|
queued: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
>>>>>>> Stashed changes
|
|
||||||
// 单条 finding:GET 详情 / PATCH 改状态/严重度/名称/类别(demo 直接改内存对象)。
|
// 单条 finding:GET 详情 / PATCH 改状态/严重度/名称/类别(demo 直接改内存对象)。
|
||||||
if (
|
if (seg[0] === "exploration" && seg[1] === "findings" && seg.length === 3 && seg[2] !== "stats") {
|
||||||
seg[0] === "exploration" &&
|
const f = mockFindings.find((x) => x.id === seg[2]);
|
||||||
seg[1] === "findings" &&
|
|
||||||
seg.length === 3 &&
|
|
||||||
seg[2] !== "stats"
|
|
||||||
) {
|
|
||||||
const f = D.findings.find((x) => x.id === seg[2]);
|
|
||||||
if (!f) return {};
|
if (!f) return {};
|
||||||
if (m === "PATCH") {
|
if (m === "PATCH") {
|
||||||
if (typeof b.status === "string") f.status = b.status as typeof f.status;
|
if (typeof b.status === "string") f.status = b.status as typeof f.status;
|
||||||
if (typeof b.severity === "string")
|
if (typeof b.severity === "string") f.severity = b.severity as typeof f.severity;
|
||||||
f.severity = b.severity as typeof f.severity;
|
|
||||||
if (typeof b.name === "string") f.name = b.name;
|
if (typeof b.name === "string") f.name = b.name;
|
||||||
if (typeof b.vulnclass === "string") f.vulnclass = b.vulnclass;
|
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") {
|
if (path === "/exploration/findings") {
|
||||||
// finding_id=id:真后端用独立表行 id 作为状态/详情句柄,mock 里用自身 id 顶上。
|
// finding_id=id:真后端用独立表行 id 作为状态/详情句柄,mock 里用自身 id 顶上。
|
||||||
// report 仅详情接口返回,列表剥掉(与后端一致)。
|
// report 仅详情接口返回,列表剥掉(与后端一致)。
|
||||||
const withFid = (f: (typeof D.findings)[number]) => ({
|
const withFid = (f: (typeof mockFindings)[number]) => ({
|
||||||
...f,
|
...f,
|
||||||
report: undefined,
|
report: undefined,
|
||||||
finding_id: f.id,
|
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)。
|
// 全局:带 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;
|
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 fSev = q.get("severity");
|
||||||
const fStatus = q.get("status");
|
const fStatus = q.get("status");
|
||||||
const fVuln = q.get("vulnclass");
|
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 (fSev) list = list.filter((f) => f.severity === fSev);
|
||||||
if (fStatus) list = list.filter((f) => f.status === fStatus);
|
if (fStatus) list = list.filter((f) => f.status === fStatus);
|
||||||
if (fVuln) list = list.filter((f) => f.vulnclass === fVuln);
|
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) =>
|
list.sort((a, b) =>
|
||||||
q.get("sort") === "severity"
|
q.get("sort") === "severity"
|
||||||
? sev[b.severity] - sev[a.severity] || +new Date(b.ts) - +new Date(a.ts)
|
? sev[b.severity] - sev[a.severity] || +new Date(b.ts) - +new Date(a.ts)
|
||||||
: +new Date(b.ts) - +new Date(a.ts),
|
: +new Date(b.ts) - +new Date(a.ts),
|
||||||
);
|
);
|
||||||
const page = Number(q.get("page") ?? 1);
|
const rawPage = Number(q.get("page") ?? 1);
|
||||||
const pageSize = Number(q.get("limit") ?? 20);
|
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 {
|
return {
|
||||||
items: list.slice((page - 1) * pageSize, page * pageSize).map(withFid),
|
items: list.slice((page - 1) * pageSize, page * pageSize).map(withFid),
|
||||||
total: list.length,
|
total: list.length,
|
||||||
@@ -609,12 +1034,32 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
|||||||
page_size: pageSize,
|
page_size: pageSize,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (path === "/exploration/intents") return D.intents;
|
if (path === "/exploration/intents") {
|
||||||
if (path === "/exploration/tokens") return { workers: D.tokenWorkers, total: D.tokenTotal };
|
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/graph") return D.explorationGraph;
|
||||||
if (path === "/exploration/activity" && seg.length === 2) {
|
if (path === "/exploration/activity" && seg.length === 2) {
|
||||||
const items = D.activityForTask();
|
const since = Number(q.get("since") ?? 0);
|
||||||
return { items, cursor: items.length ? items[items.length - 1].seq : 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) {
|
if (seg[0] === "exploration" && seg[1] === "activity" && seg.length === 3) {
|
||||||
const a = D.activity.find((x) => x.seq === Number(seg[2]));
|
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" && 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/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 === "/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 === "/gc") return { removed: 0 };
|
||||||
|
|
||||||
// ── 工具执行历史 ──
|
// ── 工具执行历史 ──
|
||||||
if (path === "/commands" && m === "GET") return { commands: D.commandRecords, total: D.commandRecords.length };
|
if (path === "/commands" && m === "GET") return { commands: D.commandRecords, total: D.commandRecords.length };
|
||||||
|
|
||||||
// ── LLM ──
|
// ── 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" && m === "DELETE") return { deleted: 0 };
|
||||||
if (path === "/llm/records/tasks") return { tasks: D.llmTasks };
|
if (path === "/llm/records/tasks") {
|
||||||
if (seg[0] === "llm" && seg[1] === "records" && seg.length === 3 && m === "GET") return D.llmRecordDetail(Number(seg[2]));
|
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 === "GET") return D.llmConfig;
|
||||||
if (path === "/llm" && m === "POST") return { ok: true };
|
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/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 === "GET") return { profiles: D.llmProfiles };
|
||||||
if (path === "/llm/profiles" && m === "POST") return { id: Number(b.id) || 3 };
|
if (path === "/llm/profiles" && m === "POST") return { id: Number(b.id) || 3 };
|
||||||
if (path === "/llm/profiles/active") return { ok: true };
|
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 ──
|
// ── agents ──
|
||||||
if (path === "/agents" && m === "GET") return { agents: D.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.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] === "triggers" && m === "GET") return { triggers: [] };
|
||||||
if (seg[0] === "agents" && seg[2] === "prompts") return { versions: D.agentDetail(seg[1]).versions };
|
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;
|
if (seg[0] === "agents" && seg[2] === "visibility" && m === "GET") return D.agentDetail(seg[1]).visibility;
|
||||||
|
|
||||||
// ── conversations ──
|
// ── conversations ──
|
||||||
if (path === "/conversations" && m === "GET") return { conversations: D.conversations };
|
if (path === "/conversations" && m === "GET") {
|
||||||
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" };
|
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") {
|
if (seg[0] === "conversations" && seg[2] === "messages" && seg.length === 3 && m === "GET") {
|
||||||
const items = D.conversationMessages[Number(seg[1])] ?? [];
|
const items = D.conversationMessages[Number(seg[1])] ?? [];
|
||||||
return { items, cursor: items.length ? items[items.length - 1].seq : 0, running: false };
|
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]) };
|
if (seg[0] === "mcp" && seg.length === 2 && m === "DELETE") return { deleted: Number(seg[1]) };
|
||||||
|
|
||||||
// ── scopesentry(demo:未配置)──
|
// ── 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/projects") return { projects: [], tag: {} };
|
||||||
if (path === "/sync/scopesentry/tasks") return { tasks: [] };
|
if (path === "/sync/scopesentry/tasks") return { tasks: [] };
|
||||||
if (path === "/sync/scopesentry/sync") return { synced: {}, companies: null, warnings: null, errors: null };
|
if (path === "/sync/scopesentry/sync") return { synced: {}, companies: null, warnings: null, errors: null };
|
||||||
|
|
||||||
// ── skills ──
|
// ── skills ──
|
||||||
if (path === "/skills" && m === "GET") return { skills: D.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 (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 === 3) return { files: ["SKILL.md"] };
|
||||||
if (seg[0] === "skills" && seg[2] === "files" && seg.length >= 4)
|
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 ──
|
// ── intercept ──
|
||||||
if (path === "/intercept/rules" && m === "GET") return { rules: D.interceptRules };
|
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 (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[3] === "decide") return { ok: true };
|
||||||
if (seg[0] === "intercept" && seg[1] === "pending" && seg.length === 3 && m === "GET")
|
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 };
|
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,
|
||||||
|
)
|
||||||
|
? []
|
||||||
|
: {};
|
||||||
}
|
}
|
||||||
+225
-43
@@ -1,12 +1,14 @@
|
|||||||
// ARTEX domain model — types used across the UI.
|
// ARTEX domain model — types used across the UI.
|
||||||
// Derived from the functional spec (section 7: 关键数据形状).
|
// 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 type EngineMode = "exploring" | "paused" | "stalled" | "idle";
|
||||||
|
|
||||||
export interface Task {
|
export interface Task {
|
||||||
id: string;
|
id: string;
|
||||||
name?: string; // 可选任务名称;空/缺省=未命名,展示时回退到描述
|
name?: string; // 可选任务名称;空/缺省=未命名,展示时回退到描述
|
||||||
|
category_id?: number;
|
||||||
|
category_name?: string;
|
||||||
description: string;
|
description: string;
|
||||||
goal: string;
|
goal: string;
|
||||||
status: TaskStatus;
|
status: TaskStatus;
|
||||||
@@ -16,6 +18,7 @@ export interface Task {
|
|||||||
completed_unix?: number; // completed_at as unix seconds (0/undef if unfinished)
|
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)
|
last_activity_unix?: number; // unix seconds of the last activity (0/undef if none)
|
||||||
paused?: boolean;
|
paused?: boolean;
|
||||||
|
queued?: boolean;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
in_flight?: number;
|
in_flight?: number;
|
||||||
last_activity?: string;
|
last_activity?: string;
|
||||||
@@ -25,8 +28,6 @@ export interface Task {
|
|||||||
engine_mode?: EngineMode;
|
engine_mode?: EngineMode;
|
||||||
tokens?: TokenTotal; // whole-task token consumption
|
tokens?: TokenTotal; // whole-task token consumption
|
||||||
llm_profile_id?: number; // LLM profile used; absent = default profile
|
llm_profile_id?: number; // LLM profile used; absent = default profile
|
||||||
<<<<<<< Updated upstream
|
|
||||||
=======
|
|
||||||
llm_profile_ids?: number[]; // ordered task-level failover chain
|
llm_profile_ids?: number[]; // ordered task-level failover chain
|
||||||
active_llm_profile_id?: number; // profile used by the next LLM call
|
active_llm_profile_id?: number; // profile used by the next LLM call
|
||||||
llm_failover_state?: "default" | "ready" | "chain_exhausted" | string;
|
llm_failover_state?: "default" | "ready" | "chain_exhausted" | string;
|
||||||
@@ -36,6 +37,14 @@ export interface Task {
|
|||||||
coverage_enabled?: boolean; // 资产覆盖度功能开关(创建时定,默认开);false=不计算/不展示覆盖度
|
coverage_enabled?: boolean; // 资产覆盖度功能开关(创建时定,默认开);false=不计算/不展示覆盖度
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TaskCategory {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
task_count: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TaskTemplate {
|
export interface TaskTemplate {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -62,7 +71,6 @@ export interface DeleteTaskResult {
|
|||||||
findings_deleted: number;
|
findings_deleted: number;
|
||||||
llm_records_deleted: number;
|
llm_records_deleted: number;
|
||||||
cleanup_warning?: string;
|
cleanup_warning?: string;
|
||||||
>>>>>>> Stashed changes
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Asset graph (global, shared across tasks) ----
|
// ---- Asset graph (global, shared across tasks) ----
|
||||||
@@ -170,6 +178,35 @@ export interface Asset {
|
|||||||
params?: Record<string, unknown>[];
|
params?: Record<string, unknown>[];
|
||||||
extra?: Record<string, unknown>;
|
extra?: Record<string, unknown>;
|
||||||
last_seen: string;
|
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) ----
|
// ---- Asset coverage graph (per task) ----
|
||||||
@@ -210,6 +247,8 @@ export interface CoverageAssetRef {
|
|||||||
kind: string;
|
kind: string;
|
||||||
state: string;
|
state: string;
|
||||||
summary: string;
|
summary: string;
|
||||||
|
source_task_id?: string;
|
||||||
|
inherited?: boolean;
|
||||||
}
|
}
|
||||||
export interface CoverageAssetRefs {
|
export interface CoverageAssetRefs {
|
||||||
intents: CoverageAssetRef[];
|
intents: CoverageAssetRef[];
|
||||||
@@ -237,17 +276,16 @@ export interface WorkspaceFile {
|
|||||||
content?: string;
|
content?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
<<<<<<< Updated upstream
|
|
||||||
=======
|
|
||||||
// 任务测试范围的一条(覆盖度分母 + 授权边界)。
|
// 任务测试范围的一条(覆盖度分母 + 授权边界)。
|
||||||
export interface TaskScopeRow {
|
export interface TaskScopeRow {
|
||||||
id: number;
|
id: number;
|
||||||
task_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_id?: number;
|
||||||
company_name?: string; // 后端 JOIN companies 解析,仅 kind=company 有值
|
company_name?: string; // 后端 JOIN companies 解析,仅 kind=company 有值
|
||||||
domain?: string;
|
domain?: string;
|
||||||
net?: string;
|
net?: string;
|
||||||
|
value?: string;
|
||||||
source: "auto" | "agent" | "manual";
|
source: "auto" | "agent" | "manual";
|
||||||
reason?: string;
|
reason?: string;
|
||||||
}
|
}
|
||||||
@@ -260,15 +298,15 @@ export interface CompanyScopeRule {
|
|||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
>>>>>>> Stashed changes
|
|
||||||
// 公司资产范围规则的一条(归属唯一真值来源)。
|
// 公司资产范围规则的一条(归属唯一真值来源)。
|
||||||
export interface ScopeRow {
|
export interface ScopeRow {
|
||||||
id: number;
|
id: number;
|
||||||
company_id: number;
|
company_id: number;
|
||||||
kind: "domain" | "ip" | "cidr";
|
kind: CompanyScopeKind;
|
||||||
domain?: string; // kind=domain 时有值
|
domain?: string; // kind=domain 时有值
|
||||||
net?: string; // kind=ip|cidr 时有值
|
net?: string; // kind=ip|cidr 时有值
|
||||||
raw: string; // 原始用户输入,用于显示和回填
|
value?: string; // kind=icp|keyword 时可能由后端直接返回
|
||||||
|
raw: string; // 原始用户输入,用于显示和回填
|
||||||
reason?: string;
|
reason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,13 +316,13 @@ export interface Company {
|
|||||||
name: string;
|
name: string;
|
||||||
logo?: string; // 远程图标 URL;空则前端用名称首字母
|
logo?: string; // 远程图标 URL;空则前端用名称首字母
|
||||||
asset_count: number;
|
asset_count: number;
|
||||||
scope: ScopeRow[];
|
scope?: ScopeRow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Exploration graph (per task) ----
|
// ---- Exploration graph (per task) ----
|
||||||
export type ExploreKind = "task" | "begin" | "goal" | "intent" | "fact" | "finding" | "hint";
|
export type ExploreKind = "task" | "begin" | "goal" | "intent" | "fact" | "finding" | "hint";
|
||||||
export type GoalState = "open" | "met" | "abandoned";
|
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 FindingState = "confirmed" | "dismissed";
|
||||||
export type HintState = "active" | "consumed";
|
export type HintState = "active" | "consumed";
|
||||||
export type ExploreRel = "spawns" | "derived_from" | "yields" | "proves";
|
export type ExploreRel = "spawns" | "derived_from" | "yields" | "proves";
|
||||||
@@ -297,6 +335,8 @@ export interface TaskNode {
|
|||||||
state: string; // GoalState | IntentState | FindingState | HintState
|
state: string; // GoalState | IntentState | FindingState | HintState
|
||||||
origin: string;
|
origin: string;
|
||||||
ts: string;
|
ts: string;
|
||||||
|
source_task_id?: string;
|
||||||
|
inherited?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 目标管理卡片用的目标(后端已把 payload 拆成 text/vulnclass)。
|
// 目标管理卡片用的目标(后端已把 payload 拆成 text/vulnclass)。
|
||||||
@@ -354,6 +394,8 @@ export interface Finding {
|
|||||||
param_id?: string;
|
param_id?: string;
|
||||||
task_id?: string;
|
task_id?: string;
|
||||||
task_description?: string;
|
task_description?: string;
|
||||||
|
source_task_id?: string;
|
||||||
|
inherited?: boolean;
|
||||||
assets?: FindingAsset[];
|
assets?: FindingAsset[];
|
||||||
ts: string;
|
ts: string;
|
||||||
}
|
}
|
||||||
@@ -366,8 +408,6 @@ export interface FindingsPage {
|
|||||||
page_size: number;
|
page_size: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
<<<<<<< Updated upstream
|
|
||||||
=======
|
|
||||||
export interface FindingGroup {
|
export interface FindingGroup {
|
||||||
task_id: string | number | null;
|
task_id: string | number | null;
|
||||||
task_name?: string; // 可选任务名称;空/缺省=未命名
|
task_name?: string; // 可选任务名称;空/缺省=未命名
|
||||||
@@ -396,7 +436,6 @@ export interface FindingDeepenResponse {
|
|||||||
queued: boolean;
|
queued: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
>>>>>>> Stashed changes
|
|
||||||
// FindingStats 是发现全表聚合(统计卡 + 漏洞类型下拉),服务端计算,不受分页影响。
|
// FindingStats 是发现全表聚合(统计卡 + 漏洞类型下拉),服务端计算,不受分页影响。
|
||||||
export interface FindingStats {
|
export interface FindingStats {
|
||||||
total: number;
|
total: number;
|
||||||
@@ -412,12 +451,8 @@ export interface FindingStats {
|
|||||||
// FindingTaskOption 是发现页「按任务」筛选下拉的一项:有漏洞的任务(描述为空表示任务已删除,
|
// FindingTaskOption 是发现页「按任务」筛选下拉的一项:有漏洞的任务(描述为空表示任务已删除,
|
||||||
// 前端回退展示 id)及其漏洞条数。
|
// 前端回退展示 id)及其漏洞条数。
|
||||||
export interface FindingTaskOption {
|
export interface FindingTaskOption {
|
||||||
<<<<<<< Updated upstream
|
|
||||||
id: number;
|
|
||||||
=======
|
|
||||||
id: string | number;
|
id: string | number;
|
||||||
name?: string; // 可选任务名称;空/缺省=未命名
|
name?: string; // 可选任务名称;空/缺省=未命名
|
||||||
>>>>>>> Stashed changes
|
|
||||||
description: string;
|
description: string;
|
||||||
count: number;
|
count: number;
|
||||||
}
|
}
|
||||||
@@ -441,9 +476,11 @@ export type ActivityKind =
|
|||||||
| "thinking"
|
| "thinking"
|
||||||
| "result"
|
| "result"
|
||||||
| "user"
|
| "user"
|
||||||
| "intent" // LLM-generated exploration objective leading a worker session (UI-synthesized)
|
| "intent" // LLM-generated exploration objective leading a worker session (UI-synthesized)
|
||||||
| "round" // planner round boundary marker (engine-emitted)
|
| "round" // planner round boundary marker (engine-emitted)
|
||||||
| "usage" // live cumulative token usage (per model turn); not rendered
|
| "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
|
| "intercept_request"; // user-approval request from the intercept layer
|
||||||
|
|
||||||
// ChatAttachment 是一次上传的文件:path 相对该会话/任务工作目录(即 agent 的 CWD)。
|
// ChatAttachment 是一次上传的文件:path 相对该会话/任务工作目录(即 agent 的 CWD)。
|
||||||
@@ -465,6 +502,11 @@ export interface Activity {
|
|||||||
is_error?: boolean;
|
is_error?: boolean;
|
||||||
summary: string;
|
summary: string;
|
||||||
detail?: string;
|
detail?: string;
|
||||||
|
metadata?: {
|
||||||
|
llm_transition?: LLMTransition;
|
||||||
|
};
|
||||||
|
source_task_id?: string;
|
||||||
|
inherited?: boolean;
|
||||||
// token usage (present only on kind='result')
|
// token usage (present only on kind='result')
|
||||||
input_tokens?: number;
|
input_tokens?: number;
|
||||||
output_tokens?: number;
|
output_tokens?: number;
|
||||||
@@ -472,6 +514,36 @@ export interface Activity {
|
|||||||
cache_write_tokens?: number;
|
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) ----
|
// ---- Agent triggers (P3 调度,仅自定义 agent) ----
|
||||||
export interface AgentTrigger {
|
export interface AgentTrigger {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -499,6 +571,8 @@ export interface Conversation {
|
|||||||
agent_key: string;
|
agent_key: string;
|
||||||
title: string;
|
title: string;
|
||||||
llm_profile_id?: number;
|
llm_profile_id?: number;
|
||||||
|
pinned?: boolean;
|
||||||
|
pinned_at?: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@@ -513,8 +587,8 @@ export interface LogLine {
|
|||||||
text: string;
|
text: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionRole = "mainagent" | "planner" | "worker";
|
export type SessionRole = "mainagent" | "planner" | "worker" | "system";
|
||||||
export type SessionStatus = "running" | "done" | "blocked" | "exhausted" | "pending" | "stopped";
|
export type SessionStatus = "running" | "paused" | "done" | "blocked" | "exhausted" | "pending" | "stopped";
|
||||||
|
|
||||||
// Daily token aggregate bucket (GET /api/tokens/daily).
|
// Daily token aggregate bucket (GET /api/tokens/daily).
|
||||||
export interface DailyTokenBucket {
|
export interface DailyTokenBucket {
|
||||||
@@ -534,6 +608,22 @@ export interface TokenUsage {
|
|||||||
cache_write_tokens: number;
|
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.
|
// Whole-task (all agents) token aggregate.
|
||||||
export interface TokenTotal {
|
export interface TokenTotal {
|
||||||
input_tokens: number;
|
input_tokens: number;
|
||||||
@@ -542,6 +632,43 @@ export interface TokenTotal {
|
|||||||
cache_write_tokens: number;
|
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 {
|
export interface Session {
|
||||||
id: string;
|
id: string;
|
||||||
role: SessionRole;
|
role: SessionRole;
|
||||||
@@ -550,6 +677,8 @@ export interface Session {
|
|||||||
live: boolean;
|
live: boolean;
|
||||||
last_activity: string;
|
last_activity: string;
|
||||||
intent_id?: string;
|
intent_id?: string;
|
||||||
|
source_task_id?: string;
|
||||||
|
inherited?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Security ----
|
// ---- Security ----
|
||||||
@@ -617,8 +746,6 @@ export interface Settings {
|
|||||||
web_search_proxy?: string;
|
web_search_proxy?: string;
|
||||||
python_interpreter?: string; // 自定义脚本工具的 python 解释器路径(空=运行时检测)
|
python_interpreter?: string; // 自定义脚本工具的 python 解释器路径(空=运行时检测)
|
||||||
workers?: number; // 并发工作 agent 数(默认3);对之后启动的任务生效
|
workers?: number; // 并发工作 agent 数(默认3);对之后启动的任务生效
|
||||||
<<<<<<< Updated upstream
|
|
||||||
=======
|
|
||||||
// 任务并发上限:同时「运行中」的任务数上限。关闭=不限;开启后新建任务超限则排队,有空位自动启动。
|
// 任务并发上限:同时「运行中」的任务数上限。关闭=不限;开启后新建任务超限则排队,有空位自动启动。
|
||||||
task_concurrency_enabled?: boolean; // 默认 false
|
task_concurrency_enabled?: boolean; // 默认 false
|
||||||
task_concurrency_limit?: number; // 开启后默认 5
|
task_concurrency_limit?: number; // 开启后默认 5
|
||||||
@@ -630,7 +757,6 @@ export interface Settings {
|
|||||||
// 操作约束注入范围(默认都开):把任务的 allow/deny 约束拼进对应 agent 的系统提示。
|
// 操作约束注入范围(默认都开):把任务的 allow/deny 约束拼进对应 agent 的系统提示。
|
||||||
constraints_inject_planner?: boolean;
|
constraints_inject_planner?: boolean;
|
||||||
constraints_inject_worker?: boolean;
|
constraints_inject_worker?: boolean;
|
||||||
>>>>>>> Stashed changes
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- LLM config ----
|
// ---- LLM config ----
|
||||||
@@ -645,9 +771,42 @@ export interface LLMProfile {
|
|||||||
rate_per_second: number;
|
rate_per_second: number;
|
||||||
rate_per_minute: number;
|
rate_per_minute: number;
|
||||||
context_window_k?: 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;
|
reasoning_effort?: string;
|
||||||
is_default: boolean;
|
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 ----
|
// ---- Agents ----
|
||||||
@@ -730,12 +889,34 @@ export interface MCPTool {
|
|||||||
// Fields align with the agentskills.io open specification.
|
// Fields align with the agentskills.io open specification.
|
||||||
// description covers both "what the skill does" and "when to use it".
|
// description covers both "what the skill does" and "when to use it".
|
||||||
export interface SkillItem {
|
export interface SkillItem {
|
||||||
name: string; // unique key = directory name
|
name: string; // unique key = directory name
|
||||||
description?: string; // required per spec; covers what + when to use
|
description?: string; // required per spec; covers what + when to use
|
||||||
license?: string; // optional: SPDX identifier or free text
|
license?: string; // optional: SPDX identifier or free text
|
||||||
compatibility?: string; // optional: environment requirements
|
compatibility?: string; // optional: environment requirements
|
||||||
mcps?: string[]; // MCP server names this skill unlocks on load
|
mcps?: string[]; // MCP server names this skill unlocks on load
|
||||||
files: string[]; // files in the skill directory
|
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 (内置工具目录) ----
|
// ---- Tools (内置工具目录) ----
|
||||||
@@ -748,12 +929,12 @@ export interface Tool {
|
|||||||
description: string;
|
description: string;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
schema: Record<string, any>; // full JSON-Schema (object with properties)
|
schema: Record<string, any>; // full JSON-Schema (object with properties)
|
||||||
agents: string[]; // bound agent keys
|
agents: string[]; // bound agent keys
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
kind?: "builtin" | "shell" | "command" | "script" | "http"; // 自定义工具类型
|
kind?: "builtin" | "shell" | "command" | "script" | "http"; // 自定义工具类型
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
exec?: Record<string, any>; // 自定义工具执行规格(kind!=builtin)
|
exec?: Record<string, any>; // 自定义工具执行规格(kind!=builtin)
|
||||||
deferred?: boolean; // schema 延迟(SearchExtraTools/ExecuteExtraTool)
|
deferred?: boolean; // schema 延迟(SearchExtraTools/ExecuteExtraTool)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Stats ----
|
// ---- Stats ----
|
||||||
@@ -763,6 +944,7 @@ export interface Stats {
|
|||||||
llm_configured: boolean;
|
llm_configured: boolean;
|
||||||
roe_enabled: boolean;
|
roe_enabled: boolean;
|
||||||
findings_confirmed: number;
|
findings_confirmed: number;
|
||||||
|
active_task?: Partial<Task>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Intercept Rules ----
|
// ---- Intercept Rules ----
|
||||||
@@ -815,14 +997,14 @@ export interface JudgeConfig {
|
|||||||
|
|
||||||
// InterceptApprovalRow enriches InterceptPending with conversation/task and rule context.
|
// InterceptApprovalRow enriches InterceptPending with conversation/task and rule context.
|
||||||
export interface InterceptApprovalRow extends InterceptPending {
|
export interface InterceptApprovalRow extends InterceptPending {
|
||||||
conv_title: string; // "" if no linked conversation
|
conv_title: string; // "" if no linked conversation
|
||||||
conv_agent_key: string; // "" if no linked conversation
|
conv_agent_key: string; // "" if no linked conversation
|
||||||
rule_name: string; // "" if rule was deleted
|
rule_name: string; // "" if rule was deleted
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 资产同步 (ScopeSentry 数据源) ──────────────────────────────────────────────
|
// ── 资产同步 (ScopeSentry 数据源) ──────────────────────────────────────────────
|
||||||
export interface SSProject {
|
export interface SSProject {
|
||||||
id: string; // MongoDB ObjectID — used as filter.project
|
id: string; // MongoDB ObjectID — used as filter.project
|
||||||
name: string;
|
name: string;
|
||||||
logo?: string;
|
logo?: string;
|
||||||
AssetCount?: number;
|
AssetCount?: number;
|
||||||
@@ -831,7 +1013,7 @@ export interface SSProject {
|
|||||||
|
|
||||||
export interface SSTask {
|
export interface SSTask {
|
||||||
id: string;
|
id: string;
|
||||||
name: string; // used as filter.task
|
name: string; // used as filter.task
|
||||||
status?: number;
|
status?: number;
|
||||||
progress?: number;
|
progress?: number;
|
||||||
creatTime?: string;
|
creatTime?: string;
|
||||||
|
|||||||
Reference in new issue
Block a user