feat: prepare 0.3.3 worker llm agent and ui enhancements

This commit is contained in:
pphua committed 2026-08-21 23:55:43 +08:00
1 parent d3183dbbd2
commit 1bebdce7f1
28 files changed
+1331 -978

No files matched your search

+2 -1
View File
@@ -55,9 +55,10 @@ jobs:
CGO_ENABLED: "0"
run: |
BIN=artex
VERSION="${GITHUB_REF_NAME#v}"
[ "$GOOS" = "windows" ] && BIN=artex.exe
go build -tags embedui -trimpath \
-ldflags "-s -w -X main.version=${GITHUB_REF_NAME}" \
-ldflags "-s -w -X main.version=${VERSION}" \
-o "$BIN" ./cmd/artex
- name: Package
run: |
+84 -18
View File
@@ -4,28 +4,93 @@
## [Unreleased]
### Added
暂无未发布变更。
- 增加全局任务模板管理,新建任务可直接加载预设描述与目标,也可将当前内容另存为模板。
- 新建任务支持关联多个企业资产范围;企业范围采用结构化规则,覆盖域名、IP、CIDR、ICP 和企业关键词。
- 发现页按任务分组展示漏洞并支持组内独立分页;漏洞可提交带描述的高优先级深入意图,在原任务中继续验证。
- 对话支持重命名、置顶和取消置顶;任务与 Worker 会话支持批量暂停、继续及逐项结果反馈。
- 会话列表展示 Main Agent、Planner 和 Worker 下一次调用的模型,并提供完整历史 Token 聚合与高亮统计。
## [0.3.3] - 2026-08-21
### Changed
### Worker
- 任务列表移除单独的“进入”操作,改为通过可聚焦的任务标题链接进入详情。
- 新增企业改用与任务、LLM 一致的右侧抽屉,并移除 Logo URL 输入项。
- 任务创建、恢复、漏洞深入和队列补位统一使用持久化并发准入与 FIFO 调度路径。
- 任务资产、企业资产、发现任务组及组内漏洞使用服务端真实分页、稳定排序和精确总数。
#### 新增的功能
### Fixed
- 运行中的 Worker 支持单项暂停、恢复和取消;暂停保留意图、事实与漏洞,取消会在执行退出后事务性清理当前意图及其直接产物。
- 增加 `paused` 意图状态、执行栅栏和具名终止原因,防止暂停、取消或任务删除后出现迟到黑板写入。
- 增加可选任务并发上限;新建、恢复、漏洞深入和队列补位统一通过持久化 FIFO 准入路径。
- 修复 LLM 配置链耗尽错误在窄屏横向溢出,以及会话、对话头部和企业范围编辑在移动端的同类布局问题。
- 修复跨任务同名漏洞节点可能联动编辑、删除或展开,以及删除末页数据后页码越界的问题。
- 修复任务生命周期并发写入可能以旧快照覆盖终态、LLM 自动切换可能被旧配置快照回退的问题。
- 修复 `timeout` 任务复活后仍被旧截止时间和收尾状态阻塞;复活后会保留时限配置并在下一次真实执行时重新计时。
- 修复资产分页不稳定、极端页码 OFFSET 溢出、超大请求体状态码错误及部分存储层错误被吞掉的问题。
#### 修改的功能
- Worker 单次运行墙钟默认时长由 600 秒调整为 1200 秒;超时任务复活后在下一次真实执行时重新计时。
- Worker、Planner 和主 Agent 的真实执行状态统一驱动任务状态徽标,修复 Worker 运行时仍显示“空闲”的问题。
- Worker 会话保留单项控制和当前会话 Token 汇总,模型名称移动到当前会话标题右侧展示。
#### 删除的功能
- 删除 Worker 多选、全选及批量暂停/继续界面,同时移除 Worker 批量控制 API 和 Mock 契约。
- 删除 Worker 列表行中的 Token 徽标与 Tooltip,底层完整 Token 账本和任务聚合接口继续保留。
### LLM
#### 新增的功能
- 任务支持有序 LLM 配置链;明确识别供应商额度不足时自动切换下一配置,并持久化当前配置、耗尽状态和错误摘要。
- 运行中和暂停任务支持编辑完整配置链、调整顺序和手动切换当前配置,从下一次 LLM 调用生效。
- 自动切换、手动切换和全链耗尽写入结构化系统活动,并通过任务活动流弹出可去重提醒。
- 增加任务角色模型解析接口,统一解析 Main Agent、Planner 和 Worker 下一次调用使用的配置与模型。
#### 修改的功能
- 目标拆解、Planner、Worker 和主 Agent 共享任务级 LLM runtime;任务显式配置链优先于角色绑定和全局配置。
- 故障转移只响应明确的额度、余额或账单错误;普通限流、鉴权、网络、服务端和上下文错误不会错误切换。
- LLM 配置页改为配置卡片列表和右侧抽屉编辑,保留任务级配置链,不引入 Pool 轮询、优先级和熔断界面。
- 配置链耗尽信息支持窄屏换行;当前模型由会话列表图标改为当前会话标题旁的文字标签和完整 Tooltip。
#### 删除的功能
- 删除会话列表中的 LLM 图标式标识,避免与 Worker 状态图标混淆。
### UI
#### 新增的功能
- 增加全局任务模板 CRUD 和右侧管理抽屉;新建任务可加载预设描述与目标,也可将当前内容另存为模板。
- 新建任务支持关联多个来源任务和多个企业资产范围;企业范围通过单个多行文本框自动识别域名、URL、IP、CIDR、ICP 和企业关键词。
- 发现页按任务分组并提供组内独立分页,支持为漏洞填写描述并创建高优先级深入意图。
- 对话支持重命名、置顶、取消置顶和删除;任务列表支持当前页批量暂停与继续。
- 流量详情和工具执行详情改为右侧抽屉;HTTP 报文补齐 `Host` 并高亮请求行、状态码、Header、JSON 和标记语言正文。
#### 修改的功能
- 任务标题改为可聚焦详情链接;任务资产、企业资产、发现任务组和组内漏洞使用服务端真实分页、稳定排序与精确总数。
- 新增企业改用右侧抽屉,范围录入统一为多行文本并提供实时识别、校验和类型预览。
- 主 Agent 输入框支持自动增长、`Enter` 发送、`Shift+Enter` 换行,并避免中文输入法组合态误发送。
- Agent 预览、任务报告和相关详情统一使用共享 Markdown 渲染;删除确认、长错误和移动端抽屉宽度统一修复。
- 任务模板选择框以模板名称显示和搜索、以模板 ID 提交;恢复系统原始字体尺寸,应用版本统一显示为 `0.3.3`。
- 任务总计和当前会话 Token 只高亮输入、缓存读取和输出数值;发送按钮使用更简洁的上箭头图标。
#### 删除的功能
- 删除任务卡片独立“进入”按钮,统一点击任务标题进入详情。
- 删除仪表盘“新建任务”按钮、新增企业的 Logo URL 输入项,以及发现页顶部和任务分组中的漏洞数量 Badge。
- 撤销全局字体增大 10% 的样式,并删除会话列表中的 Worker 选择框、模型图标和行级 Token 统计。
### Agent
#### 新增的功能
- 新任务可直接关联多个已有任务,实时只读继承直接来源任务的目标、事实、漏洞、已完成意图、资产范围和黑板上下文。
- 黑板读取工具支持按需查询来源任务节点、事实、漏洞和执行轨迹;继承节点带来源标记且所有写工具拒绝修改。
- 企业范围工具支持域名、URL、IP、CIDR、ICP 和企业关键词;关键词仅作为 Agent 范围提示,不参与资产自动归属。
- 漏洞深入操作会在原任务创建带资产锚点和 `derived_from` 边的高优先级人工 Worker 意图。
#### 修改的功能
- 来源任务意图不进入新任务 frontier,新任务继续使用独立 exploration、执行队列、工作目录和对话历史。
- 主 Agent 对话改为服务端活动流驱动,发送失败可恢复输入;打开会话时自动贴底并在详情懒加载后保持最后回复可见。
- 任务暂停会终止当前 Main Agent、Planner 和 Worker 调用,但不阻止暂停期间用户主动发起新的主 Agent 编排会话。
- 取消、关闭和流式中断统一保留真实终止原因、已生成内容、运行轮数、耗时、Token 与未返回工具调用。
#### 删除的功能
- 删除主 Agent 客户端乐观消息回显,避免暂停、失败或并发活动下出现重复消息和跨会话串流。
## [0.3.2] - 2026-08-20
@@ -68,5 +133,6 @@
- [@Autumn-27](https://github.com/Autumn-27)
- [@neouks](https://github.com/neouks)
[Unreleased]: https://github.com/Autumn-27/ARTEX/compare/v0.3.2...HEAD
[Unreleased]: https://github.com/Autumn-27/ARTEX/compare/v0.3.3...HEAD
[0.3.3]: https://github.com/Autumn-27/ARTEX/compare/v0.3.2...v0.3.3
[0.3.2]: https://github.com/Autumn-27/ARTEX/compare/v0.3.1...v0.3.2
+4 -4
View File
@@ -293,14 +293,14 @@ func (t *ToolSet) insertAssets() actool.CoreTool {
func (t *ToolSet) addCompanyScope() actool.CoreTool {
return writeTool(
"add_company_scope",
"把根域名/IP/IP段(CIDR)加入某公司的【资产范围】——加入后系统自动认领所有命中的资产(现有+未来)。\n"+
"把域名/IP/CIDR/ICP备案/企业关键词加入某公司的【资产范围】——域名、网络和ICP会自动认领命中的资产,关键词只提供给Agent作为范围提示。\n"+
"公司名唯一:company 不存在则新建,已存在则复用(只把范围并进去)。\n"+
"scope 一行一条,支持:根域名 / 单个 IP / CIDR 网段(如 1.2.3.0/24)。\n"+
"scope 一行一条,系统自动识别:根域名 / URL / 单个 IP / CIDR 网段 / ICP备案 / 企业关键词。\n"+
"务必给 reason 说明归属依据(whois/证书/ASN 等)。\n"+
"护栏:拒绝裸 TLD 与过宽网段(IPv4<=/16、IPv6<=/32),非法行会被跳过并在 errors 返回。",
"护栏:拒绝裸 TLD 与过宽网段(IPv4前缀需为/16-/32、IPv6前缀需为/32-/128),非法行会被跳过并在 errors 返回。",
obj(map[string]any{
"company": str("公司名(不存在则新建、存在则复用;名称唯一)"),
"scope": str("资产范围,一行一条:根域名 / IP / CIDR"),
"scope": str("资产范围,一行一条:域名 / URL / IP / CIDR / ICP备案 / 企业关键词"),
"reason": str("归属依据(证据/来源),务必填写"),
"logo": str("公司图标 URL(可选;仅新建公司时生效)"),
}, "company", "scope"),
+2 -8
View File
@@ -4,7 +4,7 @@
# 可选环境变量:
# ARTEX_TARGET_OS=linux 目标系统,默认当前系统
# ARTEX_TARGET_ARCH=amd64 目标架构,默认当前架构
# ARTEX_BUILD_VERSION=v0.3.0 构建版本,默认取 git describe
# ARTEX_BUILD_VERSION=0.3.3 构建版本,默认使用当前应用版本
# ARTEX_OUTPUT=/path/to/artex 输出路径
# ARTEX_SKIP_NPM_CI=1 跳过 npm ci,复用现有依赖
# ARTEX_GOSUMDB=sum.golang.org Go checksum database
@@ -24,13 +24,7 @@ ARTEX_GOSUMDB="${ARTEX_GOSUMDB:-sum.golang.org}"
ARTEX_TARGET_OS="${ARTEX_TARGET_OS:-$(GOSUMDB="$ARTEX_GOSUMDB" go env GOOS)}"
ARTEX_TARGET_ARCH="${ARTEX_TARGET_ARCH:-$(GOSUMDB="$ARTEX_GOSUMDB" go env GOARCH)}"
if [ -z "${ARTEX_BUILD_VERSION:-}" ]; then
if command -v git >/dev/null 2>&1 && git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
ARTEX_BUILD_VERSION="$(git describe --tags --always --dirty)"
else
ARTEX_BUILD_VERSION="dev"
fi
fi
ARTEX_BUILD_VERSION="${ARTEX_BUILD_VERSION:-0.3.3}"
ARTEX_BINARY_NAME="artex"
[ "$ARTEX_TARGET_OS" = "windows" ] && ARTEX_BINARY_NAME="artex.exe"
+3 -3
View File
@@ -21,9 +21,9 @@ import (
"github.com/Autumn-27/artex/server"
)
// version is the build version, injected at release time via
// -ldflags "-X main.version=<tag>". Defaults to "dev" for local builds.
var version = "dev"
// version is the application version. Release builds may override it via
// -ldflags "-X main.version=<version>".
var version = "0.3.3"
const banner = `
_ ____ _____ _______ __
+7 -2
View File
@@ -323,8 +323,8 @@ func (s *CompanyStore) AddScope(companyID int64, lines []string, reason string)
return s.AddScopeInputs(companyID, inputs, reason)
}
// AddScopeInputs inserts structured scope rules. Empty kinds retain the legacy
// domain/IP/CIDR auto-detection used by AddScope.
// AddScopeInputs inserts structured scope rules. Empty kinds use the automatic
// CIDR/IP/ICP/domain/keyword classification used by AddScope.
func (s *CompanyStore) AddScopeInputs(companyID int64, inputs []ScopeInput, reason string) (added, skipped, invalid int, errors []string) {
added, skipped, invalid, validationErrors, err := s.AddScopeInputsChecked(companyID, inputs, reason)
if err != nil {
@@ -604,6 +604,11 @@ func (s *CompanyStore) UpdateScopeInputsChecked(companyID int64, inputs []ScopeI
return 0, 0, nil, err
}
rules, invalid, errs := parseScopeInputs(inputs)
if invalid > 0 {
return 0, invalid, errs, &CompanyScopeValidationError{Message: fmt.Sprintf(
"企业范围包含 %d 条无效规则,未覆盖原有范围", invalid,
)}
}
if err := validateParsedScopeBounds(rules); err != nil {
return 0, invalid, errs, err
}
+17
View File
@@ -255,6 +255,23 @@ func TestUpdateScope(t *testing.T) {
if len(scope) != 1 || scope[0].Domain != "new-domain.com" {
t.Errorf("UpdateScope: expected new-domain.com only, got %+v", scope)
}
// Invalid replacement input must not turn a partial validation response into
// a destructive replacement of the existing rules.
added, invalid, validationErrors, err := cs.UpdateScopeInputsChecked(id, []ScopeInput{
{Kind: "domain", Value: "co.uk"},
}, "invalid replacement")
var validationErr *CompanyScopeValidationError
if added != 0 || invalid != 1 || len(validationErrors) != 1 || !errors.As(err, &validationErr) {
t.Fatalf("invalid replacement: added=%d invalid=%d validation=%v err=%v", added, invalid, validationErrors, err)
}
scope, err = cs.GetScope(id)
if err != nil {
t.Fatal(err)
}
if len(scope) != 1 || scope[0].Domain != "new-domain.com" {
t.Fatalf("invalid replacement changed existing scope: %+v", scope)
}
}
func TestUpdateScopeRollsBackOnInsertFailure(t *testing.T) {
+155 -38
View File
@@ -3,14 +3,16 @@ package db
import (
"fmt"
"net"
"net/url"
"strings"
"unicode"
"golang.org/x/net/idna"
"golang.org/x/net/publicsuffix"
)
// ParsedScope is one parsed asset-scope entry. Its kind selects Domain, Net, or Value.
// Used internally by ParseScopeLine / ParseScopeLines (passed to CompanyStore).
// Used internally by the company scope parsers and CompanyStore.
type ParsedScope struct {
Kind string // "domain" | "ip" | "cidr" | "icp" | "keyword"
Domain string // normalized registrable/root domain (kind=domain)
@@ -20,7 +22,7 @@ type ParsedScope struct {
}
// ScopeInput is the structured API form for a company scope rule. Empty Kind
// preserves the legacy one-line auto detection for domain/IP/CIDR clients.
// uses the same automatic classification as the single-textarea UI.
type ScopeInput struct {
Kind string `json:"kind,omitempty"`
Value string `json:"value"`
@@ -41,13 +43,56 @@ func normalizeKeyword(value string) string {
return strings.ToLower(strings.Join(strings.Fields(value), " "))
}
func looksLikeIPAddress(value string) bool {
value = strings.TrimSpace(value)
if strings.Contains(value, "://") || strings.IndexFunc(value, unicode.IsSpace) >= 0 {
return false
}
if strings.Count(value, ":") >= 2 {
// Require an IPv6-looking prefix. This still catches malformed values such
// as 2001:db8::zz without treating ordinary colon-delimited keywords as IPs.
parts := strings.Split(value, ":")
validSegments := 0
for _, part := range parts {
if part == "" {
if validSegments > 0 || strings.HasPrefix(value, "::") {
return true
}
continue
}
if len(part) > 4 {
return false
}
for _, r := range part {
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) {
return false
}
}
validSegments++
if validSegments >= 2 {
return true
}
}
return false
}
if !strings.Contains(value, ".") {
return false
}
for _, r := range value {
if r != '.' && (r < '0' || r > '9') {
return false
}
}
return true
}
// ParseScopeInput validates an explicitly typed rule. Legacy callers can omit
// Kind and retain the existing domain/IP/CIDR auto-detection behavior.
// Kind and use the same automatic classification as the single-textarea UI.
func ParseScopeInput(input ScopeInput) (ParsedScope, error) {
kind := strings.ToLower(strings.TrimSpace(input.Kind))
raw := strings.TrimSpace(input.Value)
if kind == "" {
return ParseScopeLine(raw)
return ParseAutoScopeLine(raw)
}
switch kind {
case "domain", "ip", "cidr":
@@ -76,6 +121,93 @@ func ParseScopeInput(input ScopeInput) (ParsedScope, error) {
}
}
// ParseAutoScopeLine classifies one untyped textarea line. Network-looking and
// domain-looking values remain strict so malformed ranges do not silently become
// Agent keywords; all other non-empty text is a keyword.
func ParseAutoScopeLine(line string) (ParsedScope, error) {
raw := strings.TrimSpace(line)
if raw == "" {
return ParsedScope{}, fmt.Errorf("空行")
}
if _, _, err := net.ParseCIDR(raw); err == nil {
return ParseScopeLine(raw)
}
if ip := net.ParseIP(raw); ip != nil {
return ParseScopeLine(raw)
}
if slash := strings.LastIndexByte(raw, '/'); slash > 0 {
address := strings.TrimSpace(raw[:slash])
if net.ParseIP(address) != nil || looksLikeIPAddress(address) {
return ParsedScope{Raw: raw}, fmt.Errorf("无效 CIDR: %s", raw)
}
}
if looksLikeIPAddress(raw) {
return ParsedScope{Raw: raw}, fmt.Errorf("无效 IP: %s", raw)
}
looksLikeDomain := strings.Contains(raw, "://") ||
(strings.Contains(raw, ".") && strings.IndexFunc(raw, unicode.IsSpace) < 0)
if looksLikeDomain {
return ParseScopeLine(raw)
}
lower := strings.ToLower(raw)
if strings.Contains(lower, "icp") || strings.Contains(raw, "备案") {
return ParseScopeInput(ScopeInput{Kind: "icp", Value: raw})
}
return ParseScopeInput(ScopeInput{Kind: "keyword", Value: raw})
}
func scopeHostname(raw string) (string, error) {
candidate := strings.TrimSpace(raw)
if candidate == "" {
return "", fmt.Errorf("主机名为空")
}
if strings.HasPrefix(candidate, "//") {
candidate = "http:" + candidate
} else if !strings.Contains(candidate, "://") {
candidate = "http://" + candidate
}
parsed, err := url.Parse(candidate)
if err != nil || parsed.Host == "" {
if err == nil {
err = fmt.Errorf("缺少主机名")
}
return "", err
}
host := strings.TrimSuffix(strings.TrimSpace(parsed.Hostname()), ".")
if host == "" {
return "", fmt.Errorf("主机名为空")
}
if ip := net.ParseIP(host); ip != nil {
return ip.String(), nil
}
host, err = idna.Lookup.ToASCII(host)
if err != nil {
return "", err
}
host = strings.ToLower(host)
if len(host) > 253 {
return "", fmt.Errorf("域名超过 253 个字符")
}
labels := strings.Split(host, ".")
if len(labels) < 2 {
return "", fmt.Errorf("域名至少需要两个标签")
}
for _, label := range labels {
if label == "" || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
return "", fmt.Errorf("域名标签无效")
}
for _, r := range label {
if !((r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-') {
return "", fmt.Errorf("域名包含无效字符")
}
}
}
return host, nil
}
// ParseScopeLine classifies and validates one scope line (root domain / IP /
// CIDR). Guardrails reject bare TLDs and over-broad networks so a rule can never
// swallow the internet. IP ranges must be expressed as CIDR.
@@ -85,12 +217,8 @@ func ParseScopeLine(line string) (ParsedScope, error) {
if raw == "" {
return r, fmt.Errorf("空行")
}
s := raw
if i := strings.Index(s, "://"); i >= 0 { // strip scheme if a URL was pasted
s = s[i+3:]
}
// CIDR first — it contains '/', which the path-strip below would remove.
if _, ipnet, err := net.ParseCIDR(s); err == nil {
// CIDR first because URL parsing treats its slash as a path separator.
if _, ipnet, err := net.ParseCIDR(raw); err == nil {
ones, bits := ipnet.Mask.Size()
if bits == 32 && ones < 16 {
return r, fmt.Errorf("网段过宽(IPv4 需 >= /16): %s", raw)
@@ -101,12 +229,8 @@ func ParseScopeLine(line string) (ParsedScope, error) {
r.Kind, r.Net = "cidr", ipnet.String()
return r, nil
}
// Strip any path and :port remnants for host-like inputs.
if i := strings.IndexByte(s, '/'); i >= 0 {
s = s[:i]
}
// Single IP.
if ip := net.ParseIP(strings.TrimSpace(s)); ip != nil {
if ip := net.ParseIP(raw); ip != nil {
r.Kind = "ip"
if ip.To4() != nil {
r.Net = ip.String() + "/32"
@@ -115,37 +239,30 @@ func ParseScopeLine(line string) (ParsedScope, error) {
}
return r, nil
}
if host, _, ok := strings.Cut(s, ":"); ok { // host:port → host
s = host
host, err := scopeHostname(raw)
if err != nil {
return r, fmt.Errorf("无法识别为有效域名/IP/CIDR: %s", raw)
}
if ip := net.ParseIP(host); ip != nil {
r.Kind = "ip"
if ip.To4() != nil {
r.Net = ip.String() + "/32"
} else {
r.Net = ip.String() + "/128"
}
return r, nil
}
if looksLikeIPAddress(host) {
return r, fmt.Errorf("无效 IP: %s", raw)
}
if strings.Contains(raw, "-") && strings.Count(raw, ".") >= 6 {
return r, fmt.Errorf("IP 段请用 CIDR 表示(如 1.2.3.0/24): %s", raw)
}
// Domain (registrable). Reject bare TLDs / public suffixes.
d := DomainKey(s)
if d == "" || !strings.Contains(d, ".") {
return r, fmt.Errorf("无法识别为根域名/IP/CIDR: %s", raw)
}
d := DomainKey(host)
if suf, icann := publicsuffix.PublicSuffix(d); icann && suf == d {
return r, fmt.Errorf("不能用裸 TLD 作为范围: %s", raw)
}
r.Kind, r.Domain = "domain", d
return r, nil
}
// ParseScopeLines parses a whole text block (one entry per line), returning the
// valid rules and the invalid lines (with reasons) so the caller can report both.
func ParseScopeLines(text string) (rules []ParsedScope, invalid []string) {
for _, ln := range strings.Split(text, "\n") {
if strings.TrimSpace(ln) == "" {
continue
}
r, err := ParseScopeLine(ln)
if err != nil {
invalid = append(invalid, err.Error())
continue
}
rules = append(rules, r)
}
return rules, invalid
}
+59 -7
View File
@@ -40,6 +40,65 @@ func TestParseScopeLine(t *testing.T) {
}
}
func TestParseAutoScopeLine(t *testing.T) {
cases := []struct {
name string
input string
kind string
normalized string
wantErr bool
}{
{name: "domain", input: "example.com", kind: "domain", normalized: "example.com"},
{name: "url", input: "https://sub.example.com/path", kind: "domain", normalized: "sub.example.com"},
{name: "url query", input: "https://example.com/path?source=x", kind: "domain", normalized: "example.com"},
{name: "url credentials", input: "https://user:[email protected]/path", kind: "domain", normalized: "example.com"},
{name: "url ip", input: "http://203.0.113.10/path", kind: "ip", normalized: "203.0.113.10/32"},
{name: "ipv4", input: "203.0.113.10", kind: "ip", normalized: "203.0.113.10/32"},
{name: "ipv6", input: "2001:db8::10", kind: "ip", normalized: "2001:db8::10/128"},
{name: "cidr", input: "198.51.100.0/24", kind: "cidr", normalized: "198.51.100.0/24"},
{name: "icp latin", input: "京 ICP备 123号", kind: "icp", normalized: "京icp备123号"},
{name: "icp chinese", input: "沪网备案 9988", kind: "icp", normalized: "沪网备案9988"},
{name: "icp domain", input: "icp.example.com", kind: "domain", normalized: "icp.example.com"},
{name: "icp url query", input: "https://example.com/path?icp=1", kind: "domain", normalized: "example.com"},
{name: "keyword", input: " ACME Security ", kind: "keyword", normalized: "acme security"},
{name: "colon keyword", input: "ACME: Cloud: Security", kind: "keyword", normalized: "acme: cloud: security"},
{name: "empty", input: " ", wantErr: true},
{name: "invalid cidr", input: "10.0.0.0/not-a-prefix", wantErr: true},
{name: "invalid ipv4", input: "999.0.0.1", wantErr: true},
{name: "invalid ipv6", input: "2001:db8::zz", wantErr: true},
{name: "invalid ipv6 cidr", input: "2001:db8::zz/64", wantErr: true},
{name: "overbroad cidr", input: "10.0.0.0/8", wantErr: true},
{name: "bare suffix", input: "co.uk", wantErr: true},
{name: "empty domain label", input: "foo..example.com", wantErr: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rule, err := ParseAutoScopeLine(tc.input)
if tc.wantErr {
if err == nil {
t.Fatalf("ParseAutoScopeLine(%q) = %+v, want error", tc.input, rule)
}
return
}
if err != nil {
t.Fatalf("ParseAutoScopeLine(%q): %v", tc.input, err)
}
if rule.Kind != tc.kind {
t.Fatalf("kind=%q want %q", rule.Kind, tc.kind)
}
got := rule.Value
if rule.Kind == "domain" {
got = rule.Domain
} else if rule.Kind == "ip" || rule.Kind == "cidr" {
got = rule.Net
}
if got != tc.normalized {
t.Fatalf("normalized=%q want %q", got, tc.normalized)
}
})
}
}
func TestExplicitCompanyAttributionSurvivesScopeRebuild(t *testing.T) {
d, as, cs := testSetup(t)
defer d.Close()
@@ -277,13 +336,6 @@ func TestCompanyScopeAttribution(t *testing.T) {
t.Fatalf("pre-scope asset should be unattributed, got %v", *preCompanyID)
}
rules, invalid := ParseScopeLines(root + "\n198.51.100.0/24\nco.uk")
if len(rules) != 2 {
t.Fatalf("want 2 valid rules, got %d (%+v)", len(rules), rules)
}
if len(invalid) != 1 {
t.Fatalf("want 1 invalid line, got %v", invalid)
}
cs.AddScope(cid, []string{root, "198.51.100.0/24"}, "unit test")
mustCid := func(id int64, want int64, label string) {
-1
View File
@@ -667,7 +667,6 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("PUT /api/tasks/{id}/llm", s.updateTaskLLMProfiles)
mux.HandleFunc("GET /api/tasks/{id}/llm/resolution", s.taskLLMResolutionHandler)
mux.HandleFunc("POST /api/tasks/{id}/intents/{iid}/control", s.controlIntent)
mux.HandleFunc("POST /api/tasks/{id}/intents/control/batch", s.controlIntentsBatch)
mux.HandleFunc("POST /api/tasks/{id}/intents/{iid}/rerun", s.rerunIntent) // 重跑单条 blocked/exhausted/stopped 意图
mux.HandleFunc("POST /api/tasks/{id}/intents/rerun-blocked", s.rerunBlocked) // 批量重跑本任务全部 blocked 意图
mux.HandleFunc("POST /api/active", s.setActive)
-37
View File
@@ -688,43 +688,6 @@ func TestBatchControlLimitCountsDeduplicatedIDs(t *testing.T) {
t.Fatalf("unique task ids status=%d body=%s", rec.Code, rec.Body.String())
}
task, err := m.CreateTask("batch intent limit", "deduplicate intent ids", nil, 0, 0)
if err != nil {
t.Fatal(err)
}
defer func() { _, _ = m.DeleteTask(task.ID, DeleteTaskOptions{}) }()
repeatedIntents := make([]string, maxBatchControlIDs+1)
for i := range repeatedIntents {
repeatedIntents[i] = "999999"
}
rec = request(s.controlIntentsBatch, "/api/tasks/1/intents/control/batch", map[string]string{"id": task.ID}, map[string]any{
"intent_ids": repeatedIntents,
"action": "pause",
})
if rec.Code != http.StatusOK {
t.Fatalf("deduplicated intent ids status=%d body=%s", rec.Code, rec.Body.String())
}
var intentPayload struct {
Items []batchControlItem `json:"items"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &intentPayload); err != nil {
t.Fatal(err)
}
if len(intentPayload.Items) != 1 || intentPayload.Items[0].ID != "999999" {
t.Fatalf("deduplicated intent response=%+v", intentPayload.Items)
}
uniqueIntents := make([]string, maxBatchControlIDs+1)
for i := range uniqueIntents {
uniqueIntents[i] = strconv.Itoa(i + 1)
}
rec = request(s.controlIntentsBatch, "/api/tasks/1/intents/control/batch", map[string]string{"id": task.ID}, map[string]any{
"intent_ids": uniqueIntents,
"action": "resume",
})
if rec.Code != http.StatusBadRequest {
t.Fatalf("unique intent ids status=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestPauseTaskToolPersistsAndDequeuesTask(t *testing.T) {
-73
View File
@@ -6,7 +6,6 @@ import (
"fmt"
"log"
"net/http"
"strconv"
"strings"
"github.com/Autumn-27/artex/agent"
@@ -32,7 +31,6 @@ type batchControlItem struct {
ID string `json:"id"`
OK bool `json:"ok"`
Status string `json:"status,omitempty"`
State string `json:"state,omitempty"`
Queued bool `json:"queued,omitempty"`
Error string `json:"error,omitempty"`
}
@@ -241,74 +239,3 @@ func (s *Server) controlTasksBatch(w http.ResponseWriter, r *http.Request) {
}
writeJSON(w, 200, map[string]any{"items": items})
}
func (s *Server) controlIntentsBatch(w http.ResponseWriter, r *http.Request) {
t, ok := s.m.Task(r.PathValue("id"))
if !ok {
writeErr(w, 404, "task not found")
return
}
r.Body = http.MaxBytesReader(w, r.Body, 256<<10)
var req struct {
IntentIDs []string `json:"intent_ids"`
Action string `json:"action"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, 400, "bad json: "+err.Error())
return
}
if req.Action != "pause" && req.Action != "resume" {
writeErr(w, 400, "action must be pause|resume")
return
}
type parsedIntentID struct {
raw string
id int64
valid bool
}
seen := map[int64]bool{}
seenInvalid := map[string]bool{}
intentIDs := make([]parsedIntentID, 0, len(req.IntentIDs))
for _, raw := range req.IntentIDs {
iid, err := strconv.ParseInt(raw, 10, 64)
if err != nil || iid <= 0 {
if seenInvalid[raw] {
continue
}
seenInvalid[raw] = true
intentIDs = append(intentIDs, parsedIntentID{raw: raw})
continue
}
if seen[iid] {
continue
}
seen[iid] = true
intentIDs = append(intentIDs, parsedIntentID{raw: raw, id: iid, valid: true})
}
if len(intentIDs) == 0 || len(intentIDs) > maxBatchControlIDs {
writeErr(w, 400, fmt.Sprintf("intent_ids 数量必须为 1-%d", maxBatchControlIDs))
return
}
if !s.engine.beginTaskOperation(t.ID) {
writeErr(w, 409, "任务正在删除,无法控制意图")
return
}
defer s.engine.decInflight(t.ID)
items := make([]batchControlItem, 0, len(intentIDs))
for _, parsed := range intentIDs {
item := batchControlItem{ID: parsed.raw}
if !parsed.valid {
item.Error = "bad intent id"
items = append(items, item)
continue
}
result, err := s.applyIntentControl(r.Context(), t, parsed.id, req.Action)
if err != nil {
item.Error = err.Error()
} else {
item.OK, item.State = true, result.State
}
items = append(items, item)
}
writeJSON(w, 200, map[string]any{"items": items})
}
+46
View File
@@ -0,0 +1,46 @@
package server
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestWorkerControlRoutes(t *testing.T) {
m, err := NewManager(t.TempDir(), "")
if err != nil {
t.Skipf("postgres unavailable (%v) - skipping", err)
}
defer m.Close()
td := t.TempDir()
s := New(context.Background(), m, td, td, td)
h := s.Handler()
token, err := signJWT(s.jwtKey)
if err != nil {
t.Fatalf("sign jwt: %v", err)
}
request := func(path string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{"action":"pause"}`))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
// The single-worker route is still registered and reaches its JSON handler.
single := request("/api/tasks/missing/intents/1/control")
if single.Code != http.StatusNotFound || !strings.Contains(single.Body.String(), `"error":"task not found"`) {
t.Fatalf("single control route unavailable: status=%d body=%s", single.Code, single.Body.String())
}
// The former batch route must fall through the mux instead of reaching a task handler.
batch := request("/api/tasks/missing/intents/control/batch")
if batch.Code != http.StatusNotFound || !strings.Contains(batch.Body.String(), "404 page not found") {
t.Fatalf("batch control route still registered: status=%d body=%s", batch.Code, batch.Body.String())
}
}
+21 -1
View File
@@ -219,7 +219,7 @@ func (t *Traffic) record(f *mproxy.Flow) {
respBody := t.bodyOrBlob(f.Response.Body)
ct := f.Response.Header.Get("Content-Type")
reqTxt := fmt.Sprintf("%s %s %s\n%s\n%s", method, f.Request.URL.RequestURI(), f.Request.Proto, headerLines(f.Request.Header), reqBody)
reqTxt := fmt.Sprintf("%s %s %s\n%s\n%s", method, f.Request.URL.RequestURI(), f.Request.Proto, requestHeaderLines(f.Request), reqBody)
respTxt := fmt.Sprintf("HTTP %d\n%s\n%s", f.Response.StatusCode, headerLines(f.Response.Header), respBody)
_ = os.WriteFile(filepath.Join(exDir, "request.http"), []byte(reqTxt), 0o644)
_ = os.WriteFile(filepath.Join(exDir, "response.http"), []byte(respTxt), 0o644)
@@ -267,6 +267,26 @@ func headerLines(h map[string][]string) string {
return b.String()
}
// requestHeaderLines restores the HTTP Host header, which net/http stores on
// Request.Host rather than in Header. Keeping it in request.http makes the raw
// capture complete and directly replayable.
func requestHeaderLines(req *mproxy.Request) string {
headers := req.Header.Clone()
headers.Del("Host")
host := req.URL.Host
if raw := req.Raw(); raw != nil && strings.TrimSpace(raw.Host) != "" {
host = raw.Host
}
var b strings.Builder
if host = strings.TrimSpace(host); host != "" {
b.WriteString("Host: ")
b.WriteString(host)
b.WriteByte('\n')
}
b.WriteString(headerLines(headers))
return b.String()
}
func sanitize(s string) string {
r := strings.NewReplacer("/", "_", "\\", "_", ":", "_", "?", "_", "*", "_", "\"", "_", "<", "_", ">", "_", "|", "_")
out := r.Replace(s)
+18
View File
@@ -2,12 +2,30 @@ package traffic
import (
"fmt"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
mproxy "github.com/lqqyt2423/go-mitmproxy/proxy"
)
func TestRequestHeaderLinesIncludesHost(t *testing.T) {
req := &mproxy.Request{
URL: &url.URL{Host: "target.example:8443"},
Header: http.Header{"Accept": []string{"application/json"}},
}
got := requestHeaderLines(req)
if !strings.Contains(got, "Host: target.example:8443\n") {
t.Fatalf("request headers missing Host: %q", got)
}
if !strings.Contains(got, "Accept: application/json\n") {
t.Fatalf("request headers missing regular header: %q", got)
}
}
// TestDeleteHost verifies the delete contract: rows for hosts containing the
// substring are removed together with their file trees, non-matching hosts are
// untouched, and the count is right.
@@ -35,7 +35,7 @@ export function MainContent({ children }: { children: ReactNode }) {
useEffect(() => {
api
.health()
.then((h) => setVersion(h.version ?? ""))
.then((h) => setVersion((h.version ?? "").replace(/^v(?=\d)/, "")))
.catch(() => setVersion(""));
}, []);
+9 -3
View File
@@ -3,6 +3,7 @@
import * as React from "react";
import {
ArrowUpIcon,
Bot,
ChevronDownIcon,
Loader2Icon,
@@ -12,7 +13,6 @@ import {
PinIcon,
PinOffIcon,
PlusIcon,
SendIcon,
Square,
Trash2Icon,
XIcon,
@@ -202,8 +202,14 @@ function Composer({
<Square className="size-3.5 fill-current" />
</Button>
) : (
<Button size="icon" onClick={onSend} disabled={disabled || (!value.trim() && atts.length === 0)}>
<SendIcon className="size-4" />
<Button
size="icon"
onClick={onSend}
disabled={disabled || (!value.trim() && atts.length === 0)}
title="发送消息"
aria-label="发送消息"
>
<ArrowUpIcon />
</Button>
)}
</div>
+8 -11
View File
@@ -10,7 +10,6 @@ import {
BugIcon,
ClockIcon,
NetworkIcon,
PlusIcon,
ShieldCheckIcon,
TargetIcon,
ZapIcon,
@@ -177,7 +176,7 @@ export default function DashboardPage() {
/* transient errors — next poll retries */
}
};
load();
void load();
const t = setInterval(load, 5000);
return () => {
alive = false;
@@ -213,7 +212,7 @@ export default function DashboardPage() {
/* transient errors */
}
};
load();
void load();
const t = setInterval(load, 15000);
return () => {
alive = false;
@@ -461,17 +460,11 @@ export default function DashboardPage() {
return (
<div className="flex flex-1 flex-col gap-4 pb-6">
{/* ── Header ── */}
<div className="flex items-center justify-between">
<div>
<div>
<h1 className="text-lg font-semibold tracking-tight">总览</h1>
<p className="text-xs text-muted-foreground">系统全局状态 · 实时刷新</p>
</div>
<Link href="/function/tasks">
<button className="flex items-center gap-1.5 rounded-lg bg-foreground px-3 py-1.5 text-xs font-medium text-background transition-opacity hover:opacity-90">
<PlusIcon className="size-3.5" />
新建任务
</button>
</Link>
</div>
{/* ── Row 1: 5 stat cards ── */}
@@ -575,6 +568,7 @@ export default function DashboardPage() {
] as const
).map(({ v, label }) => (
<button
type="button"
key={v}
onClick={() => setTokenVersion(v)}
title={
@@ -600,6 +594,7 @@ export default function DashboardPage() {
{/* Profile tabs */}
<div className="flex flex-wrap items-center gap-1">
<button
type="button"
onClick={() => setTokenTab("all")}
className={cn(
"rounded-md px-2.5 py-1 text-[10px] font-medium transition-colors",
@@ -616,6 +611,7 @@ export default function DashboardPage() {
const resolvedKey = tokenByProfile.has(key) ? key : p.is_default ? defaultProfileId : key;
return (
<button
type="button"
key={p.id}
onClick={() => setTokenTab(resolvedKey)}
className={cn(
@@ -745,6 +741,7 @@ export default function DashboardPage() {
] as const
).map(({ days, label }) => (
<button
type="button"
key={days}
onClick={() => setTokenDays(days)}
className={cn(
@@ -993,7 +990,7 @@ export default function DashboardPage() {
)}
</td>
<td className="px-4 py-3 tabular-nums">
{(t.in_flight ?? 0 > 0) ? (
{(t.in_flight ?? 0) > 0 ? (
<span className="font-semibold">{t.in_flight}</span>
) : (
<span className="text-muted-foreground">—</span>
+124 -192
View File
@@ -12,7 +12,6 @@ import {
LinkIcon,
type LucideIcon,
NetworkIcon,
PlusIcon,
RefreshCwIcon,
SearchIcon,
SmartphoneIcon,
@@ -46,7 +45,6 @@ import {
} from "@/components/ui/dialog";
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
Sheet,
@@ -59,7 +57,9 @@ import {
} from "@/components/ui/sheet";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/lib/api";
import { MAX_COMPANY_SCOPE_RULES, type ParsedCompanyScopeText, parseCompanyScopeText } from "@/lib/company-scope";
import type { Asset, Company, CompanyScopeKind, CompanyScopeRule } from "@/lib/types";
import { cn } from "@/lib/utils";
@@ -1254,11 +1254,13 @@ function AssetCard({
<SelectValue />
</SelectTrigger>
<SelectContent>
{PAGE_SIZES.map((n) => (
<SelectItem key={n} value={String(n)}>
{n} / 页
</SelectItem>
))}
<SelectGroup>
{PAGE_SIZES.map((n) => (
<SelectItem key={n} value={String(n)}>
{n} / 页
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<span className="tabular-nums">
@@ -1319,171 +1321,94 @@ function CompanyAvatar({ name, logo }: { name: string; logo?: string }) {
);
}
const COMPANY_SCOPE_KINDS: { value: CompanyScopeKind; label: string; placeholder: string }[] = [
{ value: "domain", label: "域名", placeholder: "example.com" },
{ value: "ip", label: "IP", placeholder: "203.0.113.10" },
{ value: "cidr", label: "CIDR", placeholder: "198.51.100.0/24" },
{ value: "icp", label: "ICP", placeholder: "京ICP备12345678号-1" },
{ value: "keyword", label: "企业关键词", placeholder: "Acme / 产品品牌名" },
];
const COMPANY_SCOPE_KIND_LABELS: Record<CompanyScopeKind, string> = {
domain: "域名",
ip: "IP",
cidr: "CIDR",
icp: "ICP",
keyword: "关键词",
};
const MAX_COMPANY_SCOPE_RULES = 256;
const MAX_COMPANY_SCOPE_VALUE_LENGTH = 1024;
type EditableScopeRule = CompanyScopeRule & { key: string };
let nextScopeRuleKey = 0;
function editableScopeRule(kind: CompanyScopeKind = "domain", value = ""): EditableScopeRule {
nextScopeRuleKey += 1;
return { kind, value, key: `company-scope-${nextScopeRuleKey}` };
}
function isIPv4(value: string): boolean {
const parts = value.split(".");
return (
parts.length === 4 &&
parts.every((part) => (part === "0" || /^[1-9]\d{0,2}$/.test(part)) && Number(part) >= 0 && Number(part) <= 255)
);
}
function isIPv6(value: string): boolean {
if (!value.includes(":") || /[^0-9a-f:.]/i.test(value) || value.includes(":::")) return false;
const halves = value.split("::");
if (halves.length > 2) return false;
const countSegments = (half: string): number | null => {
if (!half) return 0;
const segments = half.split(":");
let count = 0;
for (const [index, segment] of segments.entries()) {
if (segment.includes(".")) {
if (index !== segments.length - 1 || !isIPv4(segment)) return null;
count += 2;
} else {
if (!/^[0-9a-f]{1,4}$/i.test(segment)) return null;
count++;
}
}
return count;
};
const left = countSegments(halves[0]);
const right = countSegments(halves[1] ?? "");
if (left === null || right === null) return false;
return halves.length === 2 ? left + right < 8 : left === 8;
}
function ipVersion(value: string): 4 | 6 | null {
if (isIPv4(value)) return 4;
if (isIPv6(value)) return 6;
return null;
}
function scopeRuleError(rule: CompanyScopeRule): string {
const value = rule.value.trim();
if (!value) return "请填写范围值";
if (rule.kind === "domain" && (!value.includes(".") || /\s/.test(value))) return "请输入有效域名";
if (rule.kind === "ip" && ipVersion(value) === null) return "请输入有效 IP";
if (rule.kind === "cidr") {
const separator = value.lastIndexOf("/");
if (separator <= 0) return "请输入 CIDR 网段";
const address = value.slice(0, separator);
const prefixText = value.slice(separator + 1);
const version = ipVersion(address);
if (version === null || !/^\d+$/.test(prefixText)) return "请输入 CIDR 网段";
const prefix = Number(prefixText);
if (version === 4 && (prefix < 16 || prefix > 32)) return "IPv4 网段前缀需为 /16 至 /32";
if (version === 6 && (prefix < 32 || prefix > 128)) return "IPv6 网段前缀需为 /32 至 /128";
}
return "";
}
function ScopeRulesEditor({
function ScopeTextEditor({
id,
value,
onValueChange,
parsed,
}: {
value: EditableScopeRule[];
onValueChange: (value: EditableScopeRule[]) => void;
id: string;
value: string;
onValueChange: (value: string) => void;
parsed: ParsedCompanyScopeText;
}) {
const update = (index: number, patch: Partial<EditableScopeRule>) => {
onValueChange(value.map((rule, i) => (i === index ? { ...rule, ...patch } : rule)));
};
const counts = React.useMemo(() => {
const result = new Map<CompanyScopeKind, number>();
for (const rule of parsed.rules) result.set(rule.kind, (result.get(rule.kind) ?? 0) + 1);
return result;
}, [parsed.rules]);
return (
<div className="flex flex-col gap-3">
{value.map((rule, index) => {
const meta = COMPANY_SCOPE_KINDS.find((item) => item.value === rule.kind) ?? COMPANY_SCOPE_KINDS[0];
// Empty rows are omitted on submit, so the initial optional row should
// not appear invalid before the user enters a value.
const error = rule.value.trim() ? scopeRuleError(rule) : "";
return (
<Field key={rule.key} data-invalid={!!error}>
<div className="flex items-start gap-2">
<Select value={rule.kind} onValueChange={(kind) => update(index, { kind: kind as CompanyScopeKind })}>
<SelectTrigger className="w-32 shrink-0" aria-label={`范围 ${index + 1} 类型`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{COMPANY_SCOPE_KINDS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Input
value={rule.value}
placeholder={meta.placeholder}
aria-label={`范围 ${index + 1} 内容`}
aria-invalid={!!error}
maxLength={MAX_COMPANY_SCOPE_VALUE_LENGTH}
onChange={(event) => update(index, { value: event.target.value })}
/>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={`删除范围 ${index + 1}`}
disabled={value.length === 1}
onClick={() => onValueChange(value.filter((_, i) => i !== index))}
>
<Trash2Icon />
</Button>
</div>
{rule.value && error ? <FieldError>{error}</FieldError> : null}
</Field>
);
})}
<Button
type="button"
variant="outline"
className="self-start"
disabled={value.length >= MAX_COMPANY_SCOPE_RULES}
title={value.length >= MAX_COMPANY_SCOPE_RULES ? `每个企业最多 ${MAX_COMPANY_SCOPE_RULES} 条范围` : undefined}
onClick={() => onValueChange([...value, editableScopeRule()])}
>
<PlusIcon data-icon="inline-start" />
添加范围
</Button>
</div>
<Field data-invalid={parsed.errors.length > 0}>
<FieldLabel htmlFor={id}>资产范围</FieldLabel>
<FieldDescription>每行一条,自动识别域名、IP、CIDR、ICP 备案和企业关键词。</FieldDescription>
<Textarea
id={id}
rows={8}
value={value}
aria-invalid={parsed.errors.length > 0}
placeholder={"example.com\n203.0.113.10\n198.51.100.0/24\n京ICP备12345678号-1\n企业名称关键词"}
className="min-h-36 resize-y font-mono text-sm"
onChange={(event) => onValueChange(event.target.value)}
/>
{parsed.rules.length > 0 && (
<div className="flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
<span>已识别 {parsed.rules.length} 条</span>
{Object.entries(COMPANY_SCOPE_KIND_LABELS).map(([kind, label]) => {
const count = counts.get(kind as CompanyScopeKind) ?? 0;
return count > 0 ? (
<Badge key={kind} variant="secondary" className="font-mono tabular-nums">
{label} {count}
</Badge>
) : null;
})}
</div>
)}
{parsed.errors.length > 0 && (
<FieldError>
{parsed.errors.slice(0, 5).map((item) => (
<span key={`${item.line}-${item.error}`} className="block">
第 {item.line} 行:{item.error}
</span>
))}
{parsed.errors.length > 5 && <span className="block">另有 {parsed.errors.length - 5} 行错误</span>}
</FieldError>
)}
</Field>
);
}
function savedScopeRules(company: Company): EditableScopeRule[] {
const rules = (company.scope ?? []).map((scope) => editableScopeRule(scope.kind, scope.raw));
return rules.length > 0 ? rules : [editableScopeRule()];
function savedScopeRules(company: Company): CompanyScopeRule[] {
return (company.scope ?? [])
.map((scope) => ({ kind: scope.kind, value: scope.raw.trim() }))
.filter((scope) => scope.value);
}
function savedScopeText(company: Company): string {
return savedScopeRules(company)
.map((scope) => scope.value)
.join("\n");
}
// 新增企业使用与任务、LLM 编辑一致的右侧抽屉。
function CompanyDialog({ onSaved }: { onSaved: () => void }) {
const [open, setOpen] = React.useState(false);
const [name, setName] = React.useState("");
const [scope, setScope] = React.useState<EditableScopeRule[]>(() => [editableScopeRule()]);
const [scopeText, setScopeText] = React.useState("");
const [busy, setBusy] = React.useState(false);
const parsedScope = React.useMemo(() => parseCompanyScopeText(scopeText), [scopeText]);
React.useEffect(() => {
if (!open) return;
setName("");
setScope([editableScopeRule()]);
setScopeText("");
}, [open]);
const submit = async () => {
@@ -1491,16 +1416,13 @@ function CompanyDialog({ onSaved }: { onSaved: () => void }) {
toast.error("请填写企业名称");
return;
}
const rules: CompanyScopeRule[] = scope
.filter((rule) => rule.value.trim())
.map((rule) => ({ kind: rule.kind, value: rule.value.trim() }));
if (rules.some(scopeRuleError)) {
if (parsedScope.errors.length > 0) {
toast.error("请修正无效的资产范围");
return;
}
setBusy(true);
try {
const res = await api.createCompany(name.trim(), rules);
const res = await api.createCompany(name.trim(), parsedScope.rules);
const added = res.scope_added ?? 0;
const invalid = res.scope_invalid ?? 0;
if (invalid > 0) toast.warning(`已创建企业,添加 ${added} 条范围;${invalid} 行无效`);
@@ -1539,18 +1461,14 @@ function CompanyDialog({ onSaved }: { onSaved: () => void }) {
onChange={(e) => setName(e.target.value)}
/>
</Field>
<Field>
<FieldLabel>资产范围</FieldLabel>
<FieldDescription>支持域名、IP、CIDR、ICP 备案和企业名称关键词。</FieldDescription>
<ScopeRulesEditor value={scope} onValueChange={setScope} />
</Field>
<ScopeTextEditor id="cn-scope" value={scopeText} onValueChange={setScopeText} parsed={parsedScope} />
</FieldGroup>
</div>
<SheetFooter className="flex-row justify-end gap-2 border-t p-4">
<Button variant="outline" onClick={() => setOpen(false)} disabled={busy}>
取消
</Button>
<Button onClick={submit} disabled={busy || !name.trim()}>
<Button onClick={submit} disabled={busy || !name.trim() || parsedScope.errors.length > 0}>
{busy ? "保存中…" : "保存"}
</Button>
</SheetFooter>
@@ -1562,27 +1480,29 @@ function CompanyDialog({ onSaved }: { onSaved: () => void }) {
// 编辑(覆盖)资产范围弹窗
function EditScopeDialog({ company, onSaved }: { company: Company; onSaved: () => void }) {
const [open, setOpen] = React.useState(false);
const [scope, setScope] = React.useState<EditableScopeRule[]>(() => [editableScopeRule()]);
const [scopeText, setScopeText] = React.useState("");
const [reason, setReason] = React.useState("");
const [busy, setBusy] = React.useState(false);
const preservedScopeRules = React.useMemo(() => savedScopeRules(company), [company]);
const parsedScope = React.useMemo(
() => parseCompanyScopeText(scopeText, { preservedRules: preservedScopeRules }),
[preservedScopeRules, scopeText],
);
React.useEffect(() => {
if (!open) return;
setScope(savedScopeRules(company));
setScopeText(savedScopeText(company));
setReason("");
}, [open, company]);
const submit = async () => {
const rules: CompanyScopeRule[] = scope
.filter((rule) => rule.value.trim())
.map((rule) => ({ kind: rule.kind, value: rule.value.trim() }));
if (rules.some(scopeRuleError)) {
if (parsedScope.errors.length > 0) {
toast.error("请修正无效的资产范围");
return;
}
setBusy(true);
try {
const res = await api.updateCompanyScope(company.id, rules, reason);
const res = await api.updateCompanyScope(company.id, parsedScope.rules, reason);
const errCount = res.invalid ?? 0;
if (errCount > 0) toast.warning(`已保存;${errCount} 行无效`);
else toast.success(`范围已更新,共 ${res.added} 条`);
@@ -1609,23 +1529,28 @@ function EditScopeDialog({ company, onSaved }: { company: Company; onSaved: () =
编辑后将替换全部现有范围。ICP 精确匹配资产,企业关键词仅作为 Agent 提示。
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2">
<ScopeRulesEditor value={scope} onValueChange={setScope} />
<div className="grid gap-1.5">
<Label htmlFor="es-reason">归属依据(可选)</Label>
<FieldGroup className="py-2">
<ScopeTextEditor
id={`edit-company-scope-${company.id}`}
value={scopeText}
onValueChange={setScopeText}
parsed={parsedScope}
/>
<Field>
<FieldLabel htmlFor="es-reason">归属依据(可选)</FieldLabel>
<Input
id="es-reason"
placeholder="如 证书 / whois / ASN 佐证"
value={reason}
onChange={(e) => setReason(e.target.value)}
/>
</div>
</div>
</Field>
</FieldGroup>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)} disabled={busy}>
取消
</Button>
<Button onClick={submit} disabled={busy}>
<Button onClick={submit} disabled={busy || parsedScope.errors.length > 0}>
{busy ? "保存中…" : "覆盖保存"}
</Button>
</DialogFooter>
@@ -1637,31 +1562,33 @@ function EditScopeDialog({ company, onSaved }: { company: Company; onSaved: () =
// 追加资产范围弹窗
function AppendScopeDialog({ company, onSaved }: { company: Company; onSaved: () => void }) {
const [open, setOpen] = React.useState(false);
const [scope, setScope] = React.useState<EditableScopeRule[]>(() => [editableScopeRule()]);
const [scopeText, setScopeText] = React.useState("");
const [reason, setReason] = React.useState("");
const [busy, setBusy] = React.useState(false);
const remainingScopeRules = Math.max(0, MAX_COMPANY_SCOPE_RULES - (company.scope?.length || 0));
const parsedScope = React.useMemo(
() => parseCompanyScopeText(scopeText, { maxRules: remainingScopeRules }),
[remainingScopeRules, scopeText],
);
React.useEffect(() => {
if (!open) return;
setScope([editableScopeRule()]);
setScopeText("");
setReason("");
}, [open]);
const submit = async () => {
const rules: CompanyScopeRule[] = scope
.filter((rule) => rule.value.trim())
.map((rule) => ({ kind: rule.kind, value: rule.value.trim() }));
if (rules.length === 0) {
if (parsedScope.rules.length === 0) {
toast.error("请填写要追加的范围");
return;
}
if (rules.some(scopeRuleError)) {
if (parsedScope.errors.length > 0) {
toast.error("请修正无效的资产范围");
return;
}
setBusy(true);
try {
const res = await api.addCompanyScope(company.id, rules, reason);
const res = await api.addCompanyScope(company.id, parsedScope.rules, reason);
const errCount = res.invalid ?? 0;
if (errCount > 0) toast.warning(`已保存;${errCount} 行无效`);
else toast.success(`已追加 ${res.added} 条范围`);
@@ -1686,23 +1613,28 @@ function AppendScopeDialog({ company, onSaved }: { company: Company; onSaved: ()
<DialogTitle>追加资产范围 · {company.name}</DialogTitle>
<DialogDescription>新范围会追加到现有范围。ICP 精确匹配资产,企业关键词仅作为 Agent 提示。</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2">
<ScopeRulesEditor value={scope} onValueChange={setScope} />
<div className="grid gap-1.5">
<Label htmlFor="as-reason">归属依据(可选)</Label>
<FieldGroup className="py-2">
<ScopeTextEditor
id={`append-company-scope-${company.id}`}
value={scopeText}
onValueChange={setScopeText}
parsed={parsedScope}
/>
<Field>
<FieldLabel htmlFor="as-reason">归属依据(可选)</FieldLabel>
<Input
id="as-reason"
placeholder="如 证书 / whois / ASN 佐证"
value={reason}
onChange={(e) => setReason(e.target.value)}
/>
</div>
</div>
</Field>
</FieldGroup>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)} disabled={busy}>
取消
</Button>
<Button onClick={submit} disabled={busy}>
<Button onClick={submit} disabled={busy || parsedScope.rules.length === 0 || parsedScope.errors.length > 0}>
{busy ? "保存中…" : "追加"}
</Button>
</DialogFooter>
+87 -101
View File
@@ -1,37 +1,19 @@
"use client";
import * as React from "react";
import {
TerminalIcon,
SearchIcon,
ChevronLeftIcon,
ChevronRightIcon,
Loader2Icon,
XIcon,
} from "lucide-react";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { ChevronLeftIcon, ChevronRightIcon, Loader2Icon, SearchIcon, TerminalIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { api } from "@/lib/api";
import type { CommandRecord } from "@/lib/types";
import { cn } from "@/lib/utils";
function fmtTime(ts: string) {
return new Date(ts).toLocaleString("zh-CN", {
@@ -50,7 +32,9 @@ function toolInput(raw: string): string {
const obj = JSON.parse(raw);
if (obj && typeof obj.command === "string") return obj.command;
return JSON.stringify(obj, null, 2);
} catch { /* not JSON */ }
} catch {
/* not JSON */
}
return raw;
}
@@ -75,7 +59,7 @@ export default function CommandsPage() {
const [total, setTotal] = React.useState(0);
const [loading, setLoading] = React.useState(false);
// Inline detail panel (like traffic page, not a dialog)
// Selected execution is rendered in a right-side detail sheet.
const [selected, setSelected] = React.useState<CommandRecord | null>(null);
// Debounce search input.
@@ -85,6 +69,7 @@ export default function CommandsPage() {
}, [query]);
// Reset page on filter change.
// biome-ignore lint/correctness/useExhaustiveDependencies: every filter change intentionally resets pagination.
React.useEffect(() => {
setPage(0);
}, [queryQ, taskFilter, size]);
@@ -100,9 +85,15 @@ export default function CommandsPage() {
setCommands(r.commands ?? []);
setTotal(r.total ?? 0);
})
.catch(() => {})
.catch(() => {
if (!alive) return;
setCommands([]);
setTotal(0);
})
.finally(() => alive && setLoading(false));
return () => { alive = false; };
return () => {
alive = false;
};
}, [page, size, queryQ, taskFilter]);
const totalPages = Math.max(1, Math.ceil(total / size));
@@ -178,8 +169,8 @@ export default function CommandsPage() {
</div>
</div>
{/* History table + inline detail (Burp-style split) */}
<div className="flex h-[calc(100vh-13rem)] min-h-0 flex-col gap-3">
{/* History table */}
<div className="flex h-[calc(100vh-13rem)] min-h-0 flex-col">
<Card className="flex min-h-0 flex-1 flex-col overflow-hidden py-0">
<div className="min-h-0 flex-1 overflow-auto">
<Table>
@@ -210,18 +201,13 @@ export default function CommandsPage() {
commands.map((cmd) => (
<TableRow
key={cmd.id}
className={cn(
"cursor-pointer",
selected?.id === cmd.id && "bg-accent hover:bg-accent",
)}
className={cn("cursor-pointer", selected?.id === cmd.id && "bg-accent hover:bg-accent")}
onClick={() => setSelected(cmd)}
>
<TableCell className="text-xs text-muted-foreground tabular-nums">
{fmtTime(cmd.created_at)}
</TableCell>
<TableCell className="text-xs font-mono text-muted-foreground">
#{cmd.exploration_id}
</TableCell>
<TableCell className="text-xs font-mono text-muted-foreground">#{cmd.exploration_id}</TableCell>
<TableCell>
<Badge variant="outline" className="text-xs font-mono">
{cmd.worker || "-"}
@@ -239,9 +225,13 @@ export default function CommandsPage() {
</TableCell>
<TableCell>
{cmd.is_error ? (
<Badge variant="destructive" className="text-xs">失败</Badge>
<Badge variant="destructive" className="text-xs">
失败
</Badge>
) : (
<Badge variant="secondary" className="text-xs text-emerald-600">成功</Badge>
<Badge variant="secondary" className="text-xs text-emerald-600">
成功
</Badge>
)}
</TableCell>
</TableRow>
@@ -251,67 +241,63 @@ export default function CommandsPage() {
</Table>
</div>
</Card>
{/* Inline detail panel */}
{selected && (
<Card className="flex h-[42%] min-h-0 flex-col overflow-hidden py-0">
{/* Detail header */}
<div className="flex items-center gap-2 border-b px-3 py-2">
<Badge variant="outline" className="text-xs font-mono">
#{selected.exploration_id}
</Badge>
<Badge variant="outline" className="text-xs font-mono">
{selected.worker || "-"}
</Badge>
<Badge variant="secondary" className="text-xs font-mono">
{selected.tool || "-"}
</Badge>
<span className="text-xs text-muted-foreground">
{fmtTime(selected.created_at)}
</span>
{selected.is_error ? (
<Badge variant="destructive" className="text-xs">失败</Badge>
) : (
<Badge variant="secondary" className="text-xs text-emerald-600">成功</Badge>
)}
<Button
variant="ghost"
size="icon"
className="ml-auto size-7 shrink-0"
onClick={() => setSelected(null)}
>
<XIcon />
</Button>
</div>
{/* Input / Output split */}
<div className="grid min-h-0 flex-1 grid-cols-2 divide-x">
<div className="flex min-h-0 min-w-0 flex-col">
<div className="border-b px-3 py-1 text-[11px] font-medium text-muted-foreground">
输入 Input
</div>
<div className="min-h-0 flex-1 overflow-auto">
<pre className="p-3 font-mono text-xs break-all whitespace-pre-wrap">
{toolInput(selected.command)}
</pre>
</div>
</div>
<div className="flex min-h-0 min-w-0 flex-col">
<div className="border-b px-3 py-1 text-[11px] font-medium text-muted-foreground">
输出 Output
</div>
<div className="min-h-0 flex-1 overflow-auto">
<pre className={cn(
"p-3 font-mono text-xs break-all whitespace-pre-wrap",
selected.is_error && "text-red-600 dark:text-red-400",
)}>
{selected.output || "(空)"}
</pre>
</div>
</div>
</div>
</Card>
)}
</div>
<Sheet open={selected !== null} onOpenChange={(open) => !open && setSelected(null)}>
<SheetContent className="w-full! max-w-none! gap-0 p-0 sm:w-[46rem]! sm:max-w-[46rem]!">
{selected && (
<>
<SheetHeader className="border-b px-5 py-4">
<SheetTitle className="pr-8">工具执行详情</SheetTitle>
<SheetDescription>{fmtTime(selected.created_at)}</SheetDescription>
<div className="flex flex-wrap items-center gap-2 pt-2">
<Badge variant="outline" className="text-xs font-mono">
#{selected.exploration_id}
</Badge>
<Badge variant="outline" className="text-xs font-mono">
{selected.worker || "-"}
</Badge>
<Badge variant="secondary" className="text-xs font-mono">
{selected.tool || "-"}
</Badge>
{selected.is_error ? (
<Badge variant="destructive" className="text-xs">
失败
</Badge>
) : (
<Badge variant="secondary" className="text-xs text-emerald-600">
成功
</Badge>
)}
</div>
</SheetHeader>
<div className="grid min-h-0 flex-1 grid-rows-2 divide-y">
<div className="flex min-h-0 min-w-0 flex-col">
<div className="border-b px-5 py-2 text-xs font-medium text-muted-foreground">输入 Input</div>
<div className="min-h-0 flex-1 overflow-auto">
<pre className="p-5 font-mono text-xs break-all whitespace-pre-wrap">
{toolInput(selected.command)}
</pre>
</div>
</div>
<div className="flex min-h-0 min-w-0 flex-col">
<div className="border-b px-5 py-2 text-xs font-medium text-muted-foreground">输出 Output</div>
<div className="min-h-0 flex-1 overflow-auto">
<pre
className={cn(
"p-5 font-mono text-xs break-all whitespace-pre-wrap",
selected.is_error && "text-red-600 dark:text-red-400",
)}
>
{selected.output || "(空)"}
</pre>
</div>
</div>
</div>
</>
)}
</SheetContent>
</Sheet>
</div>
);
}
+29 -21
View File
@@ -6,12 +6,16 @@ import Link from "next/link";
import {
ArrowUpRightIcon,
BugIcon,
ChevronRightIcon,
ClockIcon,
DownloadIcon,
FileTextIcon,
FlaskConicalIcon,
InfoIcon,
ShieldAlertIcon,
Trash2Icon,
TriangleAlertIcon,
} from "lucide-react";
import { toast } from "sonner";
@@ -30,7 +34,6 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
@@ -529,13 +532,13 @@ export default function FindingsPage() {
}
}
const statCards: { label: string; value: number; tone?: string }[] = [
{ label: "发现总数", value: stats.total },
{ label: "待处理", value: stats.pending, tone: "text-amber-500" },
{ label: "严重", value: stats.critical, tone: "text-rose-600" },
{ label: "高危", value: stats.high, tone: "text-red-500" },
{ label: "中危", value: stats.medium, tone: "text-amber-500" },
{ label: "低危", value: stats.low, tone: "text-slate-500" },
const statCards = [
{ label: "发现总数", value: stats.total, icon: BugIcon },
{ label: "待处理", value: stats.pending, tone: "text-amber-500", icon: ClockIcon },
{ label: "严重", value: stats.critical, tone: "text-rose-600", icon: ShieldAlertIcon },
{ label: "高危", value: stats.high, tone: "text-red-500", icon: TriangleAlertIcon },
{ label: "中危", value: stats.medium, tone: "text-amber-500", icon: TriangleAlertIcon },
{ label: "低危", value: stats.low, tone: "text-slate-500", icon: InfoIcon },
];
return (
@@ -546,14 +549,20 @@ export default function FindingsPage() {
</div>
<div className="flex flex-1 flex-col gap-4 md:gap-6">
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6">
{statCards.map((s) => (
<Card key={s.label} className="gap-1 py-4">
<CardHeader className="px-4">
<CardDescription>{s.label}</CardDescription>
<CardTitle className={cn("text-2xl tabular-nums", s.tone)}>{s.value}</CardTitle>
</CardHeader>
</Card>
))}
{statCards.map((stat) => {
const StatIcon = stat.icon;
return (
<Card key={stat.label} className="gap-1 py-4">
<CardHeader className="px-4">
<CardDescription>{stat.label}</CardDescription>
<CardTitle className={cn("flex items-center gap-2 text-2xl tabular-nums", stat.tone)}>
<StatIcon className="size-5" aria-hidden="true" />
{stat.value}
</CardTitle>
</CardHeader>
</Card>
);
})}
</div>
<div className="flex flex-wrap items-center gap-2">
@@ -624,7 +633,10 @@ export default function FindingsPage() {
<span className="max-w-[14rem] truncate" title={label}>
{label}
</span>
<span className="text-muted-foreground tabular-nums">{t.count}</span>
<span className="inline-flex items-center gap-1 text-muted-foreground tabular-nums">
<BugIcon className="size-3.5" aria-hidden="true" />
{t.count}
</span>
</span>
</SelectItem>
);
@@ -646,7 +658,6 @@ export default function FindingsPage() {
{selectedIds.size > 0 && (
<span className="text-xs text-muted-foreground tabular-nums">已选 {selectedIds.size} 条</span>
)}
<span className="text-xs text-muted-foreground tabular-nums">共 {total} 条</span>
<Button size="sm" variant="outline" onClick={openExport}>
<DownloadIcon /> 导出
</Button>
@@ -712,9 +723,6 @@ export default function FindingsPage() {
</span>
);
})}
<Badge variant="secondary" className="tabular-nums">
共 {group.count} 条
</Badge>
<span className="text-xs tabular-nums text-muted-foreground">{fmtTime(group.last_found_at)}</span>
{group.task_id !== null && (
<Button size="icon-sm" variant="ghost" asChild>
@@ -3,6 +3,7 @@
import * as React from "react";
import {
ArrowUpIcon,
BrainIcon,
CircleCheckIcon,
CircleSlashIcon,
@@ -15,7 +16,6 @@ import {
PlayIcon,
RadioIcon,
RotateCwIcon,
SendIcon,
ShieldAlertIcon,
SquareIcon,
Trash2Icon,
@@ -40,7 +40,6 @@ import {
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupTextarea } from "@/components/ui/input-group";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
@@ -48,7 +47,6 @@ import { api, sseUrl } from "@/lib/api";
import { MOCK } from "@/lib/mock/enabled";
import type {
Activity,
BatchControlItem,
ChatAttachment,
InterceptApprovalRow,
Session,
@@ -163,6 +161,36 @@ function fmtTokens(n: number): string {
return String(n);
}
const TokenMetrics = React.forwardRef<
HTMLSpanElement,
React.ComponentPropsWithoutRef<"span"> & {
input: number;
cache: number;
output: number;
labels?: "short" | "long";
}
>(({ input, cache, output, labels = "short", className, ...props }, ref) => {
const names = labels === "short" ? ["入", "缓", "出"] : ["input", "cache", "output"];
const values = [input, cache, output];
return (
<span
ref={ref}
className={cn("inline-flex min-w-0 flex-wrap items-center gap-1 text-muted-foreground", className)}
{...props}
>
{values.map((value, index) => (
<React.Fragment key={names[index]}>
<span>{names[index]}</span>
<Badge variant="secondary" className="h-5 px-1.5 font-mono tabular-nums">
{fmtTokens(value)}
</Badge>
</React.Fragment>
))}
</span>
);
});
TokenMetrics.displayName = "TokenMetrics";
// fmtDuration renders an elapsed milliseconds span compactly (90s → 1m30s).
function fmtDuration(ms: number): string {
const s = Math.floor(ms / 1000);
@@ -277,12 +305,6 @@ function SessionItem({
onResume,
onCancel,
controlling,
resolution,
tokens,
selectable,
selected,
onSelectedChange,
selectionDisabled,
}: {
s: Session;
active: boolean;
@@ -294,16 +316,7 @@ function SessionItem({
onResume?: () => void;
onCancel?: () => void;
controlling?: boolean;
resolution?: TaskLLMResolution;
tokens?: TokenTotal;
selectable?: boolean;
selected?: boolean;
onSelectedChange?: (checked: boolean) => void;
selectionDisabled?: boolean;
}) {
const tokenCount = tokens
? tokens.input_tokens + tokens.output_tokens + tokens.cache_read_tokens + tokens.cache_write_tokens
: 0;
const icon =
s.role === "worker" ? (
statusIcon(s.status)
@@ -319,16 +332,6 @@ function SessionItem({
active ? "bg-accent text-accent-foreground" : "hover:bg-accent/50",
)}
>
{selectable && (
<Checkbox
checked={selected}
onCheckedChange={(value) => onSelectedChange?.(value === true)}
onClick={(event) => event.stopPropagation()}
disabled={selectionDisabled}
className="ml-1 shrink-0"
aria-label={`选择 Worker ${s.intent_id}`}
/>
)}
<button
type="button"
onClick={onClick}
@@ -345,32 +348,7 @@ function SessionItem({
来源 #{s.source_task_id}
</Badge>
)}
<span className="min-w-0 flex-1 truncate">{displayTitle}</span>
{resolution && (
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="outline" className="min-w-0 max-w-24 shrink-0">
<span className="min-w-0 truncate">{resolution.available ? resolution.model : "模型不可用"}</span>
</Badge>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-xs [overflow-wrap:anywhere]">
{resolution.available
? `${resolution.name} / ${resolution.format} / ${resolution.model}`
: resolution.reason || "没有可用的 LLM 配置"}
</TooltipContent>
</Tooltip>
)}
{tokens && tokenCount > 0 && s.role === "worker" && !s.inherited && (
<Tooltip>
<TooltipTrigger asChild>
<Badge className="shrink-0 font-mono tabular-nums">{fmtTokens(tokenCount)}</Badge>
</TooltipTrigger>
<TooltipContent side="right">
输入 {tokens.input_tokens.toLocaleString()} · 输出 {tokens.output_tokens.toLocaleString()} · 缓存读取{" "}
{tokens.cache_read_tokens.toLocaleString()} · 缓存写入 {tokens.cache_write_tokens.toLocaleString()}
</TooltipContent>
</Tooltip>
)}
<span className="min-w-0 flex-1 truncate text-sm font-medium">{displayTitle}</span>
{hasPending && <ShieldAlertIcon className="size-3.5 shrink-0 text-amber-500" />}
{!active && unread ? (
<span className="inline-flex min-w-4 items-center justify-center rounded-full bg-blue-500/15 px-1 text-[10px] font-medium tabular-nums text-blue-600 dark:text-blue-400">
@@ -446,8 +424,6 @@ export function SessionsTab({ taskId }: { taskId: string }) {
const [mainChatRunning, setMainChatRunning] = React.useState<boolean | null>(null);
const [controllingIntent, setControllingIntent] = React.useState<string | null>(null);
const [cancelIntent, setCancelIntent] = React.useState<Session | null>(null);
const [selectedIntentIds, setSelectedIntentIds] = React.useState<Set<string>>(() => new Set());
const [batchControlling, setBatchControlling] = React.useState<"pause" | "resume" | null>(null);
// 方式1 文件上传:选好的附件(已落到任务工作目录 uploads/),随下条消息一起发。
const [attachments, setAttachments] = React.useState<ChatAttachment[]>([]);
const [uploading, setUploading] = React.useState(false);
@@ -478,7 +454,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
const controlWorker = React.useCallback(
async (session: Session, action: "pause" | "resume" | "cancel") => {
if (!session.intent_id || session.inherited || controllingIntent || batchControlling) return;
if (!session.intent_id || session.inherited || controllingIntent) return;
setControllingIntent(session.intent_id);
try {
const result = await api.controlIntent(taskId, session.intent_id, action);
@@ -505,7 +481,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
setCancelIntent(null);
}
},
[activeId, batchControlling, controllingIntent, patchIntentState, taskId],
[activeId, controllingIntent, patchIntentState, taskId],
);
// SSE connection state — surfaced so a dropped realtime link is visible, never
// silently shown as "no messages".
@@ -758,7 +734,6 @@ export function SessionsTab({ taskId }: { taskId: string }) {
setFirstIntentsHasMore(false);
setOlderIntentsHasMore(false);
setHasLoadedOlderIntentsPage(false);
setSelectedIntentIds(new Set());
setTaskTokens(null);
setSessionTokens({});
setLLMResolutions(null);
@@ -978,77 +953,6 @@ export function SessionsTab({ taskId }: { taskId: string }) {
const workerSessions = React.useMemo(() => allIntents.map(intentToSession), [allIntents]);
const intentsHasMore = hasLoadedOlderIntentsPage ? olderIntentsHasMore : firstIntentsHasMore;
const selectableIntentIds = React.useMemo(
() =>
allIntents
.filter((node) => !node.inherited && (node.state === "running" || node.state === "paused"))
.map((node) => node.id),
[allIntents],
);
React.useEffect(() => {
const allowed = new Set(selectableIntentIds);
setSelectedIntentIds((current) => {
const next = new Set([...current].filter((id) => allowed.has(id)));
return next.size === current.size ? current : next;
});
}, [selectableIntentIds]);
const toggleIntentSelection = React.useCallback((id: string, checked: boolean) => {
setSelectedIntentIds((current) => {
const next = new Set(current);
if (checked) next.add(id);
else next.delete(id);
return next;
});
}, []);
const batchControlWorkers = React.useCallback(
async (action: "pause" | "resume") => {
const requiredState = action === "pause" ? "running" : "paused";
const ids = allIntents
.filter((node) => selectedIntentIds.has(node.id) && !node.inherited && node.state === requiredState)
.map((node) => node.id);
if (ids.length === 0 || batchControlling) return;
setBatchControlling(action);
try {
const items: BatchControlItem[] = [];
const requestErrors: Error[] = [];
for (let offset = 0; offset < ids.length; offset += 100) {
const chunk = ids.slice(offset, offset + 100);
try {
const response = await api.controlIntentsBatch(taskId, chunk, action);
items.push(...response.items);
} catch (error) {
requestErrors.push(error as Error);
items.push(...chunk.map((id) => ({ id, ok: false, error: (error as Error).message })));
}
}
const succeeded = items.filter((item) => item.ok);
const failed = items.length - succeeded.length;
for (const item of succeeded) {
patchIntentState(item.id, item.state ?? (action === "pause" ? "paused" : "open"));
}
setSelectedIntentIds((current) => {
const next = new Set(current);
for (const item of succeeded) next.delete(item.id);
return next;
});
if (succeeded.length > 0)
toast.success(`已${action === "pause" ? "暂停" : "继续"} ${succeeded.length} 个 Worker`);
if (requestErrors.length > 0) {
toast.error(`${requestErrors.length} 组批量请求失败,${failed} 个 Worker 未执行`);
} else if (failed > 0) {
toast.warning(`${failed} 个 Worker 状态已变化,未执行`);
}
} catch (error) {
toast.error(`批量操作失败:${(error as Error).message}`);
} finally {
setBatchControlling(null);
}
},
[allIntents, batchControlling, patchIntentState, selectedIntentIds, taskId],
);
// For each worker session (intent), derive the display title from the intent
// payload summary. Also store the full TaskNode for the hover-JSON tooltip.
const sessionMeta = React.useMemo(() => {
@@ -1217,7 +1121,6 @@ export function SessionsTab({ taskId }: { taskId: string }) {
i: activeTokens.input_tokens,
o: activeTokens.output_tokens,
cr: activeTokens.cache_read_tokens,
cw: activeTokens.cache_write_tokens,
any:
activeTokens.input_tokens +
activeTokens.output_tokens +
@@ -1331,25 +1234,13 @@ export function SessionsTab({ taskId }: { taskId: string }) {
const mainLoaded = !!store.main?.loaded;
// What the transcript pane should show for the active session.
const showLoader = !activeState || (activeState.loading && !activeState.loaded);
const selectedPauseCount = allIntents.filter(
(node) => selectedIntentIds.has(node.id) && !node.inherited && node.state === "running",
).length;
const selectedResumeCount = allIntents.filter(
(node) => selectedIntentIds.has(node.id) && !node.inherited && node.state === "paused",
).length;
const selectedSelectableCount = selectableIntentIds.filter((id) => selectedIntentIds.has(id)).length;
let allSelectableChecked: boolean | "indeterminate" = false;
if (selectableIntentIds.length > 0 && selectedSelectableCount === selectableIntentIds.length) {
allSelectableChecked = true;
} else if (selectedSelectableCount > 0) {
allSelectableChecked = "indeterminate";
}
const resolutionForSession = (session: Session): TaskLLMResolution | undefined => {
if (!llmResolutions || session.inherited || session.role === "system") return undefined;
if (session.role === "mainagent") return llmResolutions.mainagent;
if (session.role === "planner") return llmResolutions.planner;
return llmResolutions.worker;
};
const activeResolution = resolutionForSession(active);
return (
<TooltipProvider delayDuration={300}>
@@ -1384,10 +1275,15 @@ export function SessionsTab({ taskId }: { taskId: string }) {
{taskTokens && (
<Tooltip>
<TooltipTrigger asChild>
<Badge className="mt-2 w-full min-w-0 justify-start font-mono tabular-nums">
任务总计 · 入 {fmtTokens(taskTokens.input_tokens)} · 缓 {fmtTokens(taskTokens.cache_read_tokens)} ·
出 {fmtTokens(taskTokens.output_tokens)}
</Badge>
<div className="mt-2 flex min-w-0 flex-wrap items-center gap-1 text-xs text-muted-foreground">
<span>任务总计</span>
<span>·</span>
<TokenMetrics
input={taskTokens.input_tokens}
cache={taskTokens.cache_read_tokens}
output={taskTokens.output_tokens}
/>
</div>
</TooltipTrigger>
<TooltipContent>
输入 {taskTokens.input_tokens.toLocaleString()} · 输出 {taskTokens.output_tokens.toLocaleString()} ·
@@ -1396,35 +1292,6 @@ export function SessionsTab({ taskId }: { taskId: string }) {
</TooltipContent>
</Tooltip>
)}
{selectedIntentIds.size > 0 && (
<div className="mt-2 flex items-center gap-1">
<span className="min-w-0 flex-1 text-xs text-muted-foreground">
已选 {selectedIntentIds.size} 个 Worker
</span>
<Button
type="button"
variant="outline"
size="icon-xs"
disabled={selectedPauseCount === 0 || batchControlling !== null}
onClick={() => void batchControlWorkers("pause")}
title={`暂停 ${selectedPauseCount} 个运行中 Worker`}
aria-label="批量暂停 Worker"
>
{batchControlling === "pause" ? <Loader2Icon className="animate-spin" /> : <PauseIcon />}
</Button>
<Button
type="button"
variant="outline"
size="icon-xs"
disabled={selectedResumeCount === 0 || batchControlling !== null}
onClick={() => void batchControlWorkers("resume")}
title={`继续 ${selectedResumeCount} 个已暂停 Worker`}
aria-label="批量继续 Worker"
>
{batchControlling === "resume" ? <Loader2Icon className="animate-spin" /> : <PlayIcon />}
</Button>
</div>
)}
</div>
<ScrollArea type="auto" className="min-h-0 flex-1 [&_[data-slot=scroll-area-viewport]>div]:block!">
<div className="flex w-full flex-col gap-3 p-2">
@@ -1435,23 +1302,6 @@ export function SessionsTab({ taskId }: { taskId: string }) {
return (
<div key={role} className="flex flex-col gap-0.5">
<div className="flex items-center gap-1.5 px-2 py-1 text-xs font-medium text-muted-foreground">
{role === "worker" && selectableIntentIds.length > 0 && (
<Checkbox
checked={allSelectableChecked}
onCheckedChange={(value) => {
const checked = value === true;
setSelectedIntentIds((current) => {
const next = new Set(current);
for (const id of selectableIntentIds) {
if (checked) next.add(id);
else next.delete(id);
}
return next;
});
}}
aria-label="选择全部可控制 Worker"
/>
)}
<Meta.icon className="size-3.5" />
{Meta.label}
</div>
@@ -1466,19 +1316,11 @@ export function SessionsTab({ taskId }: { taskId: string }) {
hasPending={hasPendingForSession(s)}
unread={store[keyForSession(s)]?.unread}
onClick={() => setActiveId(s.id)}
controlling={batchControlling !== null || controllingIntent === s.intent_id}
resolution={resolutionForSession(s)}
tokens={tokenForSession(keyForSession(s))}
selectable={
s.role === "worker" && !s.inherited && (s.status === "running" || s.status === "paused")
}
selected={selectedIntentIds.has(s.intent_id ?? "")}
selectionDisabled={batchControlling !== null}
onSelectedChange={(checked) => s.intent_id && toggleIntentSelection(s.intent_id, checked)}
controlling={controllingIntent === s.intent_id}
onPause={() => void controlWorker(s, "pause")}
onResume={() => void controlWorker(s, "resume")}
onCancel={() => {
if (!batchControlling) setCancelIntent(s);
setCancelIntent(s);
}}
/>
);
@@ -1529,6 +1371,26 @@ export function SessionsTab({ taskId }: { taskId: string }) {
titleEl
);
})()}
{activeResolution && (
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="outline"
className="max-w-28 shrink-0 font-mono font-normal"
aria-label={activeResolution.available ? `当前模型:${activeResolution.model}` : "模型不可用"}
>
<span className="truncate">
{activeResolution.available ? activeResolution.model : "模型不可用"}
</span>
</Badge>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs [overflow-wrap:anywhere]">
{activeResolution.available
? `${activeResolution.name} / ${activeResolution.format} / ${activeResolution.model}`
: activeResolution.reason || "没有可用的 LLM 配置"}
</TooltipContent>
</Tooltip>
)}
{active.inherited && active.source_task_id && (
<Badge variant="outline">来源任务 #{active.source_task_id} · 只读历史</Badge>
)}
@@ -1545,9 +1407,17 @@ export function SessionsTab({ taskId }: { taskId: string }) {
)}
<div className="ml-auto flex min-w-0 max-w-full items-center justify-end gap-x-3 gap-y-1 text-xs text-muted-foreground max-sm:w-full max-sm:flex-wrap">
{tokenTotal.any && (
<span className="min-w-0 truncate" title="input / cache(read) / output tokens">
input {fmtTokens(tokenTotal.i)} · cache {fmtTokens(tokenTotal.cr)} · output {fmtTokens(tokenTotal.o)}
</span>
<Tooltip>
<TooltipTrigger asChild>
<TokenMetrics input={tokenTotal.i} cache={tokenTotal.cr} output={tokenTotal.o} labels="long" />
</TooltipTrigger>
<TooltipContent>
输入 {activeTokens.input_tokens.toLocaleString()} · 输出{" "}
{activeTokens.output_tokens.toLocaleString()} · 缓存读取{" "}
{activeTokens.cache_read_tokens.toLocaleString()} · 缓存写入{" "}
{activeTokens.cache_write_tokens.toLocaleString()}
</TooltipContent>
</Tooltip>
)}
{runDuration != null && (
<span className="inline-flex items-center gap-1" title="运行时长(首步 → 末步)">
@@ -1673,7 +1543,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
title="发送消息"
aria-label="发送消息"
>
{sending ? <Loader2Icon className="animate-spin" /> : <SendIcon />}
{sending ? <Loader2Icon className="animate-spin" /> : <ArrowUpIcon />}
</InputGroupButton>
)}
</InputGroupAddon>
+244 -165
View File
@@ -1,15 +1,15 @@
"use client";
import * as React from "react";
import {
RadioTowerIcon,
SearchIcon,
ChevronLeftIcon,
ChevronRightIcon,
XIcon,
Loader2Icon,
Trash2Icon,
ListChecksIcon,
Loader2Icon,
RadioTowerIcon,
SearchIcon,
Trash2Icon,
} from "lucide-react";
import {
@@ -22,35 +22,19 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Card } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils";
import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { api } from "@/lib/api";
import type { TrafficExchange, TrafficResp, TrafficDetail, TrafficHost } from "@/lib/types";
import type { TrafficDetail, TrafficExchange, TrafficHost, TrafficResp } from "@/lib/types";
import { cn } from "@/lib/utils";
function fmtTime(ts: string) {
return new Date(ts).toLocaleString("zh-CN", {
@@ -66,7 +50,7 @@ function fmtBytes(n: number) {
if (n <= 0) return "0 B";
const units = ["B", "KB", "MB"];
const i = Math.min(Math.floor(Math.log(n) / Math.log(1024)), units.length - 1);
const v = n / Math.pow(1024, i);
const v = n / 1024 ** i;
return `${i === 0 ? v : v.toFixed(1)} ${units[i]}`;
}
@@ -78,6 +62,141 @@ function statusTone(status: number) {
return "text-muted-foreground";
}
function MethodBadge({ method }: { method: string }) {
return <Badge className="shrink-0 font-mono">{method}</Badge>;
}
// Older captures may predate Host persistence because net/http keeps Host
// outside Request.Header. Fill it for display while newly recorded traffic is
// fixed at the recorder layer as well.
function requestWithHost(raw: string, exchange: TrafficExchange): string {
if (!raw.trim() || /^host\s*:/im.test(raw)) return raw;
let host = exchange.host;
try {
host = new URL(exchange.url).host || host;
} catch {
// Relative or legacy URLs fall back to the indexed host.
}
const newline = raw.includes("\r\n") ? "\r\n" : "\n";
const firstLineEnd = raw.indexOf(newline);
if (firstLineEnd < 0) return `${raw}${newline}Host: ${host}`;
return `${raw.slice(0, firstLineEnd + newline.length)}Host: ${host}${newline}${raw.slice(firstLineEnd + newline.length)}`;
}
function StartLine({ line }: { line: string }) {
const response = /^(HTTP\/\S+)(\s+)(\d{3})(.*)$/.exec(line);
if (response) {
return (
<>
<span className="text-muted-foreground">{response[1]}</span>
{response[2]}
<span className={statusTone(Number(response[3]))}>{response[3]}</span>
{response[4]}
</>
);
}
const request = /^([A-Z]+)(\s+)(\S+)(\s+)(HTTP\/\S+)$/.exec(line);
if (request) {
return (
<>
<span className="font-semibold text-primary">{request[1]}</span>
{request[2]}
<span className="text-chart-2">{request[3]}</span>
{request[4]}
<span className="text-muted-foreground">{request[5]}</span>
</>
);
}
return line;
}
function HeaderLine({ line }: { line: string }) {
const separator = line.indexOf(":");
if (separator <= 0) return line;
return (
<>
<span className="text-primary">{line.slice(0, separator)}</span>
<span className="text-muted-foreground">:</span>
<span className="text-chart-2">{line.slice(separator + 1)}</span>
</>
);
}
function JsonBody({ body }: { body: string }) {
const parts: React.ReactNode[] = [];
const tokens = /("(?:\\.|[^"\\])*")(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/g;
let cursor = 0;
for (const match of body.matchAll(tokens)) {
const index = match.index ?? 0;
if (index > cursor) parts.push(body.slice(cursor, index));
let className = "text-chart-4";
if (match[1]) className = match[2] ? "text-primary" : "text-chart-2";
else if (match[3]) className = "text-chart-3";
parts.push(
<span key={`${index}-${match[0].length}`} className={className}>
{match[0]}
</span>,
);
cursor = index + match[0].length;
}
if (cursor < body.length) parts.push(body.slice(cursor));
return parts;
}
function MarkupBody({ body }: { body: string }) {
const parts: React.ReactNode[] = [];
const tags = /<\/?[A-Za-z][^>]*>|<!--[\s\S]*?-->/g;
let cursor = 0;
for (const match of body.matchAll(tags)) {
const index = match.index ?? 0;
if (index > cursor) parts.push(body.slice(cursor, index));
parts.push(
<span key={`${index}-${match[0].length}`} className="text-primary">
{match[0]}
</span>,
);
cursor = index + match[0].length;
}
if (cursor < body.length) parts.push(body.slice(cursor));
return parts;
}
function HighlightedBody({ body }: { body: string }) {
const trimmed = body.trimStart();
if (trimmed.startsWith("{") || trimmed.startsWith("[")) return <JsonBody body={body} />;
if (trimmed.startsWith("<")) return <MarkupBody body={body} />;
return body;
}
function HttpCodeBlock({ raw }: { raw: string }) {
const value = raw || "(空)";
const lines = value.replaceAll("\r\n", "\n").split("\n");
const separator = lines.indexOf("");
const headerLines = separator >= 0 ? lines.slice(0, separator) : lines;
const body = separator >= 0 ? lines.slice(separator + 1).join("\n") : "";
return (
<pre className="min-w-full whitespace-pre p-5 font-mono text-xs leading-relaxed">
<code>
{headerLines.map((line, index) => (
<React.Fragment key={`${index}-${line}`}>
{index === 0 ? <StartLine line={line} /> : <HeaderLine line={line} />}
{index < headerLines.length - 1 ? "\n" : null}
</React.Fragment>
))}
{separator >= 0 && (
<>
{"\n\n"}
<HighlightedBody body={body} />
</>
)}
</code>
</pre>
);
}
// Fixed method set (server filters exact-match); avoids deriving options from a
// single page, which would only ever list the methods on that page.
const METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"];
@@ -116,12 +235,14 @@ export default function TrafficPage() {
}, [query]);
// Any filter/size change resets to the first page.
// biome-ignore lint/correctness/useExhaustiveDependencies: these values intentionally trigger a page reset.
React.useEffect(() => {
setPage(0);
}, [hostQ, queryQ, method, size]);
// Load the current page. Auto-refresh only on page 0 (newest) so paging back
// through history isn't yanked out from under the user.
// biome-ignore lint/correctness/useExhaustiveDependencies: reloadTick is an explicit manual-refetch trigger.
React.useEffect(() => {
let alive = true;
const load = () => {
@@ -130,13 +251,17 @@ export default function TrafficPage() {
.then((r) => {
if (alive) setTraffic(r);
})
.catch(() => {});
.catch(() => {
// Keep the last successful snapshot during transient refresh failures.
});
api
.trafficHosts()
.then((r) => {
if (alive) setHosts(r.hosts ?? []);
})
.catch(() => {});
.catch(() => {
// Keep the last successful host list during transient refresh failures.
});
};
load();
const t = setInterval(() => {
@@ -155,22 +280,21 @@ export default function TrafficPage() {
const confirmDelete = () => {
setDeleting(true);
const p =
deleteMode === "selected"
? api.trafficDeleteHosts(selectedHosts)
: api.trafficDeleteHost(hostQ);
const p = deleteMode === "selected" ? api.trafficDeleteHosts(selectedHosts) : api.trafficDeleteHost(hostQ);
p.then(() => {
setDeleteMode(null);
setSelected(null);
setDetail(null);
if (deleteMode === "selected") {
setSelectedHosts([]);
setPickerOpen(false);
}
setPage(0);
setReloadTick((t) => t + 1);
setDeleteMode(null);
setSelected(null);
setDetail(null);
if (deleteMode === "selected") {
setSelectedHosts([]);
setPickerOpen(false);
}
setPage(0);
setReloadTick((t) => t + 1);
})
.catch(() => {
// Keep the confirmation open so the user can retry a failed deletion.
})
.catch(() => {})
.finally(() => setDeleting(false));
};
@@ -210,9 +334,7 @@ export default function TrafficPage() {
<div className="flex items-center justify-between gap-4">
<div>
<h1 className="text-xl font-semibold tracking-tight">流量</h1>
<p className="text-muted-foreground text-sm">
全局录制代理 · 所有 HTTP 往来
</p>
<p className="text-muted-foreground text-sm">全局录制代理 · 所有 HTTP 往来</p>
</div>
<div className="flex items-center gap-4 text-sm">
<span
@@ -226,11 +348,7 @@ export default function TrafficPage() {
<RadioTowerIcon className="size-3.5" />
{traffic?.enabled ? "录制中" : "已停用"}
</span>
{traffic?.proxy && (
<span className="font-mono text-xs text-muted-foreground">
{traffic.proxy}
</span>
)}
{traffic?.proxy && <span className="font-mono text-xs text-muted-foreground">{traffic.proxy}</span>}
<span className="text-xs text-muted-foreground">
共 <span className="tabular-nums">{traffic?.count ?? 0}</span> 条
</span>
@@ -266,29 +384,25 @@ export default function TrafficPage() {
</div>
<div className="max-h-64 overflow-y-auto">
{hosts.length === 0 ? (
<div className="px-3 py-6 text-center text-xs text-muted-foreground">
暂无流量记录
</div>
<div className="px-3 py-6 text-center text-xs text-muted-foreground">暂无流量记录</div>
) : (
hosts.map((h) => (
hosts.map((h, index) => (
<label
key={h.host}
htmlFor={`traffic-host-${index}`}
className="flex cursor-pointer items-center gap-2 px-3 py-1.5 text-xs hover:bg-accent"
>
<Checkbox
id={`traffic-host-${index}`}
checked={selectedHosts.includes(h.host)}
onCheckedChange={() =>
setSelectedHosts((prev) =>
prev.includes(h.host)
? prev.filter((x) => x !== h.host)
: [...prev, h.host],
prev.includes(h.host) ? prev.filter((x) => x !== h.host) : [...prev, h.host],
)
}
/>
<span className="truncate font-mono">{h.host}</span>
<span className="ml-auto shrink-0 tabular-nums text-muted-foreground">
{h.count}
</span>
<span className="ml-auto shrink-0 tabular-nums text-muted-foreground">{h.count}</span>
</label>
))
)}
@@ -310,12 +424,7 @@ export default function TrafficPage() {
</PopoverContent>
</Popover>
<div className="relative w-48">
<Input
placeholder="host…"
value={host}
onChange={(e) => setHost(e.target.value)}
className="h-8"
/>
<Input placeholder="host…" value={host} onChange={(e) => setHost(e.target.value)} className="h-8" />
</div>
<Button
variant="destructive"
@@ -391,8 +500,8 @@ export default function TrafficPage() {
</div>
</div>
{/* History table + detail (Burp-style split) */}
<div className="flex h-[calc(100vh-15rem)] min-h-0 flex-col gap-3">
{/* History table */}
<div className="flex h-[calc(100vh-15rem)] min-h-0 flex-col">
<Card className="flex min-h-0 flex-1 flex-col overflow-hidden py-0">
<div className="min-h-0 flex-1 overflow-auto">
<Table>
@@ -411,50 +520,29 @@ export default function TrafficPage() {
{exchanges.map((e) => (
<TableRow
key={e.id}
className={cn(
"cursor-pointer",
selected?.id === e.id && "bg-accent hover:bg-accent",
)}
className={cn("cursor-pointer", selected?.id === e.id && "bg-accent hover:bg-accent")}
onClick={() => setSelected(e)}
>
<TableCell className="text-xs text-muted-foreground tabular-nums">
{fmtTime(e.ts)}
</TableCell>
<TableCell className="text-xs text-muted-foreground tabular-nums">{fmtTime(e.ts)}</TableCell>
<TableCell className="font-mono text-xs">{e.host}</TableCell>
<TableCell>
<Badge variant="outline" className="font-mono text-xs">
{e.method}
</Badge>
<MethodBadge method={e.method} />
</TableCell>
<TableCell className="max-w-0">
<span className="block truncate font-mono text-xs">
{e.url}
</span>
<span className="block truncate font-mono text-xs">{e.url}</span>
</TableCell>
<TableCell>
<span
className={cn(
"font-mono text-xs font-semibold tabular-nums",
statusTone(e.status),
)}
>
<span className={cn("font-mono text-xs font-semibold tabular-nums", statusTone(e.status))}>
{e.status}
</span>
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{e.content_type}
</TableCell>
<TableCell className="text-right text-xs tabular-nums">
{fmtBytes(e.resp_len)}
</TableCell>
<TableCell className="text-xs text-muted-foreground">{e.content_type}</TableCell>
<TableCell className="text-right text-xs tabular-nums">{fmtBytes(e.resp_len)}</TableCell>
</TableRow>
))}
{exchanges.length === 0 && (
<TableRow>
<TableCell
colSpan={7}
className="py-12 text-center text-sm text-muted-foreground"
>
<TableCell colSpan={7} className="py-12 text-center text-sm text-muted-foreground">
{traffic === null ? "加载中…" : "没有匹配的流量。"}
</TableCell>
</TableRow>
@@ -463,66 +551,60 @@ export default function TrafficPage() {
</Table>
</div>
</Card>
{/* Raw request/response pane */}
{selected && (
<Card className="flex h-[42%] min-h-0 flex-col overflow-hidden py-0">
<div className="flex items-center gap-2 border-b px-3 py-2">
<Badge variant="outline" className="font-mono text-xs">
{selected.method}
</Badge>
<span className="truncate font-mono text-xs">
{selected.host}
{selected.url}
</span>
<span
className={cn(
"ml-auto font-mono text-xs font-semibold tabular-nums",
statusTone(selected.status),
)}
>
{selected.status}
</span>
<Button
variant="ghost"
size="icon"
className="size-7 shrink-0"
onClick={() => setSelected(null)}
>
<XIcon />
</Button>
</div>
<div className="grid min-h-0 flex-1 grid-cols-2 divide-x">
{(
[
["请求 Request", detail?.req],
["响应 Response", detail?.resp],
] as const
).map(([label, body]) => (
<div key={label} className="flex min-h-0 min-w-0 flex-col">
<div className="border-b px-3 py-1 text-[11px] font-medium text-muted-foreground">
{label}
</div>
<div className="min-h-0 flex-1 overflow-auto">
{detailLoading ? (
<div className="flex items-center gap-2 p-3 text-xs text-muted-foreground">
<Loader2Icon className="size-3.5 animate-spin" />
加载报文…
</div>
) : (
<pre className="p-3 font-mono text-xs break-all whitespace-pre-wrap">
{body || "(空)"}
</pre>
)}
</div>
</div>
))}
</div>
</Card>
)}
</div>
<AlertDialog open={deleteMode !== null} onOpenChange={(o) => { if (!o) setDeleteMode(null); }}>
<Sheet open={selected !== null} onOpenChange={(open) => !open && setSelected(null)}>
<SheetContent className="w-full! max-w-none! gap-0 p-0 sm:w-[48rem]! sm:max-w-[48rem]!">
{selected && (
<>
<SheetHeader className="border-b px-5 py-4">
<div className="flex items-center gap-2 pr-8">
<MethodBadge method={selected.method} />
<Badge variant="secondary" className={cn("font-mono tabular-nums", statusTone(selected.status))}>
{selected.status}
</Badge>
<span className="ml-auto text-xs text-muted-foreground tabular-nums">{fmtTime(selected.ts)}</span>
</div>
<SheetTitle className="break-all font-mono">{selected.host}</SheetTitle>
<SheetDescription className="break-all font-mono">{selected.url}</SheetDescription>
</SheetHeader>
<Tabs defaultValue="request" className="min-h-0 flex-1 gap-0">
<TabsList className="mx-5 mt-4 grid w-auto grid-cols-2">
<TabsTrigger value="request">请求 Request</TabsTrigger>
<TabsTrigger value="response">响应 Response</TabsTrigger>
</TabsList>
<TabsContent value="request" className="min-h-0 overflow-auto">
{detailLoading ? (
<div className="flex items-center gap-2 p-5 text-xs text-muted-foreground">
<Loader2Icon className="size-3.5 animate-spin" />
加载报文…
</div>
) : (
<HttpCodeBlock raw={requestWithHost(detail?.req ?? "", selected)} />
)}
</TabsContent>
<TabsContent value="response" className="min-h-0 overflow-auto">
{detailLoading ? (
<div className="flex items-center gap-2 p-5 text-xs text-muted-foreground">
<Loader2Icon className="size-3.5 animate-spin" />
加载报文…
</div>
) : (
<HttpCodeBlock raw={detail?.resp ?? ""} />
)}
</TabsContent>
</Tabs>
</>
)}
</SheetContent>
</Sheet>
<AlertDialog
open={deleteMode !== null}
onOpenChange={(o) => {
if (!o) setDeleteMode(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
@@ -533,9 +615,7 @@ export default function TrafficPage() {
<AlertDialogDescription>
{deleteMode === "selected" ? (
<>
将永久删除{" "}
<span className="font-semibold tabular-nums">{selectedHosts.length}</span>{" "}
个目标(
将永久删除 <span className="font-semibold tabular-nums">{selectedHosts.length}</span> 个目标(
<span className="font-mono">
{selectedHosts.slice(0, 3).join("、")}
{selectedHosts.length > 3 ? "…" : ""}
@@ -544,8 +624,7 @@ export default function TrafficPage() {
</>
) : (
<>
将永久删除 host 包含{" "}
<span className="font-mono font-semibold">{hostQ}</span>{" "}
将永久删除 host 包含 <span className="font-mono font-semibold">{hostQ}</span>{" "}
的所有流量记录(含请求/响应原文),此操作不可撤销。
</>
)}
+52 -48
View File
@@ -24,17 +24,10 @@ import {
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Spinner } from "@/components/ui/spinner";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/lib/api";
@@ -172,19 +165,19 @@ function TaskTemplateManager({
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="grid max-h-[90svh] max-w-4xl grid-rows-[auto_minmax(0,1fr)_auto] gap-0 overflow-hidden p-0">
<DialogHeader className="border-b px-6 py-5">
<DialogTitle>任务模板管理</DialogTitle>
<DialogDescription>模板只保存描述与目标;修改不会影响已经创建的任务。</DialogDescription>
</DialogHeader>
<div className="grid min-h-0 overflow-y-auto md:grid-cols-[15rem_minmax(0,1fr)] md:overflow-hidden">
<div className="flex min-h-0 flex-col border-b p-3 md:border-r md:border-b-0">
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="grid h-full w-full! max-w-none! grid-rows-[auto_minmax(0,1fr)_auto] gap-0 overflow-hidden p-0 sm:w-[48rem]! sm:max-w-[48rem]!">
<SheetHeader className="border-b px-6 py-5">
<SheetTitle>任务模板管理</SheetTitle>
<SheetDescription>模板只保存描述与目标;修改不会影响已经创建的任务。</SheetDescription>
</SheetHeader>
<div className="grid min-h-0 overflow-y-auto lg:grid-cols-[15rem_minmax(0,1fr)] lg:overflow-hidden">
<div className="flex min-h-0 flex-col border-b p-3 lg:border-r lg:border-b-0">
<Button type="button" variant="outline" className="w-full" onClick={startNew}>
<PlusIcon data-icon="inline-start" />
新建模板
</Button>
<ScrollArea className="mt-2 max-h-44 md:max-h-[27rem]">
<ScrollArea className="mt-2 max-h-44 lg:max-h-none lg:flex-1">
<div className="flex flex-col gap-1 pr-2">
{templates.length === 0 && (
<p className="px-2 py-6 text-center text-muted-foreground text-sm">暂无模板</p>
@@ -206,7 +199,7 @@ function TaskTemplateManager({
</div>
</ScrollArea>
</div>
<ScrollArea className="max-h-[58svh]">
<ScrollArea className="min-h-0">
<FieldGroup className="p-6">
<Field>
<FieldLabel htmlFor="task-template-name">模板名称</FieldLabel>
@@ -241,7 +234,7 @@ function TaskTemplateManager({
</FieldGroup>
</ScrollArea>
</div>
<DialogFooter className="border-t px-6 py-4">
<SheetFooter className="border-t px-6 py-4 sm:flex-row sm:items-center">
{selectedID != null && (
<Button type="button" variant="destructive" className="sm:mr-auto" onClick={() => setDeleteOpen(true)}>
<Trash2Icon data-icon="inline-start" />
@@ -255,9 +248,9 @@ function TaskTemplateManager({
{saving ? <Spinner data-icon="inline-start" /> : <SaveIcon data-icon="inline-start" />}
{saveLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</SheetFooter>
</SheetContent>
</Sheet>
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
@@ -303,6 +296,7 @@ export function TaskTemplateControls({
}: TaskTemplateControlsProps) {
const [templates, setTemplates] = React.useState<TaskTemplate[]>([]);
const [loading, setLoading] = React.useState(true);
const [templateInputValue, setTemplateInputValue] = React.useState("");
const [pendingTemplate, setPendingTemplate] = React.useState<TaskTemplate | null>(null);
const [managerOpen, setManagerOpen] = React.useState(false);
const [managerSeed, setManagerSeed] = React.useState<Pick<TemplateDraft, "description" | "goal"> | null>(null);
@@ -323,25 +317,28 @@ export function TaskTemplateControls({
void loadTemplates();
}, [loadTemplates]);
const templateIDs = React.useMemo(() => templates.map((template) => String(template.id)), [templates]);
const templatesByID = React.useMemo(
() => new Map(templates.map((template) => [String(template.id), template])),
[templates],
const selectedTemplate = React.useMemo(
() => templates.find((template) => template.id === selectedTemplateID) ?? null,
[selectedTemplateID, templates],
);
React.useEffect(() => {
setTemplateInputValue(selectedTemplate?.name ?? "");
}, [selectedTemplate]);
const applyTemplate = (template: TaskTemplate) => {
onApply(template);
onSelectedTemplateIDChange(template.id);
setPendingTemplate(null);
};
const chooseTemplate = (value: string | null) => {
if (!value) {
const chooseTemplate = (template: TaskTemplate | null) => {
if (!template) {
setTemplateInputValue("");
onSelectedTemplateIDChange(null);
return;
}
const template = templatesByID.get(value);
if (!template) return;
setTemplateInputValue(template.name);
const hasContent = description.trim() !== "" || goal.trim() !== "";
const changesContent = description !== template.description || goal !== template.goal;
if (hasContent && changesContent) {
@@ -391,9 +388,12 @@ export function TaskTemplateControls({
</div>
</div>
<Combobox
items={templateIDs}
itemToStringValue={(id) => templatesByID.get(id)?.name ?? id}
value={selectedTemplateID == null ? null : String(selectedTemplateID)}
items={templates}
itemToStringLabel={(template) => template.name}
itemToStringValue={(template) => String(template.id)}
inputValue={templateInputValue}
onInputValueChange={(value) => setTemplateInputValue(value)}
value={selectedTemplate}
onValueChange={chooseTemplate}
>
<ComboboxInput
@@ -406,27 +406,31 @@ export function TaskTemplateControls({
<ComboboxContent portalContainer={portalContainer}>
<ComboboxEmpty>没有匹配的模板</ComboboxEmpty>
<ComboboxList>
{(id) => {
const template = templatesByID.get(id);
return (
<ComboboxItem key={id} value={id}>
<LibraryIcon />
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-sm">{template?.name ?? `模板 #${id}`}</p>
{template?.description && (
<p className="truncate text-muted-foreground text-xs">{template.description}</p>
)}
</div>
</ComboboxItem>
);
}}
{(template) => (
<ComboboxItem key={template.id} value={template}>
<LibraryIcon />
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-sm">{template.name}</p>
{template.description && (
<p className="truncate text-muted-foreground text-xs">{template.description}</p>
)}
</div>
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
<FieldDescription>选择后会复制模板的描述和目标,不与模板保持关联。</FieldDescription>
</Field>
<AlertDialog open={pendingTemplate != null} onOpenChange={(open) => !open && setPendingTemplate(null)}>
<AlertDialog
open={pendingTemplate != null}
onOpenChange={(open) => {
if (open) return;
setPendingTemplate(null);
setTemplateInputValue(selectedTemplate?.name ?? "");
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>使用模板「{pendingTemplate?.name}」?</AlertDialogTitle>
-7
View File
@@ -11,7 +11,6 @@ import type {
AgentDetail,
AgentTrigger,
Asset,
AssetNode,
Audit,
BatchControlItem,
ChatAttachment,
@@ -56,7 +55,6 @@ import type {
SSTask,
Stats,
Task,
TaskAssetView,
TaskLLMResolutions,
TaskNode,
TaskScopeRow,
@@ -230,11 +228,6 @@ export const api = {
state: "paused" | "open" | "cancelled";
deleted?: { intents: number; facts: number; findings: number; activities: number };
}>(`/tasks/${taskId}/intents/${intentId}/control`, { action }),
controlIntentsBatch: (taskId: string, intentIds: string[], action: "pause" | "resume") =>
post<{ items: BatchControlItem[] }>(`/tasks/${taskId}/intents/control/batch`, {
intent_ids: intentIds,
action,
}),
taskLLMResolution: (id: string) => get<TaskLLMResolutions>(`/tasks/${id}/llm/resolution`),
// 重跑一条没跑成功的意图(blocked/exhausted/stopped):置回 open,worker 会重新认领、从头再跑。
rerunIntent: (taskId: string, intentId: string) =>
+212
View File
@@ -0,0 +1,212 @@
import type { CompanyScopeKind, CompanyScopeRule } from "@/lib/types";
export const MAX_COMPANY_SCOPE_RULES = 256;
export const MAX_COMPANY_SCOPE_VALUE_LENGTH = 1024;
export type CompanyScopeTextIssue = {
line: number;
rule?: CompanyScopeRule;
error?: string;
};
export type ParsedCompanyScopeText = {
rules: CompanyScopeRule[];
errors: Array<{ line: number; error: string }>;
};
export const COMPANY_SCOPE_KINDS = new Set<CompanyScopeKind>(["domain", "ip", "cidr", "icp", "keyword"]);
export function isCompanyScopeKind(value: unknown): value is CompanyScopeKind {
return typeof value === "string" && COMPANY_SCOPE_KINDS.has(value as CompanyScopeKind);
}
function isIPv4(value: string): boolean {
const parts = value.split(".");
return (
parts.length === 4 && parts.every((part) => (part === "0" || /^[1-9]\d{0,2}$/.test(part)) && Number(part) <= 255)
);
}
function isIPv6(value: string): boolean {
if (!value.includes(":") || /[^0-9a-f:.]/i.test(value) || value.includes(":::")) return false;
const halves = value.split("::");
if (halves.length > 2) return false;
const countSegments = (half: string): number | null => {
if (!half) return 0;
const segments = half.split(":");
let count = 0;
for (const [index, segment] of segments.entries()) {
if (segment.includes(".")) {
if (index !== segments.length - 1 || !isIPv4(segment)) return null;
count += 2;
} else {
if (!/^[0-9a-f]{1,4}$/i.test(segment)) return null;
count++;
}
}
return count;
};
const left = countSegments(halves[0]);
const right = countSegments(halves[1] ?? "");
if (left === null || right === null) return false;
return halves.length === 2 ? left + right < 8 : left === 8;
}
function ipVersion(value: string): 4 | 6 | null {
if (isIPv4(value)) return 4;
if (isIPv6(value)) return 6;
return null;
}
function looksLikeIPAddress(value: string): boolean {
const trimmed = value.trim();
if (trimmed.includes("://") || /\s/.test(trimmed)) return false;
if ((trimmed.match(/:/g) ?? []).length >= 2) {
const parts = trimmed.split(":");
let validSegments = 0;
for (const part of parts) {
if (!part) {
if (validSegments > 0 || trimmed.startsWith("::")) return true;
continue;
}
if (part.length > 4 || !/^[0-9a-f]+$/i.test(part)) return false;
validSegments++;
if (validSegments >= 2) return true;
}
return false;
}
return trimmed.includes(".") && /^[0-9.]+$/.test(trimmed);
}
function domainHostname(value: string): string | null {
try {
let candidate = value;
if (value.startsWith("//")) candidate = `http:${value}`;
else if (!value.includes("://")) candidate = `http://${value}`;
const url = new URL(candidate);
return (
url.hostname
.replace(/^\[|\]$/g, "")
.replace(/\.$/, "")
.toLowerCase() || null
);
} catch {
return null;
}
}
export function companyScopeRuleError(rule: CompanyScopeRule): string {
const value = rule.value.trim();
if (!value) return "请填写范围值";
if (Array.from(value).length > MAX_COMPANY_SCOPE_VALUE_LENGTH) {
return `最多 ${MAX_COMPANY_SCOPE_VALUE_LENGTH} 个字符`;
}
if (rule.kind === "domain") {
if (/\s/.test(value)) return "请输入不含空格的有效域名或 URL";
const hostname = domainHostname(value);
if (!hostname || ipVersion(hostname) !== null) return "请输入有效域名或 URL";
const labels = hostname.split(".");
if (
hostname.length > 253 ||
labels.length < 2 ||
labels.some(
(label) =>
!label || label.length > 63 || label.startsWith("-") || label.endsWith("-") || !/^[a-z0-9-]+$/i.test(label),
)
) {
return "请输入有效域名或 URL";
}
}
if (rule.kind === "ip" && ipVersion(value) === null) return "请输入有效 IP";
if (rule.kind === "cidr") {
const separator = value.lastIndexOf("/");
if (separator <= 0) return "请输入 CIDR 网段";
const address = value.slice(0, separator);
const prefixText = value.slice(separator + 1);
const version = ipVersion(address);
if (version === null || !/^\d+$/.test(prefixText)) return "请输入 CIDR 网段";
const prefix = Number(prefixText);
if (version === 4 && (prefix < 16 || prefix > 32)) return "IPv4 网段前缀需为 /16 至 /32";
if (version === 6 && (prefix < 32 || prefix > 128)) return "IPv6 网段前缀需为 /32 至 /128";
}
return "";
}
export function classifyCompanyScopeLine(
raw: string,
line: number,
preservedRule?: CompanyScopeRule,
): CompanyScopeTextIssue {
const value = raw.trim();
if (Array.from(value).length > MAX_COMPANY_SCOPE_VALUE_LENGTH) {
return { line, error: `最多 ${MAX_COMPANY_SCOPE_VALUE_LENGTH} 个字符` };
}
if (preservedRule) {
const rule = { kind: preservedRule.kind, value };
const error = companyScopeRuleError(rule);
return error ? { line, error } : { line, rule };
}
const separator = value.lastIndexOf("/");
if (
separator > 0 &&
(ipVersion(value.slice(0, separator)) !== null || looksLikeIPAddress(value.slice(0, separator)))
) {
const rule: CompanyScopeRule = { kind: "cidr", value };
const error = companyScopeRuleError(rule);
return error ? { line, error } : { line, rule };
}
if (ipVersion(value) !== null) return { line, rule: { kind: "ip", value } };
if (looksLikeIPAddress(value)) return { line, error: "请输入有效 IP" };
const looksLikeDomain = value.includes("://") || (!/\s/.test(value) && value.includes("."));
if (looksLikeDomain) {
const hostname = domainHostname(value);
if (hostname && ipVersion(hostname) !== null) return { line, rule: { kind: "ip", value: hostname } };
const rule: CompanyScopeRule = { kind: "domain", value };
const error = companyScopeRuleError(rule);
return error ? { line, error } : { line, rule };
}
if (/icp|备案/i.test(value)) return { line, rule: { kind: "icp", value } };
return { line, rule: { kind: "keyword", value } };
}
export function parseCompanyScopeText(
value: string,
options: { preservedRules?: CompanyScopeRule[]; maxRules?: number } = {},
): ParsedCompanyScopeText {
const nonEmpty = value
.split(/\r?\n/)
.map((raw, index) => ({ raw, line: index + 1 }))
.filter(({ raw }) => raw.trim());
const preserved = new Map<string, CompanyScopeRule[]>();
for (const rule of options.preservedRules ?? []) {
const key = rule.value.trim();
preserved.set(key, [...(preserved.get(key) ?? []), rule]);
}
const issues = nonEmpty.map(({ raw, line }) => {
const key = raw.trim();
const queue = preserved.get(key);
const preservedRule = queue?.shift();
return classifyCompanyScopeLine(raw, line, preservedRule);
});
const maxRules = options.maxRules ?? MAX_COMPANY_SCOPE_RULES;
if (nonEmpty.length > maxRules) {
issues.push({
line: nonEmpty[Math.max(0, maxRules)]?.line ?? 1,
error: `最多允许 ${maxRules} 条范围,当前 ${nonEmpty.length} 条`,
});
}
return {
rules: issues.flatMap((item) => (item.rule ? [item.rule] : [])),
errors: issues.flatMap((item) => (item.error ? [{ line: item.line, error: item.error }] : [])),
};
}
export function normalizeCompanyScopeValue(rule: CompanyScopeRule): string {
const value = rule.value.trim();
if (rule.kind === "domain") return domainHostname(value) ?? value.toLowerCase();
if (rule.kind === "icp") return value.toLowerCase().replace(/\s/gu, "");
if (rule.kind === "keyword") return value.toLowerCase().split(/\s+/u).filter(Boolean).join(" ");
return value.toLowerCase();
}
+69 -27
View File
@@ -2,6 +2,13 @@
// 未命中的一律返回安全默认([] / {} / {ok:true}),保证任何页面都不崩。
// 只在 NEXT_PUBLIC_MOCK=1 时经由 api.ts 的 http() 短路进入这里。
import {
classifyCompanyScopeLine,
companyScopeRuleError,
isCompanyScopeKind,
MAX_COMPANY_SCOPE_RULES,
normalizeCompanyScopeValue,
} from "../company-scope";
import type {
Asset,
BatchControlItem,
@@ -36,24 +43,53 @@ function mockAssetCounts(): Record<string, number> {
}, {});
}
function mockScopeRows(companyID: number, input: unknown): ScopeRow[] {
if (!Array.isArray(input)) return [];
function mockScopeRows(
companyID: number,
input: unknown,
existing: ScopeRow[] = [],
): { rows: ScopeRow[]; invalid: number; skipped: number } {
if (!Array.isArray(input)) return { rows: [], invalid: 0, skipped: 0 };
if (input.length > MAX_COMPANY_SCOPE_RULES) throw new Error(`企业范围最多支持 ${MAX_COMPANY_SCOPE_RULES} 条`);
let nextID =
mockCompanies.flatMap((company) => company.scope ?? []).reduce((max, row) => Math.max(max, row.id), 0) + 1;
const rows: ScopeRow[] = [];
for (const candidate of input) {
if (!candidate || typeof candidate !== "object") continue;
const rule = candidate as Partial<CompanyScopeRule>;
const kind = rule.kind;
const value = String(rule.value ?? "").trim();
if (!kind || !value) continue;
const row: ScopeRow = { id: nextID++, company_id: companyID, kind, raw: value };
if (kind === "domain") row.domain = value.toLowerCase();
else if (kind === "ip" || kind === "cidr") row.net = value;
else row.value = value;
let invalid = 0;
let skipped = 0;
const keys = new Set(
existing.map((row) => `${row.kind}|${row.domain ?? row.net ?? row.value ?? row.raw.trim().toLowerCase()}`),
);
for (const [index, candidate] of input.entries()) {
let rule: CompanyScopeRule | undefined;
if (typeof candidate === "string") {
rule = classifyCompanyScopeLine(candidate, index + 1).rule;
} else if (candidate && typeof candidate === "object") {
const item = candidate as { kind?: unknown; value?: unknown };
const value = String(item.value ?? "").trim();
if (item.kind === undefined || item.kind === "") rule = classifyCompanyScopeLine(value, index + 1).rule;
else if (isCompanyScopeKind(item.kind)) rule = { kind: item.kind, value };
}
if (!rule || companyScopeRuleError(rule)) {
invalid++;
continue;
}
const normalized = normalizeCompanyScopeValue(rule);
const row: ScopeRow = { id: nextID++, company_id: companyID, kind: rule.kind, raw: rule.value.trim() };
if (rule.kind === "domain") row.domain = normalized;
else if (rule.kind === "ip") row.net = `${normalized}/${normalized.includes(":") ? 128 : 32}`;
else if (rule.kind === "cidr") row.net = normalized;
else row.value = normalized;
const key = `${row.kind}|${row.domain ?? row.net ?? row.value}`;
if (keys.has(key)) {
skipped++;
continue;
}
keys.add(key);
rows.push(row);
}
return rows;
if (existing.length + rows.length > MAX_COMPANY_SCOPE_RULES) {
throw new Error(`企业范围规则过多:每个企业最多 ${MAX_COMPANY_SCOPE_RULES} 条`);
}
return { rows, invalid, skipped };
}
function mockProfileResolution(profileID: number | undefined, source: TaskLLMResolution["source"]): TaskLLMResolution {
@@ -109,15 +145,17 @@ function bodyIDs(value: unknown): string[] {
return ids;
}
function controlMockIntent(id: string, action: "pause" | "resume"): BatchControlItem {
type MockIntentControlResult = { ok: boolean; state?: string; error?: string };
function controlMockIntent(id: string, action: "pause" | "resume"): MockIntentControlResult {
const intent = mockIntents.find((item) => item.id === id);
if (!intent) return { id, ok: false, error: "意图不存在" };
if (!intent) return { ok: false, error: "意图不存在" };
const requiredState = action === "pause" ? "running" : "paused";
if (intent.inherited || intent.state !== requiredState) {
return { id, ok: false, state: intent.state, error: "Worker 状态已变化" };
return { ok: false, state: intent.state, error: "Worker 状态已变化" };
}
intent.state = action === "pause" ? "paused" : "open";
return { id, ok: true, state: intent.state };
return { ok: true, state: intent.state };
}
function controlMockTask(id: string, action: "pause" | "resume"): BatchControlItem {
@@ -357,10 +395,6 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
const action = b.action === "resume" ? "resume" : "pause";
return { items: bodyIDs(b.task_ids).map((id) => controlMockTask(id, action)) };
}
if (seg[0] === "tasks" && seg[2] === "intents" && seg[3] === "control" && seg[4] === "batch" && m === "POST") {
const action = b.action === "resume" ? "resume" : "pause";
return { items: bodyIDs(b.intent_ids).map((id) => controlMockIntent(id, action)) };
}
if (seg[0] === "tasks" && seg[2] === "intents" && seg[4] === "control" && m === "POST") {
const id = seg[3];
if (b.action === "cancel") {
@@ -515,17 +549,25 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
throw new Error("企业已存在");
}
const id = mockCompanies.reduce((max, company) => Math.max(max, company.id), 0) + 1;
const scope = mockScopeRows(id, b.scope);
const company: Company = { id, name, asset_count: 0, scope };
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: scope.length, scope_invalid: 0 };
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 rows = mockScopeRows(company.id, b.scope);
company.scope = b.reset === true ? rows : [...(company.scope ?? []), ...rows];
return { added: rows.length, skipped: 0, invalid: 0 };
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]);
-1
View File
@@ -555,7 +555,6 @@ export interface BatchControlItem {
id: string;
ok: boolean;
status?: string;
state?: string;
queued?: boolean;
error?: string;
}