` 渲染。单行输出保持 prose。fence 长度自适应避免与内容中的 backtick 冲突。
+
+**文件**: `pkg/tui/banner.go`, `pkg/tui/commands.go`, `pkg/tui/ioa.go`, `pkg/webagent/agent.go`
+
+---
+
+## 15. 环境变量优先级修正
+
+旧逻辑中 provider-scoped env(如 `ANTHROPIC_MODEL`)和 aiscan 自有 env(`AISCAN_MODEL`)在 `else if` 链中平级。hub 启动的 agent 继承 hub 环境后,Settings UI 配置的 model 被环境变量覆盖。
+
+新逻辑拆为两个独立 `if`:
+1. 先看 aiscan 自有 env(`AISCAN_MODEL`)
+2. 再检查 `option.Model` 是否仍为空,才 fallback 到 provider env
+
+对 `BaseURL`、`APIKey` 同理。
+
+**文件**: `core/config/env.go`
diff --git a/go.mod b/go.mod
index 38f1a606..7e9d3980 100644
--- a/go.mod
+++ b/go.mod
@@ -6,22 +6,26 @@ require (
github.com/alecthomas/chroma/v2 v2.14.0
github.com/carapace-sh/carapace v1.11.6
github.com/chainreactors/crtm v0.0.3-0.20260618163257-073207497076
- github.com/chainreactors/fingers v1.2.2-0.20260703124922-b8ac7e00cf68
- github.com/chainreactors/gogo/v2 v2.14.2-0.20260704194421-e5ce938d9b51
+ github.com/chainreactors/fingers v1.2.2-0.20260704073236-3e22b6a528b9
+ github.com/chainreactors/gogo/v2 v2.14.2-0.20260710171447-b1776cb06226
github.com/chainreactors/ioa v0.1.2-0.20260621175506-35d6a4a11645
+ github.com/chainreactors/libcstx/go v0.0.0-20260716111447-af6771384af7
github.com/chainreactors/logs v0.0.0-20260624034259-9aaea4aa52cc
- github.com/chainreactors/neutron v0.1.1-0.20260704194031-f57d0a560e32
- github.com/chainreactors/proton v0.3.3-0.20260704194051-e7034d2bb8cb
+ github.com/chainreactors/neutron v0.1.1-0.20260710171341-456d36779ab2
+ github.com/chainreactors/proton v0.3.3-0.20260707162538-471f99ea6131
github.com/chainreactors/proxyclient v1.1.1-0.20260529172347-2a80e08d5593
github.com/chainreactors/proxyclient/extra v0.0.0-20260527160727-36cf133952c3
- github.com/chainreactors/sdk v0.3.4-0.20260704194820-4141f24c2bcd
+ github.com/chainreactors/sdk v0.3.4-0.20260708104745-dcad8620f5e9
+ github.com/chainreactors/sdk/gogo v0.0.0-20260708104745-dcad8620f5e9
+ github.com/chainreactors/sdk/spray v0.0.0-20260708104745-dcad8620f5e9
+ github.com/chainreactors/sdk/zombie v0.0.0-20260708104745-dcad8620f5e9
github.com/chainreactors/spray v1.3.3-0.20260704194611-7ce7b850d447
github.com/chainreactors/tui/console v0.0.0-20260701051656-c5b85e7256a9
github.com/chainreactors/tui/readline v0.0.0-20260626181537-7c0eb4b933cd
- github.com/chainreactors/utils v0.0.0-20260704063748-6f849f173481
- github.com/chainreactors/utils/mitmproxy v0.0.0-20260630095004-c4fb7a13ed39
- github.com/chainreactors/utils/parsers v0.0.2
- github.com/chainreactors/utils/pty v0.0.0-20260630095004-c4fb7a13ed39
+ github.com/chainreactors/utils v0.0.0-20260707181750-8aa6ca296863
+ github.com/chainreactors/utils/mitmproxy v0.0.0-20260707181750-8aa6ca296863
+ github.com/chainreactors/utils/parsers v0.0.3-0.20260707181750-8aa6ca296863
+ github.com/chainreactors/utils/pty v0.0.0-20260707181750-8aa6ca296863
github.com/chainreactors/zombie v1.3.0
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/glamour v0.8.0
@@ -31,6 +35,7 @@ require (
github.com/gorilla/websocket v1.5.3
github.com/invopop/jsonschema v0.14.0
github.com/jessevdk/go-flags v1.6.1
+ github.com/mattn/go-runewidth v0.0.23
github.com/muesli/termenv v0.16.0
github.com/projectdiscovery/goflags v0.1.74
github.com/projectdiscovery/gologger v1.1.68
@@ -82,19 +87,18 @@ require (
github.com/bodgit/sevenzip v1.6.4 // indirect
github.com/bodgit/windows v1.0.1 // indirect
github.com/bradfitz/gomemcache v0.0.0-20260422231931-4d751bb6e37c // indirect
- github.com/brianvoe/gofakeit/v7 v7.2.1 // indirect
github.com/buger/jsonparser v1.1.2 // indirect
github.com/carapace-sh/carapace-shlex v1.1.1 // indirect
github.com/censys/censys-sdk-go v0.19.1 // indirect
github.com/chainreactors/files v0.0.0-20240716182835-7884ee1e77f0 // indirect
- github.com/chainreactors/neutron/operators/full v0.1.1-0.20260703124839-c4091ad1e02d // indirect
+ github.com/chainreactors/neutron/operators/full v0.1.1-0.20260704194031-f57d0a560e32 // indirect
github.com/chainreactors/parsers v0.0.0-20260608085142-3d2c51baa8fe // indirect
- github.com/chainreactors/utils/cert v0.0.0-20260624181253-2b3d0b35862f // indirect
+ github.com/chainreactors/utils/cert v0.0.0-20260707181750-8aa6ca296863 // indirect
github.com/chainreactors/words v0.0.0-20260520145736-270600e60fb4 // indirect
github.com/charlievieth/fastwalk v1.0.14 // indirect
- github.com/charmbracelet/bubbletea v1.3.10 // indirect
+ github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/colorprofile v0.4.3 // indirect
- github.com/charmbracelet/lipgloss v1.1.0 // indirect
+ github.com/charmbracelet/lipgloss v1.1.0
github.com/charmbracelet/x/ansi v0.11.7 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
@@ -147,8 +151,6 @@ require (
github.com/google/uuid v1.6.0 // indirect
github.com/gookit/goutil v0.7.5 // indirect
github.com/gorilla/css v1.0.1 // indirect
- github.com/gosimple/slug v1.15.0 // indirect
- github.com/gosimple/unidecode v1.0.1 // indirect
github.com/gosnmp/gosnmp v1.43.2 // indirect
github.com/h2non/filetype v1.1.3 // indirect
github.com/happyhackingspace/dit v0.0.14 // indirect
@@ -156,10 +158,8 @@ require (
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/go-version v1.9.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
- github.com/hdm/jarm-go v0.0.7 // indirect
github.com/hirochachacha/go-smb2 v1.1.0 // indirect
github.com/huin/asn1ber v0.0.0-20120622192748-af09f62e6358 // indirect
- github.com/iangcarroll/cookiemonster v1.6.0 // indirect
github.com/icholy/digest v1.1.0 // indirect
github.com/icodeface/tls v0.0.0-20230910023335-34df9250cd12 // indirect
github.com/imroc/req/v3 v3.57.0 // indirect
@@ -168,7 +168,6 @@ require (
github.com/itchyny/timefmt-go v0.1.8 // indirect
github.com/jlaffaye/ftp v0.2.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
- github.com/kataras/jwt v0.1.8 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/klauspost/compress v1.18.6 // indirect
github.com/klauspost/pgzip v1.2.6 // indirect
@@ -184,7 +183,6 @@ require (
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
- github.com/mattn/go-runewidth v0.0.23 // indirect
github.com/metacubex/utls v1.7.3 // indirect
github.com/mfonda/simhash v0.0.0-20151007195837-79f94a1100d6 // indirect
github.com/mholt/archiver v3.1.1+incompatible // indirect
@@ -214,9 +212,7 @@ require (
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/projectdiscovery/blackrock v0.0.1 // indirect
- github.com/projectdiscovery/dsl v0.8.17 // indirect
github.com/projectdiscovery/fastdialer v0.5.6 // indirect
- github.com/projectdiscovery/gostruct v0.0.2 // indirect
github.com/projectdiscovery/hmap v0.0.100 // indirect
github.com/projectdiscovery/mapcidr v1.1.97 // indirect
github.com/projectdiscovery/networkpolicy v0.1.37 // indirect
@@ -233,10 +229,10 @@ require (
github.com/rs/xid v1.5.0 // indirect
github.com/sagernet/sing v0.7.6 // indirect
github.com/sagernet/sing-vmess v0.2.7 // indirect
+ github.com/sahilm/fuzzy v0.1.1 // indirect
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d // indirect
github.com/samuel/go-zookeeper v0.0.0-20201211165307-7117e9ea2414 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
- github.com/sashabaranov/go-openai v1.37.0 // indirect
github.com/satori/go.uuid v1.2.0 // indirect
github.com/sijms/go-ora/v2 v2.9.0 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
@@ -267,7 +263,6 @@ require (
github.com/valyala/fasthttp v1.71.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/vbauerster/mpb/v8 v8.12.1 // indirect
- github.com/vulncheck-oss/go-exploit v1.51.0 // indirect
github.com/wasilibs/go-re2 v1.11.0 // indirect
github.com/wasilibs/wazero-helpers v0.0.0-20250123031827-cd30c44769bb // indirect
github.com/weppos/publicsuffix-go v0.50.3-0.20260104170930-90713dec78f2 // indirect
@@ -310,3 +305,12 @@ require (
modernc.org/memory v1.11.0 // indirect
mvdan.cc/sh/v3 v3.13.1 // indirect
)
+
+replace (
+ github.com/chainreactors/tui/console => ../tui/console
+ github.com/chainreactors/tui/readline => ../tui/readline
+)
+
+replace github.com/projectdiscovery/katana => github.com/chainreactors/katana v1.6.2-0.20260716115809-46dd3ac126d2
+
+replace github.com/wasilibs/go-re2 => github.com/chainreactors/go-re2 v1.11.1-0.20260716082604-8121b6cd261e
diff --git a/go.sum b/go.sum
index 9d287f0c..1f100cce 100644
--- a/go.sum
+++ b/go.sum
@@ -78,7 +78,6 @@ github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAE
github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8/go.mod h1:I0gYDMZ6Z5GRU7l58bNFSkPTFN6Yl12dsUlAZ8xy98g=
github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
-github.com/RumbleDiscovery/rumble-tools v0.0.0-20201105153123-f2adbb3244d2/go.mod h1:jD2+mU+E2SZUuAOHZvZj4xP4frlOo+N/YrXDvASFhkE=
github.com/STARRY-S/zip v0.2.3 h1:luE4dMvRPDOWQdeDdUxUoZkzUIpTccdKdhHHsQJ1fm4=
github.com/STARRY-S/zip v0.2.3/go.mod h1:lqJ9JdeRipyOQJrYSOtpNAiaesFO6zVDsE8GIGFaoSk=
github.com/VividCortex/ewma v1.2.0 h1:f58SaIzcDXrSy3kWaHNvuJgJ3Nmz59Zji6XoJR/q1ow=
@@ -154,8 +153,6 @@ github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4=
github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM=
github.com/bradfitz/gomemcache v0.0.0-20260422231931-4d751bb6e37c h1:6Gpm9YYUEQx2T9zMsYolQhr6sjwwGtFitSA0pQsa7a8=
github.com/bradfitz/gomemcache v0.0.0-20260422231931-4d751bb6e37c/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c=
-github.com/brianvoe/gofakeit/v7 v7.2.1 h1:AGojgaaCdgq4Adzrd2uWdbGNDyX6MWNhHdQBraNfOHI=
-github.com/brianvoe/gofakeit/v7 v7.2.1/go.mod h1:QXuPeBw164PJCzCUZVmgpgHJ3Llj49jSLVkKPMtxtxA=
github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk=
github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
@@ -174,45 +171,53 @@ github.com/chainreactors/crtm v0.0.3-0.20260618163257-073207497076 h1:vIEqkeRYDy
github.com/chainreactors/crtm v0.0.3-0.20260618163257-073207497076/go.mod h1:+T3JvsT0teBxi4+ValZTYWCDIwM8inbx57+nQfMFkbA=
github.com/chainreactors/files v0.0.0-20240716182835-7884ee1e77f0 h1:cU3sGEODXZsUZGBXfnz0nyxF6+37vA+ZGDx6L/FKN4o=
github.com/chainreactors/files v0.0.0-20240716182835-7884ee1e77f0/go.mod h1:NSxGNMRWryAyrDzZpVwmujI22wbGw6c52bQOd5zEvyU=
-github.com/chainreactors/fingers v1.2.2-0.20260703124922-b8ac7e00cf68 h1:H+0G6mS84D2onz/pltLqbZdGwPWniaRzbAVfJ8+O834=
-github.com/chainreactors/fingers v1.2.2-0.20260703124922-b8ac7e00cf68/go.mod h1:1f83YqXk4AoTqo8t22WRX/KMxWhaHiAjsPk1epdYxeE=
-github.com/chainreactors/gogo/v2 v2.14.2-0.20260704194421-e5ce938d9b51 h1:xK97YJLYLjZasZacbF54pgVVtjqrdw69lt7vDCTKHoY=
-github.com/chainreactors/gogo/v2 v2.14.2-0.20260704194421-e5ce938d9b51/go.mod h1:pumWVdPvZEv8ZjByNQHfs1s7//cZmSEdBQjHGrAz4x0=
+github.com/chainreactors/fingers v1.2.2-0.20260704073236-3e22b6a528b9 h1:6TntNzkBkKaZ2rN/w+pJUmmb0VOoZkcOZUkrIYi05RQ=
+github.com/chainreactors/fingers v1.2.2-0.20260704073236-3e22b6a528b9/go.mod h1:rTZEazmD80vXmSpgwDcMo7bbZU8dop3D57XIsuy1W3M=
+github.com/chainreactors/go-re2 v1.11.1-0.20260716142648-7b4fcc466374 h1:PDUneeDqDAhsmJtnL4CenHMPukoUdEG1vO94puW6Gac=
+github.com/chainreactors/go-re2 v1.11.1-0.20260716142648-7b4fcc466374/go.mod h1:4qC68vqWSuPTct3spuTrWBqCpm00mQ707JKLS1izVjI=
+github.com/chainreactors/gogo/v2 v2.14.2-0.20260710171447-b1776cb06226 h1:f/9CjNNXnSn718EsSysQUyj4AchRTw5xWqig+myOPt4=
+github.com/chainreactors/gogo/v2 v2.14.2-0.20260710171447-b1776cb06226/go.mod h1:pCbDa+HwfjKCGOD4PJ0puFa/FJkE/kiy29tkX0OIw70=
github.com/chainreactors/ioa v0.1.2-0.20260621175506-35d6a4a11645 h1:uNVPsxHycN17wCxB1HFS5vaklSs0q8eO6XK8orFiIGw=
github.com/chainreactors/ioa v0.1.2-0.20260621175506-35d6a4a11645/go.mod h1:IqHyULc67RKEmr9qsyPpJzgSGJRK8JeRXXXVthQu5Z8=
+github.com/chainreactors/katana v1.6.2-0.20260716115809-46dd3ac126d2 h1:pc7Vw1H4CyFnzOCxRmM1kdDwX0vJBTotIxOQjA78J/Q=
+github.com/chainreactors/katana v1.6.2-0.20260716115809-46dd3ac126d2/go.mod h1:X0uOEyD6Q/d7QBQ/MMU+J9bN83Xc/+XN3M2KJDaQuNw=
+github.com/chainreactors/libcstx/go v0.0.0-20260716111447-af6771384af7 h1:DJxafZodSjffB7ilvNf2eZDEAcSkD5I2GzSWlJUlb3A=
+github.com/chainreactors/libcstx/go v0.0.0-20260716111447-af6771384af7/go.mod h1:YQNpUU90e8tw9k1VNEUF7smIY8kSI0J3T0/zp8ri85Y=
github.com/chainreactors/logs v0.0.0-20260624034259-9aaea4aa52cc h1:e6rjnU8dmfhAnkFzLp/R5gta0LbFM13L27djxbC/i4I=
github.com/chainreactors/logs v0.0.0-20260624034259-9aaea4aa52cc/go.mod h1:VrXmYPbNN5AVoo1sc5aeyPVBYqubMdb3KO/tn5rRZpo=
-github.com/chainreactors/neutron v0.1.1-0.20260704194031-f57d0a560e32 h1:cLXoLI5XkDLOblX5ztKZ37ROOfdaf+RhvcbRvyde4g4=
-github.com/chainreactors/neutron v0.1.1-0.20260704194031-f57d0a560e32/go.mod h1:BAWFIherRWHI1kjZkncx54tuhaKLoS6OM6T7zQBPynU=
-github.com/chainreactors/neutron/operators/full v0.1.1-0.20260703124839-c4091ad1e02d h1:DIyoNJQyzBZ8wENIkGUmFKTpREEDcK2onaIomNHwm/k=
-github.com/chainreactors/neutron/operators/full v0.1.1-0.20260703124839-c4091ad1e02d/go.mod h1:8Yg/msDYB3syXD2ryGObqSn8GG+xaBoDG/1S67A4ByQ=
+github.com/chainreactors/neutron v0.1.1-0.20260710171341-456d36779ab2 h1:u0gNplhf4avPGO6fowTIAl8VrXE+hKccbbrFt8ETjm4=
+github.com/chainreactors/neutron v0.1.1-0.20260710171341-456d36779ab2/go.mod h1:BAWFIherRWHI1kjZkncx54tuhaKLoS6OM6T7zQBPynU=
+github.com/chainreactors/neutron/operators/full v0.1.1-0.20260704194031-f57d0a560e32 h1:q14uQiXYcizQqkHraBghJEPzcE5StLQLIWF0HvNFKhY=
+github.com/chainreactors/neutron/operators/full v0.1.1-0.20260704194031-f57d0a560e32/go.mod h1:8Yg/msDYB3syXD2ryGObqSn8GG+xaBoDG/1S67A4ByQ=
github.com/chainreactors/parsers v0.0.0-20260608085142-3d2c51baa8fe h1:n1pFLHHYXMiX5rCVWeciOTJUFggWXOrLtCu9jhq5Mbs=
github.com/chainreactors/parsers v0.0.0-20260608085142-3d2c51baa8fe/go.mod h1:ygxMqZQ/hGY2uegUvC0LbR538hbgNH7HP4dTuv/jfSM=
-github.com/chainreactors/proton v0.3.3-0.20260704194051-e7034d2bb8cb h1:Q0iNaE0ZD724AazhfyYilTUQlqBnrm8+UehoBG7MIhg=
-github.com/chainreactors/proton v0.3.3-0.20260704194051-e7034d2bb8cb/go.mod h1:c4kezBtDrE4sBIH6qF0+OShJ3/fijZENyxcUbcfZ/qQ=
+github.com/chainreactors/proton v0.3.3-0.20260707162538-471f99ea6131 h1:gTrBbrASTvndSBr2XL75Kdw8fAM3xw/dikTqMNzoQBE=
+github.com/chainreactors/proton v0.3.3-0.20260707162538-471f99ea6131/go.mod h1:c4kezBtDrE4sBIH6qF0+OShJ3/fijZENyxcUbcfZ/qQ=
github.com/chainreactors/proxyclient v1.1.1-0.20260529172347-2a80e08d5593 h1:tnXa9DobeX30xGIkiAcH+BOKKwvscvxy6GfI0Q0qu8Q=
github.com/chainreactors/proxyclient v1.1.1-0.20260529172347-2a80e08d5593/go.mod h1:xSNRChMYF8en5O5ZQVmCOmNTTyQhvsrk0D0vluh/JKk=
github.com/chainreactors/proxyclient/extra v0.0.0-20260527160727-36cf133952c3 h1:WVEb3Bjq3AC67oa9pNjHJfIH+mX6PozJO3S5ccVZJYQ=
github.com/chainreactors/proxyclient/extra v0.0.0-20260527160727-36cf133952c3/go.mod h1:4BG8xIxTebn0VoOTEwQ2pmYIB3K2qwGINxnRHALBNZg=
-github.com/chainreactors/sdk v0.3.4-0.20260704194820-4141f24c2bcd h1:v4BbmITJ7RV3feKI5EV923CPkJ5ePTW0VfusjeKx+Yk=
-github.com/chainreactors/sdk v0.3.4-0.20260704194820-4141f24c2bcd/go.mod h1:Mg9czK8sDWOoaPnbEPGfXTK5L1NBdNsgFh4B4/geCaU=
+github.com/chainreactors/sdk v0.3.4-0.20260708104745-dcad8620f5e9 h1:zHGP9WFSpTyZYWeKDHPDoEysmNYjck25Wv8/eVtpYBE=
+github.com/chainreactors/sdk v0.3.4-0.20260708104745-dcad8620f5e9/go.mod h1:SkcTgyo+bDEV44sLBX2+1G0UfmDdJaRCkqnyc5rEdmo=
+github.com/chainreactors/sdk/gogo v0.0.0-20260708104745-dcad8620f5e9 h1:EKjaJQee9EF2+p3ae8zVGezpsrVIuV4NqK8ZLCxdiCA=
+github.com/chainreactors/sdk/gogo v0.0.0-20260708104745-dcad8620f5e9/go.mod h1:2s9c3JVh33o+RLK/ATVoHPtLsln7oq3EZRCu6IIwFok=
+github.com/chainreactors/sdk/spray v0.0.0-20260708104745-dcad8620f5e9 h1:8dTUikJF+VZ5fEd1ck7ntxLrE6fnMEC2Gs9MZ5SdRdY=
+github.com/chainreactors/sdk/spray v0.0.0-20260708104745-dcad8620f5e9/go.mod h1:rb6iY0hDiAvyPc3YuzlrcNV8vyMqWnz9SJUOi6LJbpY=
+github.com/chainreactors/sdk/zombie v0.0.0-20260708104745-dcad8620f5e9 h1:laDuMr+cdTpfnOQ83UWN97oDgZcjpdYJLhJx+fEty3A=
+github.com/chainreactors/sdk/zombie v0.0.0-20260708104745-dcad8620f5e9/go.mod h1:Up0+yz0/VttlaIyjXQbAizFWGOD29/AjgnnpJ/wYMT8=
github.com/chainreactors/spray v1.3.3-0.20260704194611-7ce7b850d447 h1:4RawLZEJD1Ae/yjsqQMxiKXtECB2xjW1qC3txfmRAus=
github.com/chainreactors/spray v1.3.3-0.20260704194611-7ce7b850d447/go.mod h1:QT+vmYNPBmiemn+MJ5oNDNFSM/w0LNxtM5VhpI7RNNA=
-github.com/chainreactors/tui/console v0.0.0-20260701051656-c5b85e7256a9 h1:uXMnvtLAZ14A69yQ5FFHV1/dJ/5NCkboZpbsODlh4eY=
-github.com/chainreactors/tui/console v0.0.0-20260701051656-c5b85e7256a9/go.mod h1:lVNsVwhAj7AqSiw53pbktHmDRp0KoZI7n1VMVaAn+GI=
-github.com/chainreactors/tui/readline v0.0.0-20260626181537-7c0eb4b933cd h1:2IScCXplK2DIZFX53CRnhFVHvJIKPUYqezeH44ikTOI=
-github.com/chainreactors/tui/readline v0.0.0-20260626181537-7c0eb4b933cd/go.mod h1:nEHRbLD/s2GWdAGbNVjz/KDF0ac7WZ3tPMgWmW8sZWA=
github.com/chainreactors/utils v0.0.0-20240716182459-e85f2b01ee16/go.mod h1:LajXuvESQwP+qCMAvlcoSXppQCjuLlBrnQpu9XQ1HtU=
-github.com/chainreactors/utils v0.0.0-20260704063748-6f849f173481 h1:cdV7ZHB3fHrvEi0ziH2k62T6NF0kOgw2V6IBWAVn7i8=
-github.com/chainreactors/utils v0.0.0-20260704063748-6f849f173481/go.mod h1:xwbUlFoSSxLHujyb8D48o1s2DqmEAxUNfxIy0DVUmcg=
-github.com/chainreactors/utils/cert v0.0.0-20260624181253-2b3d0b35862f h1:7KonUAvkZhpmK8RDwDBCuey0qXeoqxJ7uWjoXqzL2z4=
-github.com/chainreactors/utils/cert v0.0.0-20260624181253-2b3d0b35862f/go.mod h1:xvvWMcU9Fcht6GR1cc9ceAZ3/Hl2HrkoRzpeyOzx1rQ=
-github.com/chainreactors/utils/mitmproxy v0.0.0-20260630095004-c4fb7a13ed39 h1:NVhRQxqEm+A9doKU6VYP1dPOerMy52lg9HpYVC0ZVoY=
-github.com/chainreactors/utils/mitmproxy v0.0.0-20260630095004-c4fb7a13ed39/go.mod h1:2O3/Vw66VnbzhwsHGFJ2Ge98RuSh6XzMMFGZmMmlZ9M=
-github.com/chainreactors/utils/parsers v0.0.2 h1:Q31qvztQaWALcYzggsiloyiQusrGLVtn6mtgaXR1BdQ=
-github.com/chainreactors/utils/parsers v0.0.2/go.mod h1:S9lkpQ1I4wcBq0YEBde/UPmR061IPok3bLl7aPz6Vkk=
-github.com/chainreactors/utils/pty v0.0.0-20260630095004-c4fb7a13ed39 h1:blMHXluxKB7Vg42z18BB7Op32+OEmhnO9e7XfvD+Kdk=
-github.com/chainreactors/utils/pty v0.0.0-20260630095004-c4fb7a13ed39/go.mod h1:RW1v+8hFMeO9+TJyQ1iIx9Ea37s+B7BaDf9fyJ0OEC4=
+github.com/chainreactors/utils v0.0.0-20260707181750-8aa6ca296863 h1:jfuZD+vg3/K/+l8au9RXnZCQf0J6S3vG6VRqmeBmxo0=
+github.com/chainreactors/utils v0.0.0-20260707181750-8aa6ca296863/go.mod h1:xwbUlFoSSxLHujyb8D48o1s2DqmEAxUNfxIy0DVUmcg=
+github.com/chainreactors/utils/cert v0.0.0-20260707181750-8aa6ca296863 h1:41tvJzi9t1NUlM/CzVdl8OG+W6PMFChsDOChohI2VeU=
+github.com/chainreactors/utils/cert v0.0.0-20260707181750-8aa6ca296863/go.mod h1:xvvWMcU9Fcht6GR1cc9ceAZ3/Hl2HrkoRzpeyOzx1rQ=
+github.com/chainreactors/utils/mitmproxy v0.0.0-20260707181750-8aa6ca296863 h1:r6UUUUQt4r/0SL6vgrwoq6ynidAkN3auSZsvzZ5BBRE=
+github.com/chainreactors/utils/mitmproxy v0.0.0-20260707181750-8aa6ca296863/go.mod h1:2O3/Vw66VnbzhwsHGFJ2Ge98RuSh6XzMMFGZmMmlZ9M=
+github.com/chainreactors/utils/parsers v0.0.3-0.20260707181750-8aa6ca296863 h1:u9cXebLoVtKwN0KkpGFfrjANUYo+93MijB//X9qONeY=
+github.com/chainreactors/utils/parsers v0.0.3-0.20260707181750-8aa6ca296863/go.mod h1:S9lkpQ1I4wcBq0YEBde/UPmR061IPok3bLl7aPz6Vkk=
+github.com/chainreactors/utils/pty v0.0.0-20260707181750-8aa6ca296863 h1:oXhSMk9Gov6jrtaFS4jRSPGV6SjfO/eaz2KRkacmEbg=
+github.com/chainreactors/utils/pty v0.0.0-20260707181750-8aa6ca296863/go.mod h1:RW1v+8hFMeO9+TJyQ1iIx9Ea37s+B7BaDf9fyJ0OEC4=
github.com/chainreactors/words v0.0.0-20260520145736-270600e60fb4 h1:lvnDYEkatmZFHP5i321qQXK9L4vKRfso/uUfr5tOeC8=
github.com/chainreactors/words v0.0.0-20260520145736-270600e60fb4/go.mod h1:zfz367PUmyaX6oAqV9SktVqyRXKlEh0sel9Wsq9dd2c=
github.com/chainreactors/zombie v1.3.0 h1:gUIrV3syRlqGmNptAi5oKvrctxU/MybWMAVwIfd77SY=
@@ -377,7 +382,6 @@ github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJA
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
-github.com/gofrs/uuid v3.3.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/gofrs/uuid/v5 v5.3.2 h1:2jfO8j3XgSwlz/wHqemAEugfnTlikAYHhnqQ8Xh4fE0=
github.com/gofrs/uuid/v5 v5.3.2/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
@@ -492,10 +496,6 @@ github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
-github.com/gosimple/slug v1.15.0 h1:wRZHsRrRcs6b0XnxMUBM6WK1U1Vg5B0R7VkIf1Xzobo=
-github.com/gosimple/slug v1.15.0/go.mod h1:UiRaFH+GEilHstLUmcBgWcI42viBN7mAb818JrYOeFQ=
-github.com/gosimple/unidecode v1.0.1 h1:hZzFTMMqSswvf0LBJZCZgThIZrpDHFXux9KeGmn6T/o=
-github.com/gosimple/unidecode v1.0.1/go.mod h1:CP0Cr1Y1kogOtx0bJblKzsVWrqYaqfNOnHzpgWw4Awc=
github.com/gosnmp/gosnmp v1.43.2 h1:F9loz6uMCNtIQj0RNO5wz/mZ+FZt2WyNKJYOvw+Zosw=
github.com/gosnmp/gosnmp v1.43.2/go.mod h1:smHIwoaqr1M+HTAEd7+mKkPs8lp3Lf/U+htPUql1Q3c=
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
@@ -543,8 +543,6 @@ github.com/hashicorp/memberlist v0.2.2/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOn
github.com/hashicorp/memberlist v0.3.0/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE=
github.com/hashicorp/serf v0.9.5/go.mod h1:UWDWwZeL5cuWDJdl0C6wrvrUwEqtQ4ZKBKKENpqIUyk=
github.com/hashicorp/serf v0.9.6/go.mod h1:TXZNMjZQijwlDvp+r0b63xZ45H7JmCmgg4gpTwn9UV4=
-github.com/hdm/jarm-go v0.0.7 h1:Eq0geenHrBSYuKrdVhrBdMMzOmA+CAMLzN2WrF3eL6A=
-github.com/hdm/jarm-go v0.0.7/go.mod h1:kinGoS0+Sdn1Rr54OtanET5E5n7AlD6T6CrJAKDjJSQ=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/hirochachacha/go-smb2 v1.1.0 h1:b6hs9qKIql9eVXAiN0M2wSFY5xnhbHAQoCwRKbaRTZI=
@@ -555,8 +553,6 @@ github.com/hugelgupf/vmtest v0.0.0-20240307030256-5d9f3d34a58d/go.mod h1:B63hDJM
github.com/huin/asn1ber v0.0.0-20120622192748-af09f62e6358 h1:hVXNJ57IHkOA8FBq80UG263MEBwNUMfS9c82J2QE5UQ=
github.com/huin/asn1ber v0.0.0-20120622192748-af09f62e6358/go.mod h1:qBE210J2T9uLXRB3GNc73SvZACDEFAmDCOlDkV47zbY=
github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho=
-github.com/iangcarroll/cookiemonster v1.6.0 h1:NPFkn/ZZYZgzXhJ1awRnYhZ3fJK3hKWgbctfTW21kew=
-github.com/iangcarroll/cookiemonster v1.6.0/go.mod h1:n3MvoAq56NkNyCEyhcYs3ZJMzTc9rL3w7IaITI0apMg=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/icholy/digest v1.1.0 h1:HfGg9Irj7i+IX1o1QAmPfIBNu/Q5A5Tu3n/MED9k9H4=
@@ -600,8 +596,6 @@ github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/X
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U=
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
-github.com/kataras/jwt v0.1.8 h1:u71baOsYD22HWeSOg32tCHbczPjdCk7V4MMeJqTtmGk=
-github.com/kataras/jwt v0.1.8/go.mod h1:Q5j2IkcIHnfwy+oNY3TVWuEBJNw0ADgCcXK9CaZwV4o=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
@@ -625,6 +619,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
+github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/lmittmann/tint v1.0.6 h1:vkkuDAZXc0EFGNzYjWcV0h7eEX+uujH48f/ifSkJWgc=
@@ -677,7 +673,6 @@ github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwX
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso=
-github.com/miekg/dns v1.1.35/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM=
github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI=
github.com/miekg/dns v1.1.62 h1:cN8OuEF1/x5Rq6Np+h1epln8OiyPWV+lROx9LxcGgIQ=
github.com/miekg/dns v1.1.62/go.mod h1:mvDlcItzm+br7MToIKqkglaGhlFMHJ9DTNNWONWXbNQ=
@@ -762,20 +757,14 @@ github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndr
github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s=
github.com/projectdiscovery/blackrock v0.0.1 h1:lHQqhaaEFjgf5WkuItbpeCZv2DUIE45k0VbGJyft6LQ=
github.com/projectdiscovery/blackrock v0.0.1/go.mod h1:ANUtjDfaVrqB453bzToU+YB4cUbvBRpLvEwoWIwlTss=
-github.com/projectdiscovery/dsl v0.8.17 h1:q0pKTuvI+Gov18+cra/Ql6umNUNbT0fDk3o+ooQP1yc=
-github.com/projectdiscovery/dsl v0.8.17/go.mod h1:C742lJ6Yhpe0wHXGFz8Eg9H2UyvYsBQB97p2WyAlkfU=
github.com/projectdiscovery/fastdialer v0.5.6 h1:kIBFmzbXrua41uf4fGsQClTZmT7cm7E3vVgcSj8gs6Q=
github.com/projectdiscovery/fastdialer v0.5.6/go.mod h1:QxvCe02Jii+j8vA3hWYkymgZIY8cqMgs2s3Jbz6mvbs=
github.com/projectdiscovery/goflags v0.1.74 h1:n85uTRj5qMosm0PFBfsvOL24I7TdWRcWq/1GynhXS7c=
github.com/projectdiscovery/goflags v0.1.74/go.mod h1:UMc9/7dFz2oln+10tv6cy+7WZKTHf9UGhaNkF95emh4=
github.com/projectdiscovery/gologger v1.1.68 h1:KfdIO/3X7BtHssWZuqhxPZ+A946epCCx2cz+3NnRAnU=
github.com/projectdiscovery/gologger v1.1.68/go.mod h1:Xae0t4SeqJVa0RQGK9iECx/+HfXhvq70nqOQp2BuW+o=
-github.com/projectdiscovery/gostruct v0.0.2 h1:s8gP8ApugGM4go1pA+sVlPDXaWqNP5BBDDSv7VEdG1M=
-github.com/projectdiscovery/gostruct v0.0.2/go.mod h1:H86peL4HKwMXcQQtEa6lmC8FuD9XFt6gkNR0B/Mu5PE=
github.com/projectdiscovery/hmap v0.0.100 h1:DBZ3Req9lWf4P1YC9PRa4eiMvLY0Uxud43NRBcocPfs=
github.com/projectdiscovery/hmap v0.0.100/go.mod h1:2O06pR8pHOP9wSmxAoxuM45U7E+UqOqOdlSIeddM0bA=
-github.com/projectdiscovery/katana v1.6.1 h1:Dd1MKntRLOQtvRPu72Vk8sxzpL9Igd7zol/DGXU+8sU=
-github.com/projectdiscovery/katana v1.6.1/go.mod h1:sZh1uju9+06eHCiL3a777hINx9+cvxt7o19MTtKxmdQ=
github.com/projectdiscovery/mapcidr v1.1.97 h1:7FkxNNVXp+m1rIu5Nv/2SrF9k4+LwP8QuWs2puwy+2w=
github.com/projectdiscovery/mapcidr v1.1.97/go.mod h1:9dgTJh1SP02gYZdpzMjm6vtYFkEHQHoTyaVNvaeJ7lA=
github.com/projectdiscovery/networkpolicy v0.1.37 h1:y/eGU4Mu+z8thiOrAMj9RMmxXG6Zi2Nci81cjZVkMqM=
@@ -831,14 +820,14 @@ github.com/sagernet/sing v0.7.6/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfl
github.com/sagernet/sing-vmess v0.2.7 h1:2ee+9kO0xW5P4mfe6TYVWf9VtY8k1JhNysBqsiYj0sk=
github.com/sagernet/sing-vmess v0.2.7/go.mod h1:5aYoOtYksAyS0NXDm0qKeTYW1yoE1bJVcv+XLcVoyJs=
github.com/sagikazarmark/crypt v0.3.0/go.mod h1:uD/D+6UF4SrIR1uGEv7bBNkNqLGqUr43MRiaGWX1Nig=
+github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
+github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d h1:hrujxIzL1woJ7AwssoOcM/tq5JjjG2yYOc8odClEiXA=
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU=
github.com/samuel/go-zookeeper v0.0.0-20201211165307-7117e9ea2414 h1:AJNDS0kP60X8wwWFvbLPwDuojxubj9pbfK7pjHw0vKg=
github.com/samuel/go-zookeeper v0.0.0-20201211165307-7117e9ea2414/go.mod h1:gi+0XIa01GRL2eRQVjQkKGqKF3SF9vZR/HnPullcV2E=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
-github.com/sashabaranov/go-openai v1.37.0 h1:hQQowgYm4OXJ1Z/wTrE+XZaO20BYsL0R3uRPSpfNZkY=
-github.com/sashabaranov/go-openai v1.37.0/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww=
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
@@ -946,10 +935,6 @@ github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQ
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/vbauerster/mpb/v8 v8.12.1 h1:pyj3yQ2ZGQJgUXm4h17QpR+eERaNz5OQ1ftPSEE/sMM=
github.com/vbauerster/mpb/v8 v8.12.1/go.mod h1:XLXRfStkw/6i5k0aQltijDHT1Z93fD1DVwmIdcFUp6k=
-github.com/vulncheck-oss/go-exploit v1.51.0 h1:HTmJ4Q94tbEDPb35mQZn6qMg4rT+Sw9n+L7g3Pjr+3o=
-github.com/vulncheck-oss/go-exploit v1.51.0/go.mod h1:J28w0dLnA6DnCrnBm9Sbt6smX8lvztnnN2wCXy7No6c=
-github.com/wasilibs/go-re2 v1.11.0 h1:9j4uCAJyO3IJHygeOdUJNzm6sX3P1+3xuKTbCITQrvc=
-github.com/wasilibs/go-re2 v1.11.0/go.mod h1:cDOlK/pNYGbGKRNa+NnXDJ1c8R63tWjnTjFKzoSbLv0=
github.com/wasilibs/wazero-helpers v0.0.0-20250123031827-cd30c44769bb h1:gQ+ZV4wJke/EBKYciZ2MshEouEHFuinB85dY3f5s1q8=
github.com/wasilibs/wazero-helpers v0.0.0-20250123031827-cd30c44769bb/go.mod h1:jMeV4Vpbi8osrE/pKUxRZkVaA0EX7NZN0A9/oRzgpgY=
github.com/weppos/publicsuffix-go v0.13.0/go.mod h1:z3LCPQ38eedDQSwmsSRW4Y7t2L8Ln16JPQ02lHAdn5k=
@@ -1056,7 +1041,6 @@ golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8U
golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
@@ -1151,7 +1135,6 @@ golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
-golang.org/x/net v0.0.0-20200528225125-3c3fba18258b/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
@@ -1362,7 +1345,6 @@ golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtn
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191216052735-49a3e744a425/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go
index dc5702b8..448fe54f 100644
--- a/pkg/agent/agent.go
+++ b/pkg/agent/agent.go
@@ -6,6 +6,7 @@ import (
"sync"
"github.com/chainreactors/aiscan/pkg/agent/inbox"
+ "github.com/chainreactors/aiscan/pkg/telemetry"
)
type Agent struct {
@@ -27,9 +28,9 @@ func (a *Agent) Run(ctx context.Context, prompt string) (*Result, error) {
defer cancel()
defer a.finishRun()
- cfg := a.Cfg
+ cfg := a.configSnapshot()
cfg = cfg.init()
- cfg.Messages = a.messagesSnapshot()
+ cfg.Messages = a.MessagesSnapshot()
if cfg.Inbox == nil {
cfg.Inbox = inbox.NewBuffered(SubInboxCapacity)
}
@@ -55,14 +56,61 @@ func (a *Agent) Continue(ctx context.Context) (*Result, error) {
defer cancel()
defer a.finishRun()
- cfg := a.Cfg
+ cfg := a.configSnapshot()
cfg = cfg.init()
- cfg.Messages = a.messagesSnapshot()
+ cfg.Messages = a.MessagesSnapshot()
result, runErr := runLoop(runCtx, cfg)
a.saveState(result, runErr)
return result, runErr
}
+// SetProvider hot-swaps the LLM provider (and model, when non-empty) on the
+// agent. A run already in flight keeps the provider it snapshotted at start; the
+// next run picks up the new one. Safe to call concurrently with Run/Continue.
+func (a *Agent) SetProvider(p Provider, model string) {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ a.Cfg.Provider = p
+ if model != "" {
+ a.Cfg.Model = model
+ }
+}
+
+// SetMaxTurns overrides the per-run turn cap (0 = unlimited). Applied to the
+// next Run; a run already in flight keeps the cap it snapshotted at its start.
+func (a *Agent) SetMaxTurns(n int) {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ a.Cfg.MaxTurns = n
+}
+
+func (a *Agent) SetLogger(logger telemetry.Logger) {
+ if a == nil {
+ return
+ }
+ if logger == nil {
+ logger = telemetry.NopLogger()
+ }
+ a.mu.Lock()
+ a.Cfg.Logger = logger
+ if a.Cfg.LoopScheduler != nil {
+ a.Cfg.LoopScheduler.SetLogger(logger)
+ }
+ tools := a.Cfg.Tools
+ a.mu.Unlock()
+ if tools != nil {
+ tools.SetLogger(logger)
+ }
+}
+
+// configSnapshot copies Cfg under the lock so a concurrent SetProvider can't
+// tear the read a run takes at its start.
+func (a *Agent) configSnapshot() Config {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ return a.Cfg
+}
+
// Derive creates a new Agent with the same infrastructure (provider, tools,
// model, logger) but clean state. Use for spawning independent agent tasks.
func (a *Agent) Derive() *Agent {
@@ -148,7 +196,7 @@ func (a *Agent) finishRun() {
a.running = false
}
-func (a *Agent) messagesSnapshot() []ChatMessage {
+func (a *Agent) MessagesSnapshot() []ChatMessage {
a.mu.Lock()
defer a.mu.Unlock()
return append([]ChatMessage(nil), a.state.Messages...)
diff --git a/pkg/agent/compact.go b/pkg/agent/compact.go
new file mode 100644
index 00000000..b3dda9d6
--- /dev/null
+++ b/pkg/agent/compact.go
@@ -0,0 +1,221 @@
+package agent
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "github.com/chainreactors/aiscan/pkg/agent/truncate"
+)
+
+const defaultKeepRecentTokens = 20000
+
+const compactSystemPrompt = `You are a context summarization assistant. Your task is to read a conversation between a user and an AI assistant, then produce a structured summary following the exact format specified.
+
+Do NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary.`
+
+const compactUserPrompt = `The messages above are a conversation to summarize. Create a structured context checkpoint summary that another LLM will use to continue the work.
+
+Use this EXACT format:
+
+## Goal
+[What is the user trying to accomplish? Can be multiple items if the session covers different tasks.]
+
+## Progress
+### Done
+- [x] [Completed tasks/changes]
+
+### In Progress
+- [ ] [Current work]
+
+## Key Decisions
+- **[Decision]**: [Brief rationale]
+
+## Next Steps
+1. [Ordered list of what should happen next]
+
+## Critical Context
+- [File paths, function names, error messages, or other data needed to continue]
+- [Or "(none)" if not applicable]
+
+Keep each section concise. Preserve exact file paths, function names, and error messages.`
+
+type CompactConfig struct {
+ Provider Provider
+ Model string
+ KeepRecentTokens int
+ CustomInstructions string
+}
+
+type CompactResult struct {
+ TokensBefore int
+ TokensAfter int
+ KeptMessages int
+}
+
+func (a *Agent) Compact(ctx context.Context, cfg CompactConfig) (*CompactResult, error) {
+ a.mu.Lock()
+ msgs := append([]ChatMessage(nil), a.state.Messages...)
+ bus := a.Cfg.Bus
+ if cfg.Provider == nil {
+ cfg.Provider = a.Cfg.Provider
+ }
+ if cfg.Model == "" {
+ cfg.Model = a.Cfg.Model
+ }
+ a.mu.Unlock()
+
+ if len(msgs) < 4 {
+ return nil, fmt.Errorf("nothing to compact (too few messages)")
+ }
+ if cfg.KeepRecentTokens <= 0 {
+ cfg.KeepRecentTokens = defaultKeepRecentTokens
+ }
+
+ bus.Emit(Event{Type: EventCompactStart})
+
+ tokensBefore := estimateAllTokens(msgs)
+ cutIdx := findCutPoint(msgs, cfg.KeepRecentTokens)
+ if cutIdx <= 0 {
+ bus.Emit(Event{Type: EventCompactError})
+ return nil, fmt.Errorf("nothing to compact (context already fits in %d tokens)", cfg.KeepRecentTokens)
+ }
+
+ summary, err := summarize(ctx, cfg.Provider, cfg.Model, msgs[:cutIdx], cfg.CustomInstructions)
+ if err != nil {
+ bus.Emit(Event{Type: EventCompactError})
+ return nil, fmt.Errorf("compact summarize: %w", err)
+ }
+
+ summaryMsg := NewTextMessage("user",
+ "The conversation history before this point was compacted into the following summary:\n\n\n"+
+ summary+"\n ")
+ newMsgs := make([]ChatMessage, 0, 1+len(msgs)-cutIdx)
+ newMsgs = append(newMsgs, summaryMsg)
+ newMsgs = append(newMsgs, msgs[cutIdx:]...)
+
+ result := &CompactResult{
+ TokensBefore: tokensBefore,
+ TokensAfter: estimateAllTokens(newMsgs),
+ KeptMessages: len(msgs) - cutIdx,
+ }
+
+ a.mu.Lock()
+ a.state.Messages = newMsgs
+ a.mu.Unlock()
+
+ bus.Emit(Event{
+ Type: EventCompactEnd,
+ CompactTokensBefore: result.TokensBefore,
+ CompactTokensAfter: result.TokensAfter,
+ CompactKeptMessages: result.KeptMessages,
+ })
+ return result, nil
+}
+
+func estimateMessageTokens(msg ChatMessage) int {
+ chars := 0
+ if msg.Content != nil {
+ chars += len(*msg.Content)
+ }
+ for _, part := range msg.ContentParts {
+ chars += len(part.Text)
+ }
+ if msg.ReasoningContent != nil {
+ chars += len(*msg.ReasoningContent)
+ }
+ for _, tc := range msg.ToolCalls {
+ chars += len(tc.Function.Name) + len(tc.Function.Arguments)
+ }
+ if chars == 0 {
+ return 0
+ }
+ return (chars + 3) / 4
+}
+
+func estimateAllTokens(msgs []ChatMessage) int {
+ total := 0
+ for _, m := range msgs {
+ total += estimateMessageTokens(m)
+ }
+ return total
+}
+
+// findCutPoint returns the index of the first message to keep.
+// Messages before this index are summarized; messages from this index onward are retained.
+// Returns 0 if all messages fit within keepTokens (nothing to compact).
+func findCutPoint(msgs []ChatMessage, keepTokens int) int {
+ accumulated := 0
+ for i := len(msgs) - 1; i >= 0; i-- {
+ accumulated += estimateMessageTokens(msgs[i])
+ if accumulated >= keepTokens {
+ for j := i; j < len(msgs); j++ {
+ if msgs[j].Role == "user" && msgs[j].ToolCallID == "" {
+ return j
+ }
+ }
+ return i
+ }
+ }
+ return 0
+}
+
+func serializeMessages(msgs []ChatMessage) string {
+ var sb strings.Builder
+ for _, m := range msgs {
+ content := ""
+ if m.Content != nil {
+ content = *m.Content
+ }
+ switch m.Role {
+ case "user":
+ if m.ToolCallID != "" {
+ continue
+ }
+ fmt.Fprintf(&sb, "[User]: %s\n\n", content)
+ case "assistant":
+ if content != "" {
+ fmt.Fprintf(&sb, "[Assistant]: %s\n\n", content)
+ }
+ for _, tc := range m.ToolCalls {
+ fmt.Fprintf(&sb, "[Tool Call]: %s(%s)\n\n",
+ tc.Function.Name, truncate.Clip(tc.Function.Arguments, 200))
+ }
+ case "tool":
+ fmt.Fprintf(&sb, "[Tool Result]: %s\n\n", truncate.Clip(content, 500))
+ case "system":
+ fmt.Fprintf(&sb, "[System]: %s\n\n", truncate.Clip(content, 300))
+ }
+ }
+ return sb.String()
+}
+
+func summarize(ctx context.Context, p Provider, model string, msgs []ChatMessage, customInstructions string) (string, error) {
+ prompt := compactUserPrompt
+ if customInstructions != "" {
+ prompt += "\n\nAdditional focus: " + customInstructions
+ }
+ userContent := "\n" + serializeMessages(msgs) + " \n\n" + prompt
+
+ temp := float64(0)
+ resp, err := p.ChatCompletion(ctx, &ChatCompletionRequest{
+ Model: model,
+ Messages: []ChatMessage{
+ NewTextMessage("system", compactSystemPrompt),
+ NewTextMessage("user", userContent),
+ },
+ MaxTokens: 4096,
+ Temperature: &temp,
+ })
+ if err != nil {
+ return "", fmt.Errorf("LLM call: %w", err)
+ }
+ if len(resp.Choices) == 0 {
+ return "", fmt.Errorf("no choices returned")
+ }
+ content := resp.Choices[0].Message.Content
+ if content == nil || *content == "" {
+ return "", fmt.Errorf("empty summary returned")
+ }
+ return *content, nil
+}
diff --git a/pkg/agent/compact_test.go b/pkg/agent/compact_test.go
new file mode 100644
index 00000000..c7f077db
--- /dev/null
+++ b/pkg/agent/compact_test.go
@@ -0,0 +1,155 @@
+package agent
+
+import (
+ "testing"
+)
+
+func msg(role, content string) ChatMessage {
+ return NewTextMessage(role, content)
+}
+
+func toolResult(id, content string) ChatMessage {
+ return NewToolResultMessage(id, content)
+}
+
+func TestEstimateMessageTokens(t *testing.T) {
+ tests := []struct {
+ name string
+ msg ChatMessage
+ want int
+ }{
+ {"empty", ChatMessage{Role: "user"}, 0},
+ {"short text", msg("user", "hello"), 2}, // 5 chars → ceil(5/4) = 2
+ {"exact boundary", msg("user", "abcd"), 1}, // 4 chars → 1
+ {"longer text", msg("user", "hello world, this is a test message"), 9}, // 35 chars → ceil(35/4) = 9
+ {"with tool calls", ChatMessage{
+ Role: "assistant",
+ ToolCalls: []ToolCall{{
+ Function: FunctionCall{Name: "bash", Arguments: `{"command":"ls -la"}`},
+ }},
+ }, 6}, // (4+19+3)/4 = 6
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := estimateMessageTokens(tt.msg)
+ if got != tt.want {
+ t.Errorf("estimateMessageTokens() = %d, want %d", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestEstimateAllTokens(t *testing.T) {
+ msgs := []ChatMessage{
+ msg("user", "hello"), // 2
+ msg("assistant", "world"), // 2
+ msg("user", "how are you"), // 3
+ }
+ got := estimateAllTokens(msgs)
+ want := 7
+ if got != want {
+ t.Errorf("estimateAllTokens() = %d, want %d", got, want)
+ }
+}
+
+func TestFindCutPoint(t *testing.T) {
+ longContent := make([]byte, 100000)
+ for i := range longContent {
+ longContent[i] = 'a'
+ }
+ longStr := string(longContent)
+
+ tests := []struct {
+ name string
+ msgs []ChatMessage
+ keepTokens int
+ wantIdx int
+ }{
+ {
+ "all fit within budget",
+ []ChatMessage{msg("user", "hi"), msg("assistant", "hello")},
+ 20000,
+ 0,
+ },
+ {
+ "cut at user message boundary",
+ []ChatMessage{
+ msg("user", longStr), // ~25000 tokens — old
+ msg("assistant", longStr), // ~25000 tokens — old
+ msg("user", "recent"), // kept
+ msg("assistant", "reply"), // kept
+ },
+ 20000,
+ 2, // cut before the "recent" user message
+ },
+ {
+ "skip tool results",
+ []ChatMessage{
+ msg("user", longStr),
+ msg("assistant", longStr),
+ toolResult("tc1", "result"),
+ msg("user", "recent"),
+ msg("assistant", "reply"),
+ },
+ 20000,
+ 3, // cut at the "recent" user message, skipping tool result
+ },
+ {
+ "empty messages",
+ []ChatMessage{},
+ 20000,
+ 0,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := findCutPoint(tt.msgs, tt.keepTokens)
+ if got != tt.wantIdx {
+ t.Errorf("findCutPoint() = %d, want %d", got, tt.wantIdx)
+ }
+ })
+ }
+}
+
+func TestSerializeMessages(t *testing.T) {
+ msgs := []ChatMessage{
+ msg("user", "search for bugs"),
+ msg("assistant", "I'll search now"),
+ }
+ result := serializeMessages(msgs)
+ if result == "" {
+ t.Fatal("serializeMessages returned empty string")
+ }
+ if !contains(result, "[User]: search for bugs") {
+ t.Errorf("missing user message in serialized output")
+ }
+ if !contains(result, "[Assistant]: I'll search now") {
+ t.Errorf("missing assistant message in serialized output")
+ }
+}
+
+func TestSerializeMessagesSkipsToolResultRoleUser(t *testing.T) {
+ msgs := []ChatMessage{
+ toolResult("tc1", "some tool output"),
+ }
+ result := serializeMessages(msgs)
+ if contains(result, "[User]") {
+ t.Error("tool result should not appear as User message")
+ }
+ if !contains(result, "[Tool Result]") {
+ t.Error("tool result should appear as Tool Result")
+ }
+}
+
+func contains(s, substr string) bool {
+ return len(s) >= len(substr) && searchString(s, substr)
+}
+
+func searchString(s, substr string) bool {
+ for i := 0; i <= len(s)-len(substr); i++ {
+ if s[i:i+len(substr)] == substr {
+ return true
+ }
+ }
+ return false
+}
diff --git a/pkg/agent/evaluator/loop.go b/pkg/agent/evaluator/loop.go
index 29d25b80..510b7aad 100644
--- a/pkg/agent/evaluator/loop.go
+++ b/pkg/agent/evaluator/loop.go
@@ -29,7 +29,13 @@ func RunWithEval(ctx context.Context, a *agent.Agent, cfg EvalLoopConfig) (*agen
}
for attempt := 0; attempt < cfg.MaxEvalRounds; attempt++ {
- if result.Stop != agent.StopReasonTerminated && result.Stop != agent.StopReasonCompleted {
+ // Judge whenever the run produced work worth evaluating. Only bail on a
+ // hard error or a user cancel — a run that merely hit its turn or token
+ // budget (Stopped/Budget) still did work the criteria should be checked
+ // against, and is exactly when a fresh feedback round is most useful.
+ // (The old gate skipped everything but Terminated/Completed, so a
+ // turn-capped agent silently never got evaluated.)
+ if result.Stop == agent.StopReasonError || result.Stop == agent.StopReasonCanceled {
return result, nil, nil
}
@@ -64,8 +70,14 @@ func RunWithEval(ctx context.Context, a *agent.Agent, cfg EvalLoopConfig) (*agen
}
if !verdict.InheritContext {
- cfg.Evaluator.cfg.Logger.Importantf("evaluate: resetting context (round %d)", attempt+1)
- a.Reset()
+ cfg.Evaluator.cfg.Logger.Importantf("evaluate: compacting context (round %d)", attempt+1)
+ if _, err := a.Compact(ctx, agent.CompactConfig{
+ Provider: cfg.Evaluator.cfg.Provider,
+ Model: cfg.Evaluator.cfg.Model,
+ }); err != nil {
+ cfg.Evaluator.cfg.Logger.Warnf("compact failed, falling back to reset: %s", err)
+ a.Reset()
+ }
}
cfg.Evaluator.cfg.Logger.Importantf("evaluate: injecting feedback (round %d): %s", attempt+1, feedback)
diff --git a/pkg/agent/event_json.go b/pkg/agent/event_json.go
index 65b2ddb6..1499df07 100644
--- a/pkg/agent/event_json.go
+++ b/pkg/agent/event_json.go
@@ -32,6 +32,17 @@ func (e Event) MarshalJSON() ([]byte, error) {
RequestModel string `json:"request_model,omitempty"`
RequestMessages int `json:"request_messages,omitempty"`
RequestTools int `json:"request_tools,omitempty"`
+ // Evaluator-loop verdict fields. Without these the Goal-mode per-round
+ // pass/reason never leaves the agent process, so the web UI's eval badge
+ // has nothing to render. omitempty keeps them off every non-eval event.
+ EvalRound int `json:"eval_round,omitempty"`
+ EvalPass bool `json:"eval_pass,omitempty"`
+ EvalReason string `json:"eval_reason,omitempty"`
+ EvalError string `json:"eval_error,omitempty"`
+
+ CompactTokensBefore int `json:"compact_tokens_before,omitempty"`
+ CompactTokensAfter int `json:"compact_tokens_after,omitempty"`
+ CompactKeptMessages int `json:"compact_kept_messages,omitempty"`
}{
Timestamp: ts.UTC().Format(time.RFC3339Nano),
Type: e.Type,
@@ -45,6 +56,14 @@ func (e Event) MarshalJSON() ([]byte, error) {
IsError: e.IsError,
Stop: e.Stop,
ContextTokens: e.ContextTokens,
+ EvalRound: e.EvalRound,
+ EvalPass: e.EvalPass,
+ EvalReason: e.EvalReason,
+ EvalError: e.EvalError,
+
+ CompactTokensBefore: e.CompactTokensBefore,
+ CompactTokensAfter: e.CompactTokensAfter,
+ CompactKeptMessages: e.CompactKeptMessages,
}
if e.Err != nil {
diff --git a/pkg/agent/event_json_eval_test.go b/pkg/agent/event_json_eval_test.go
new file mode 100644
index 00000000..d82fa16f
--- /dev/null
+++ b/pkg/agent/event_json_eval_test.go
@@ -0,0 +1,47 @@
+package agent
+
+import (
+ "encoding/json"
+ "testing"
+)
+
+// TestEventMarshalIncludesEvalVerdict pins the fix for the deepest layer of the
+// Goal-mode "eval badge missing" bug: Event.MarshalJSON is an allowlist, and it
+// used to omit the evaluator verdict fields entirely, so the per-round pass/
+// reason never left the agent process over the WS wire. Guard that they now
+// serialize (as snake_case, matching the hub decoder).
+func TestEventMarshalIncludesEvalVerdict(t *testing.T) {
+ e := Event{Type: EventEvalEnd, EvalRound: 2, EvalPass: true, EvalReason: "found SQLi"}
+ b, err := json.Marshal(e)
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ var got struct {
+ Type string `json:"type"`
+ EvalRound int `json:"eval_round"`
+ EvalPass bool `json:"eval_pass"`
+ EvalReason string `json:"eval_reason"`
+ }
+ if err := json.Unmarshal(b, &got); err != nil {
+ t.Fatalf("unmarshal: %v (raw=%s)", err, b)
+ }
+ if got.Type != "eval_end" || got.EvalRound != 2 || !got.EvalPass || got.EvalReason != "found SQLi" {
+ t.Fatalf("verdict not serialized: raw=%s", b)
+ }
+}
+
+// A judge error carries its message in eval_error; the hub renders it as the
+// verdict reason.
+func TestEventMarshalIncludesEvalError(t *testing.T) {
+ e := Event{Type: EventEvalError, EvalRound: 0, EvalError: "judge timed out"}
+ b, _ := json.Marshal(e)
+ var got struct {
+ EvalError string `json:"eval_error"`
+ }
+ if err := json.Unmarshal(b, &got); err != nil {
+ t.Fatalf("unmarshal: %v (raw=%s)", err, b)
+ }
+ if got.EvalError != "judge timed out" {
+ t.Fatalf("eval_error not serialized: raw=%s", b)
+ }
+}
diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go
index 8de537a8..aaaaf7fa 100644
--- a/pkg/agent/loop.go
+++ b/pkg/agent/loop.go
@@ -8,6 +8,7 @@ import (
"sync"
"time"
+ "github.com/chainreactors/aiscan/core/output"
"github.com/chainreactors/aiscan/pkg/agent/truncate"
"github.com/chainreactors/aiscan/pkg/commands"
"github.com/chainreactors/aiscan/pkg/telemetry"
@@ -252,7 +253,10 @@ func executeToolCalls(ctx context.Context, cfg Config, bus emitter, assistantMsg
slots := make([]toolCallSlot, len(toolCalls))
for i, tc := range toolCalls {
- cfg.Logger.Infof("[turn %d] tool_call id=%s name=%s args=%q", turn, tc.ID, tc.Function.Name, truncate.Clip(tc.Function.Arguments, 200))
+ cfg.Logger.Infof("[turn %d] tool_call name=%s args=%q", turn, tc.Function.Name, truncate.Clip(tc.Function.Arguments, 200))
+ slots[i] = toolCallSlot{tc: tc}
+ }
+ for _, tc := range toolCalls {
bus.Emit(Event{
Type: EventToolExecutionStart,
Turn: turn,
@@ -260,7 +264,6 @@ func executeToolCalls(ctx context.Context, cfg Config, bus emitter, assistantMsg
ToolName: tc.Function.Name,
Arguments: tc.Function.Arguments,
})
- slots[i] = toolCallSlot{tc: tc}
}
sem := make(chan struct{}, cfg.MaxParallelTools)
@@ -292,7 +295,7 @@ func executeToolCalls(ctx context.Context, cfg Config, bus emitter, assistantMsg
Err: s.result.err,
StartedAt: s.startedAt,
})
- cfg.Logger.Debugf("[turn %d] tool_result id=%s name=%s bytes=%d", turn, s.tc.ID, s.tc.Function.Name, len(s.result.result))
+ cfg.Logger.Debugf("[turn %d] tool_result name=%s bytes=%d", turn, s.tc.Function.Name, len(s.result.result))
toolMsg := toolResultToMessage(s.tc.ID, s.result)
bus.Emit(Event{Type: EventMessageStart, Turn: turn, Message: toolMsg})
bus.Emit(Event{Type: EventMessageEnd, Turn: turn, Message: toolMsg})
@@ -323,15 +326,16 @@ type toolExecution struct {
}
func runToolCall(ctx context.Context, cfg Config, assistantMsg ChatMessage, tc ToolCall, turn int) toolExecution {
- execution := beforeToolCall(ctx, cfg, assistantMsg, tc)
+ toolCtx := output.ContextWithCallID(ctx, tc.ID)
+ execution := beforeToolCall(toolCtx, cfg, assistantMsg, tc)
if execution.result == "" && !execution.isError {
- toolResult, execErr := cfg.Tools.ExecuteTool(ctx, tc.Function.Name, tc.Function.Arguments)
+ toolResult, execErr := cfg.Tools.ExecuteTool(toolCtx, tc.Function.Name, tc.Function.Arguments)
execution.result = toolResult.Text()
execution.err = execErr
execution.isError = execErr != nil || toolResult.IsError
if execErr != nil {
execution.result = fmt.Sprintf("error: %s", execErr.Error())
- cfg.Logger.Warnf("[turn %d] tool_error id=%s name=%s error=%q", turn, tc.ID, tc.Function.Name, execErr.Error())
+ cfg.Logger.Warnf("[turn %d] tool_error name=%s error=%q", turn, tc.Function.Name, execErr.Error())
}
if toolResult.Terminate {
execution.flow = ToolFlowTerminate
@@ -348,7 +352,7 @@ func runToolCall(ctx context.Context, cfg Config, assistantMsg ChatMessage, tc T
"\n\n[truncated: showing %d/%d lines (%s of %s). Refine your query or use filter/parse tools to access specific parts.]",
tr.OutputLines, tr.TotalLines, truncate.FormatSize(tr.OutputBytes), truncate.FormatSize(tr.TotalBytes))
}
- return afterToolCall(ctx, cfg, assistantMsg, tc, execution)
+ return afterToolCall(toolCtx, cfg, assistantMsg, tc, execution)
}
func (e toolExecution) eventResult() string {
diff --git a/pkg/agent/loop_scheduler.go b/pkg/agent/loop_scheduler.go
index baff77a7..03934976 100644
--- a/pkg/agent/loop_scheduler.go
+++ b/pkg/agent/loop_scheduler.go
@@ -18,10 +18,6 @@ const (
// ModeInbox pushes LoopEntry.Prompt to the inbox as a system message.
// The agent's turn loop drains it and lets the LLM decide what to do.
ModeInbox LoopMode = iota
-
- // ModeIndependent calls LoopEntry.OnFire in a goroutine.
- // Used for work that needs its own agent run (e.g. swarm heartbeat).
- ModeIndependent
)
// LoopEntry defines a single recurring task.
@@ -30,7 +26,6 @@ const (
// - If Cron is set, it drives scheduling (Interval is ignored).
// - If only Interval is set, it is used as a simple ticker.
// - ModeInbox requires Prompt.
-// - ModeIndependent requires OnFire.
type LoopEntry struct {
Name string
Cron *CronExpr
@@ -38,7 +33,6 @@ type LoopEntry struct {
Prompt string
Mode LoopMode
Immediate bool
- OnFire func(ctx context.Context, entry LoopEntry) (string, error)
CreatedAt time.Time
}
@@ -51,11 +45,11 @@ func (e LoopEntry) Schedule() string {
}
type LoopInfo struct {
- Name string `json:"name"`
- Prompt string `json:"prompt"`
- Schedule string `json:"schedule"`
- Mode LoopMode `json:"mode"`
- FireCount int `json:"fire_count"`
+ Name string `json:"name"`
+ Prompt string `json:"prompt"`
+ Schedule string `json:"schedule"`
+ Mode LoopMode `json:"mode"`
+ FireCount int `json:"fire_count"`
LastFired time.Time `json:"last_fired,omitempty"`
}
@@ -70,7 +64,6 @@ type LoopScheduler struct {
type loopState struct {
entry LoopEntry
cancel context.CancelFunc
- wg sync.WaitGroup
fireCount int
lastFired time.Time
}
@@ -78,6 +71,9 @@ type loopState struct {
const DefaultMinLoopInterval = 10 * time.Second
func NewLoopScheduler(ib inbox.Inbox, logger telemetry.Logger) *LoopScheduler {
+ if logger == nil {
+ logger = telemetry.NopLogger()
+ }
return &LoopScheduler{
loops: make(map[string]*loopState),
inbox: ib,
@@ -86,18 +82,21 @@ func NewLoopScheduler(ib inbox.Inbox, logger telemetry.Logger) *LoopScheduler {
}
}
-func (s *LoopScheduler) SetMinInterval(d time.Duration) {
+func (s *LoopScheduler) SetLogger(logger telemetry.Logger) {
+ if s == nil {
+ return
+ }
+ if logger == nil {
+ logger = telemetry.NopLogger()
+ }
s.mu.Lock()
- defer s.mu.Unlock()
- s.minInterval = d
+ s.log = logger
+ s.mu.Unlock()
}
func (s *LoopScheduler) Add(ctx context.Context, entry LoopEntry) (string, error) {
- if entry.Mode == ModeIndependent && entry.OnFire == nil {
- return "", fmt.Errorf("OnFire callback is required for ModeIndependent")
- }
- if entry.Mode == ModeInbox && strings.TrimSpace(entry.Prompt) == "" {
- return "", fmt.Errorf("prompt is required for ModeInbox")
+ if strings.TrimSpace(entry.Prompt) == "" {
+ return "", fmt.Errorf("prompt is required")
}
if entry.Cron == nil && entry.Interval == 0 {
return "", fmt.Errorf("either Cron or Interval is required")
@@ -184,28 +183,16 @@ func (s *LoopScheduler) fire(ctx context.Context, state *loopState) {
entry := state.entry
s.mu.Unlock()
- switch entry.Mode {
- case ModeInbox:
- content := fmt.Sprintf("\n%s\n ",
- entry.Name, entry.Schedule(), count, entry.Prompt)
- msg := inbox.NewMessage(inbox.OriginSystem, "user", content)
- msg.Priority = inbox.PriorityLow
- msg.Meta = map[string]any{
- "loop_name": entry.Name,
- "fire_count": count,
- }
- if err := s.inbox.Push(msg); err != nil {
- s.log.Warnf("loop=%s fire=%d inbox push failed: %s", entry.Name, count, err)
- }
-
- case ModeIndependent:
- state.wg.Add(1)
- go func() {
- defer state.wg.Done()
- if _, err := entry.OnFire(ctx, entry); err != nil {
- s.log.Warnf("loop=%s fire=%d failed: %s", entry.Name, count, err)
- }
- }()
+ content := fmt.Sprintf("\n%s\n ",
+ entry.Name, entry.Schedule(), count, entry.Prompt)
+ msg := inbox.NewMessage(inbox.OriginSystem, "user", content)
+ msg.Priority = inbox.PriorityLow
+ msg.Meta = map[string]any{
+ "loop_name": entry.Name,
+ "fire_count": count,
+ }
+ if err := s.inbox.Push(msg); err != nil {
+ s.log.Warnf("loop=%s fire=%d inbox push failed: %s", entry.Name, count, err)
}
}
@@ -219,7 +206,6 @@ func (s *LoopScheduler) Remove(name string) error {
state.cancel()
delete(s.loops, name)
s.mu.Unlock()
- state.wg.Wait()
s.log.Importantf("loop=%s deleted", name)
return nil
}
@@ -249,14 +235,9 @@ func (s *LoopScheduler) Active() int {
func (s *LoopScheduler) Stop() {
s.mu.Lock()
- states := make([]*loopState, 0, len(s.loops))
for name, state := range s.loops {
state.cancel()
- states = append(states, state)
delete(s.loops, name)
}
s.mu.Unlock()
- for _, state := range states {
- state.wg.Wait()
- }
}
diff --git a/pkg/agent/probe/conn.go b/pkg/agent/probe/conn.go
new file mode 100644
index 00000000..8dba578a
--- /dev/null
+++ b/pkg/agent/probe/conn.go
@@ -0,0 +1,325 @@
+// Package probe verifies connectivity to aiscan's external dependencies
+// (cyberhub, recon providers, search, IOA, and the LLM) using a supplied config.
+// Probe failures are reported inside the result structs rather than as returned
+// errors; a returned error only signals an unknown/untestable section.
+package probe
+
+import (
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+
+ ioaclient "github.com/chainreactors/ioa/client"
+ "github.com/chainreactors/sdk/pkg/cyberhub"
+
+ "github.com/chainreactors/aiscan/pkg/tools/search"
+ "github.com/chainreactors/aiscan/pkg/webproto"
+)
+
+// ConnCheck is the outcome of probing one external dependency. A single
+// settings section may run more than one check (Recon probes FOFA and Hunter
+// independently), so callers always receive a list and the UI renders each row.
+type ConnCheck struct {
+ Name string `json:"name"` // fofa, hunter, cyberhub, tavily, ioa
+ OK bool `json:"ok"`
+ LatencyMs int64 `json:"latency_ms"`
+ Detail string `json:"detail,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+// connProbeTimeout bounds a single connectivity check so an unreachable or
+// misconfigured endpoint fails fast instead of hanging the settings dialog.
+const connProbeTimeout = 20 * time.Second
+
+// Recon provider endpoints. Declared as vars (not consts) so tests can point
+// them at a local stub server.
+var (
+ FofaInfoEndpoint = "https://fofa.info/api/v1/info/my"
+ HunterSearchEndpoint = "https://hunter.qianxin.com/openApi/search"
+)
+
+// TestConn probes the external dependencies of one settings section using the
+// supplied (possibly partial) config. Secret fields left blank fall back to the
+// value already stored on the server (stored), mirroring the settings UI
+// convention where a configured secret is left empty to keep it unchanged. Like
+// TestLLM, probe failures are reported inside ConnCheck rather than as a
+// returned error; a non-nil error only signals an unknown/untestable section.
+func TestConn(ctx context.Context, section string, in, stored webproto.DistributeConfig) ([]ConnCheck, error) {
+ switch strings.ToLower(strings.TrimSpace(section)) {
+ case "cyberhub":
+ return testCyberhub(ctx, in, stored), nil
+ case "recon":
+ return testRecon(ctx, in, stored), nil
+ case "search":
+ return testSearch(ctx, in, stored), nil
+ case "ioa":
+ return testIOA(ctx, in, stored), nil
+ default:
+ return nil, fmt.Errorf("section %q has no connection to test", section)
+ }
+}
+
+// --- section probes ---
+
+func testCyberhub(ctx context.Context, in, stored webproto.DistributeConfig) []ConnCheck {
+ hubURL := fallbackStr(in.Cyberhub.URL, stored.Cyberhub.URL)
+ key := fallbackStr(in.Cyberhub.Key, stored.Cyberhub.Key)
+ return []ConnCheck{runCheck("cyberhub", func() (string, error) {
+ if strings.TrimSpace(hubURL) == "" {
+ return "", fmt.Errorf("cyberhub url is empty")
+ }
+ probeCtx, cancel := context.WithTimeout(ctx, connProbeTimeout)
+ defer cancel()
+ provider := cyberhub.NewProvider(hubURL, key).
+ WithTimeout(connProbeTimeout).
+ WithFilter(&cyberhub.ExportFilter{Limit: 1})
+ fingers, _, err := provider.Fingers(probeCtx)
+ if err != nil {
+ return "", err
+ }
+ return fmt.Sprintf("reachable · %d fingerprint(s) sampled", len(fingers)), nil
+ })}
+}
+
+func testRecon(ctx context.Context, in, stored webproto.DistributeConfig) []ConnCheck {
+ proxy := fallbackStr(in.Recon.Proxy, stored.Recon.Proxy)
+ var checks []ConnCheck
+
+ if fofaKey := fallbackStr(in.Recon.FofaKey, stored.Recon.FofaKey); strings.TrimSpace(fofaKey) != "" {
+ checks = append(checks, runCheck("fofa", func() (string, error) {
+ return probeFofa(ctx, fofaKey, proxy)
+ }))
+ }
+
+ // Hunter accepts either an API key or a (legacy, rarely used) web token; the
+ // API key takes precedence, matching the recon engine's credential order.
+ hunterKey := fallbackStr(in.Recon.HunterAPIKey, stored.Recon.HunterAPIKey)
+ if strings.TrimSpace(hunterKey) == "" {
+ hunterKey = fallbackStr(in.Recon.HunterToken, stored.Recon.HunterToken)
+ }
+ if strings.TrimSpace(hunterKey) != "" {
+ checks = append(checks, runCheck("hunter", func() (string, error) {
+ return probeHunter(ctx, hunterKey, proxy)
+ }))
+ }
+
+ if len(checks) == 0 {
+ checks = append(checks, ConnCheck{Name: "recon", Error: "no FOFA or Hunter credentials configured"})
+ }
+ return checks
+}
+
+func testSearch(ctx context.Context, in, stored webproto.DistributeConfig) []ConnCheck {
+ keys := fallbackStr(in.Search.TavilyKeys, stored.Search.TavilyKeys)
+ return []ConnCheck{runCheck("tavily", func() (string, error) {
+ first := firstCSV(keys)
+ if first == "" {
+ return "", fmt.Errorf("no tavily api key configured")
+ }
+ probeCtx, cancel := context.WithTimeout(ctx, connProbeTimeout)
+ defer cancel()
+ return search.ProbeTavily(probeCtx, first, "")
+ })}
+}
+
+func testIOA(ctx context.Context, in, stored webproto.DistributeConfig) []ConnCheck {
+ ioaURL := fallbackStr(in.IOA.URL, stored.IOA.URL)
+ token := fallbackStr(in.IOA.Token, stored.IOA.Token)
+ return []ConnCheck{runCheck("ioa", func() (string, error) {
+ if strings.TrimSpace(ioaURL) == "" {
+ return "", fmt.Errorf("ioa url is empty")
+ }
+ client, err := newIOAProbeClient(ioaURL, token)
+ if err != nil {
+ return "", err
+ }
+ probeCtx, cancel := context.WithTimeout(ctx, connProbeTimeout)
+ defer cancel()
+ // ListSpaces is a read-only authenticated call: it proves the server is
+ // reachable and the token is accepted without the side effect of
+ // registering a node (which EnsureRegistered would do on every click).
+ spaces, err := client.ListSpaces(probeCtx)
+ if err != nil {
+ return "", err
+ }
+ return fmt.Sprintf("connected · %d space(s)", len(spaces)), nil
+ })}
+}
+
+// --- provider probes ---
+
+// redactURLError strips the query string from a *url.Error's URL so a secret
+// carried as a query parameter (e.g. a FOFA/Hunter API key) is never surfaced
+// in an error message. Non-url.Error values pass through unchanged.
+func redactURLError(err error) error {
+ var ue *url.Error
+ if errors.As(err, &ue) {
+ if i := strings.IndexByte(ue.URL, '?'); i >= 0 {
+ ue.URL = ue.URL[:i] + "?"
+ }
+ }
+ return err
+}
+
+// probeGetJSON issues a bounded GET to endpoint through the (optionally proxied)
+// probe client, returning the response body and failing on any non-200 status.
+func probeGetJSON(ctx context.Context, endpoint, proxy string) ([]byte, error) {
+ probeCtx, cancel := context.WithTimeout(ctx, connProbeTimeout)
+ defer cancel()
+ req, err := http.NewRequestWithContext(probeCtx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Accept", "application/json")
+
+ transport := &http.Transport{Proxy: http.ProxyFromEnvironment}
+ if strings.TrimSpace(proxy) != "" {
+ if proxyURL, err := url.Parse(proxy); err == nil {
+ transport.Proxy = http.ProxyURL(proxyURL)
+ }
+ }
+ client := &http.Client{Timeout: connProbeTimeout, Transport: transport}
+
+ resp, err := client.Do(req)
+ if err != nil {
+ // The provider key rides in the query string (FOFA/Hunter have no
+ // header/body auth), and *url.Error.Error() echoes the full URL — which
+ // then flows into ConnCheck.Error and back to the client/logs. Strip the
+ // query before surfacing so the (write-only) key never leaks.
+ return nil, redactURLError(err)
+ }
+ defer resp.Body.Close()
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
+ if resp.StatusCode != http.StatusOK {
+ s := strings.TrimSpace(string(body))
+ if len(s) > 200 {
+ s = s[:200] + "…"
+ }
+ return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, s)
+ }
+ return body, nil
+}
+
+// probeFofa validates a FOFA key via the account-info endpoint, which checks
+// the credential without consuming a search quota.
+func probeFofa(ctx context.Context, key, proxy string) (string, error) {
+ body, err := probeGetJSON(ctx, FofaInfoEndpoint+"?key="+url.QueryEscape(key), proxy)
+ if err != nil {
+ return "", err
+ }
+
+ var r struct {
+ Error bool `json:"error"`
+ Errmsg string `json:"errmsg"`
+ Email string `json:"email"`
+ Username string `json:"username"`
+ FofaPoint json.Number `json:"fofa_point"`
+ }
+ if err := json.Unmarshal(body, &r); err != nil {
+ return "", fmt.Errorf("parse FOFA response: %w", err)
+ }
+ if r.Error {
+ if r.Errmsg != "" {
+ return "", fmt.Errorf("%s", r.Errmsg)
+ }
+ return "", fmt.Errorf("FOFA rejected the key")
+ }
+ who := r.Username
+ if who == "" {
+ who = r.Email
+ }
+ if who == "" {
+ who = "key valid"
+ }
+ if r.FofaPoint != "" {
+ return fmt.Sprintf("%s · %s points", who, r.FofaPoint.String()), nil
+ }
+ return who, nil
+}
+
+// probeHunter validates a Hunter key with the smallest possible search. Hunter
+// has no free account-info endpoint, so this consumes one minimal query.
+func probeHunter(ctx context.Context, key, proxy string) (string, error) {
+ now := time.Now()
+ params := url.Values{}
+ params.Set("api-key", key)
+ params.Set("search", base64.URLEncoding.EncodeToString([]byte(`ip="1.1.1.1"`)))
+ params.Set("page", "1")
+ params.Set("page_size", "1")
+ params.Set("is_web", "3")
+ params.Set("start_time", now.AddDate(0, 0, -1).Format("2006-01-02 15:04:05"))
+ params.Set("end_time", now.Format("2006-01-02 15:04:05"))
+
+ body, err := probeGetJSON(ctx, HunterSearchEndpoint+"?"+params.Encode(), proxy)
+ if err != nil {
+ return "", err
+ }
+
+ var r struct {
+ Code int `json:"code"`
+ Message string `json:"message"`
+ Data struct {
+ Total int `json:"total"`
+ } `json:"data"`
+ }
+ if err := json.Unmarshal(body, &r); err != nil {
+ return "", fmt.Errorf("parse Hunter response: %w", err)
+ }
+ if r.Code != 200 {
+ if r.Message != "" {
+ return "", fmt.Errorf("code %d: %s", r.Code, r.Message)
+ }
+ return "", fmt.Errorf("Hunter returned code %d", r.Code)
+ }
+ return fmt.Sprintf("key valid · %d total", r.Data.Total), nil
+}
+
+func newIOAProbeClient(rawURL, token string) (*ioaclient.Client, error) {
+ if strings.TrimSpace(token) != "" {
+ return ioaclient.NewClientWithToken(rawURL, token)
+ }
+ // No explicit token: NewClient still extracts an access key from a
+ // userinfo-style URL (http://token@host:port).
+ return ioaclient.NewClient(rawURL, "")
+}
+
+// --- helpers ---
+
+// runCheck times fn and folds its outcome into a ConnCheck.
+func runCheck(name string, fn func() (string, error)) ConnCheck {
+ start := time.Now()
+ detail, err := fn()
+ c := ConnCheck{Name: name, LatencyMs: time.Since(start).Milliseconds()}
+ if err != nil {
+ c.Error = err.Error()
+ return c
+ }
+ c.OK = true
+ c.Detail = detail
+ return c
+}
+
+// fallbackStr returns in when non-blank, otherwise the stored value.
+func fallbackStr(in, stored string) string {
+ if strings.TrimSpace(in) != "" {
+ return in
+ }
+ return stored
+}
+
+func firstCSV(s string) string {
+ for _, part := range strings.Split(s, ",") {
+ if p := strings.TrimSpace(part); p != "" {
+ return p
+ }
+ }
+ return ""
+}
+
diff --git a/pkg/agent/probe/llm.go b/pkg/agent/probe/llm.go
new file mode 100644
index 00000000..b73f614c
--- /dev/null
+++ b/pkg/agent/probe/llm.go
@@ -0,0 +1,161 @@
+package probe
+
+import (
+ "context"
+ "strings"
+ "time"
+
+ "github.com/chainreactors/aiscan/pkg/agent"
+)
+
+// LLMProbeRequest carries the connection parameters the user wants to verify
+// or use for model enumeration. It mirrors the LLM section of
+// webproto.DistributeConfig. An empty APIKey means "use the key already stored
+// in the config" (matching the settings UI where a configured key is left blank
+// to keep it unchanged). Model is only required for TestLLM; ListLLMModels
+// ignores it.
+type LLMProbeRequest struct {
+ Provider string `json:"provider"`
+ BaseURL string `json:"base_url"`
+ APIKey string `json:"api_key"`
+ Model string `json:"model,omitempty"`
+ Proxy string `json:"proxy"`
+}
+
+// LLMTestResult reports whether a probe request reached the provider and
+// returned a usable completion.
+type LLMTestResult struct {
+ OK bool `json:"ok"`
+ Provider string `json:"provider"`
+ Model string `json:"model"`
+ LatencyMs int64 `json:"latency_ms"`
+ Reply string `json:"reply,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+// llmProbeTimeout bounds a single connectivity test so a misconfigured or
+// unreachable endpoint fails fast instead of hanging the settings dialog.
+const llmProbeTimeout = 30 * time.Second
+
+
+// LLMModelsResult reports the model IDs discovered at the endpoint. ok=false
+// carries the reason (unsupported provider, auth failure, unreachable, …).
+type LLMModelsResult struct {
+ OK bool `json:"ok"`
+ Models []string `json:"models,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+// modelLister is the optional capability a provider implements when its
+// endpoint exposes a model catalog (the OpenAI-compatible GET /models route).
+type modelLister interface {
+ ListModels(ctx context.Context) ([]string, error)
+}
+
+// ListLLMModels asks the configured endpoint for its model catalog so the
+// settings UI can offer a picklist instead of requiring the model to be typed
+// by hand. Like TestLLM it never returns a transport error — failures are
+// captured inside LLMModelsResult. When req.APIKey is blank, storedAPIKey is
+// used (the settings UI leaves a configured key blank to keep it unchanged).
+func ListLLMModels(ctx context.Context, req LLMProbeRequest, storedAPIKey string) (LLMModelsResult, error) {
+ apiKey := strings.TrimSpace(req.APIKey)
+ if apiKey == "" {
+ apiKey = strings.TrimSpace(storedAPIKey)
+ }
+
+ cfg := agent.ProviderConfig{
+ Provider: strings.TrimSpace(req.Provider),
+ BaseURL: strings.TrimSpace(req.BaseURL),
+ APIKey: apiKey,
+ Proxy: strings.TrimSpace(req.Proxy),
+ Timeout: int(llmProbeTimeout / time.Second),
+ }
+
+ var result LLMModelsResult
+
+ prov, err := agent.NewProvider(&cfg)
+ if err != nil {
+ result.Error = err.Error()
+ return result, nil
+ }
+
+ lister, ok := prov.(modelLister)
+ if !ok {
+ result.Error = "provider does not support listing models"
+ return result, nil
+ }
+
+ probeCtx, cancel := context.WithTimeout(ctx, llmProbeTimeout)
+ defer cancel()
+
+ models, err := lister.ListModels(probeCtx)
+ if err != nil {
+ result.Error = err.Error()
+ return result, nil
+ }
+
+ result.OK = true
+ result.Models = models
+ return result, nil
+}
+
+// TestLLM issues a minimal chat completion against the supplied LLM settings
+// and reports the outcome. It never returns a transport error to the caller —
+// failures are captured inside LLMTestResult so the UI can render them. A nil
+// error only signals the request was well-formed enough to attempt. When
+// req.APIKey is blank, storedAPIKey is used (the settings UI leaves a configured
+// key blank to keep it unchanged).
+func TestLLM(ctx context.Context, req LLMProbeRequest, storedAPIKey string) (LLMTestResult, error) {
+ apiKey := strings.TrimSpace(req.APIKey)
+ if apiKey == "" {
+ apiKey = strings.TrimSpace(storedAPIKey)
+ }
+
+ cfg := agent.ProviderConfig{
+ Provider: strings.TrimSpace(req.Provider),
+ BaseURL: strings.TrimSpace(req.BaseURL),
+ APIKey: apiKey,
+ Model: strings.TrimSpace(req.Model),
+ Proxy: strings.TrimSpace(req.Proxy),
+ Timeout: int(llmProbeTimeout / time.Second),
+ }
+
+ result := LLMTestResult{Provider: cfg.Provider, Model: cfg.Model}
+
+ if cfg.Model == "" {
+ result.Error = "model is required"
+ return result, nil
+ }
+
+ prov, err := agent.NewProvider(&cfg)
+ if err != nil {
+ result.Error = err.Error()
+ return result, nil
+ }
+
+ probeCtx, cancel := context.WithTimeout(ctx, llmProbeTimeout)
+ defer cancel()
+
+ maxTokens := 16
+ start := time.Now()
+ resp, err := prov.ChatCompletion(probeCtx, &agent.ChatCompletionRequest{
+ Model: cfg.Model,
+ Messages: []agent.ChatMessage{agent.NewTextMessage("user", "ping")},
+ MaxTokens: maxTokens,
+ })
+ result.LatencyMs = time.Since(start).Milliseconds()
+ if err != nil {
+ result.Error = err.Error()
+ return result, nil
+ }
+ if len(resp.Choices) == 0 {
+ result.Error = "provider returned no choices"
+ return result, nil
+ }
+
+ result.OK = true
+ if msg := resp.Choices[0].Message; msg.Content != nil {
+ result.Reply = strings.TrimSpace(*msg.Content)
+ }
+ return result, nil
+}
diff --git a/pkg/agent/provider/anthropic.go b/pkg/agent/provider/anthropic.go
index 42135e2c..8016c466 100644
--- a/pkg/agent/provider/anthropic.go
+++ b/pkg/agent/provider/anthropic.go
@@ -62,7 +62,7 @@ func (p *AnthropicProvider) ChatCompletion(ctx context.Context, req *ChatComplet
ctx, "POST", p.completionEndpoint(), bodyBytes, p.setAuthHeaders,
)
if err != nil {
- return nil, err
+ return nil, hint404(err, p.completionEndpoint(), "OpenAI", "openai")
}
result, err := parseAnthropicResponse(data)
@@ -91,10 +91,14 @@ func (p *AnthropicProvider) ChatCompletionStream(ctx context.Context, req *ChatC
}
parser := &anthropicStreamParser{}
- return streamSSE(ctx, p.client, timeoutFromConfig(p.config.Timeout),
+ events, err := streamSSE(ctx, p.client, timeoutFromConfig(p.config.Timeout),
p.completionEndpoint(), bodyBytes, p.setAuthHeaders,
parser.parse,
)
+ if err != nil {
+ return nil, hint404(err, p.completionEndpoint(), "OpenAI", "openai")
+ }
+ return events, nil
}
func (p *AnthropicProvider) completionEndpoint() string {
@@ -112,6 +116,42 @@ func (p *AnthropicProvider) setAuthHeaders(req *http.Request) {
req.Header.Set("anthropic-version", anthropicVersion)
}
+func (p *AnthropicProvider) modelsEndpoint() string {
+ base := strings.TrimSuffix(p.config.BaseURL, "/")
+ base = strings.TrimSuffix(base, "/messages")
+ return strings.TrimSuffix(base, "/") + "/models"
+}
+
+// ListModels enumerates the model IDs advertised by GET {base}/models. The
+// Anthropic Messages API and the OpenAI-compatible gateways that front it both
+// answer this route with a {"data":[{"id":...}]} list, so the settings UI can
+// offer a model picklist under provider=anthropic instead of erroring with
+// "provider does not support listing models". Implementing this satisfies the
+// probe's modelLister interface for the Anthropic provider.
+func (p *AnthropicProvider) ListModels(ctx context.Context) ([]string, error) {
+ data, err := (&apiRequest{client: p.client, timeout: timeoutFromConfig(p.config.Timeout)}).do(
+ ctx, "GET", p.modelsEndpoint(), nil, p.setAuthHeaders,
+ )
+ if err != nil {
+ return nil, err
+ }
+ var result struct {
+ Data []struct {
+ ID string `json:"id"`
+ } `json:"data"`
+ }
+ if err := json.Unmarshal(data, &result); err != nil {
+ return nil, fmt.Errorf("unmarshal models: %w", err)
+ }
+ ids := make([]string, 0, len(result.Data))
+ for _, m := range result.Data {
+ if id := strings.TrimSpace(m.ID); id != "" {
+ ids = append(ids, id)
+ }
+ }
+ return ids, nil
+}
+
type cacheControlMarker struct {
Type string `json:"type"`
}
diff --git a/pkg/agent/provider/capability_parity_test.go b/pkg/agent/provider/capability_parity_test.go
new file mode 100644
index 00000000..3c8f7fe2
--- /dev/null
+++ b/pkg/agent/provider/capability_parity_test.go
@@ -0,0 +1,22 @@
+package provider
+
+import "context"
+
+// Compile-time capability parity guard: every optional capability the app
+// asserts at runtime must be satisfied by BOTH providers, or provider=anthropic
+// silently loses features (the ListModels bug). If either line stops compiling,
+// a capability gap was reintroduced.
+var (
+ _ interface {
+ ListModels(context.Context) ([]string, error)
+ } = (*OpenAIProvider)(nil)
+ _ interface {
+ ListModels(context.Context) ([]string, error)
+ } = (*AnthropicProvider)(nil)
+ _ StreamingProvider = (*OpenAIProvider)(nil)
+ _ StreamingProvider = (*AnthropicProvider)(nil)
+ _ WebSearchProvider = (*OpenAIProvider)(nil)
+ _ WebSearchProvider = (*AnthropicProvider)(nil)
+ _ interface{ DisableImages() } = (*OpenAIProvider)(nil)
+ _ interface{ DisableImages() } = (*AnthropicProvider)(nil)
+)
diff --git a/pkg/agent/provider/endpoint_hint_test.go b/pkg/agent/provider/endpoint_hint_test.go
new file mode 100644
index 00000000..b14e0b77
--- /dev/null
+++ b/pkg/agent/provider/endpoint_hint_test.go
@@ -0,0 +1,66 @@
+package provider
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+// A 404 on the chat endpoint must surface as an actionable protocol-mismatch
+// hint (not a bare "API error (404): " with an empty body, which is what an
+// Anthropic-only gateway returns for /chat/completions). The underlying
+// *APIError must still unwrap so retry classification is unchanged.
+func TestChatCompletion404GivesProtocolHint(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusNotFound) // empty body, like a wrong-protocol gateway
+ }))
+ defer srv.Close()
+
+ cases := []struct {
+ name string
+ provider string
+ wantProvider string // the provider the hint should steer the user toward
+ }{
+ {"openai endpoint 404 suggests anthropic", "openai", "llm.provider=anthropic"},
+ {"anthropic endpoint 404 suggests openai", "anthropic", "llm.provider=openai"},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ p, err := NewProvider(&ProviderConfig{Provider: tc.provider, BaseURL: srv.URL + "/v1", APIKey: "k", Model: "m"})
+ if err != nil {
+ t.Fatalf("NewProvider: %v", err)
+ }
+ _, err = p.ChatCompletion(context.Background(), &ChatCompletionRequest{
+ Messages: []ChatMessage{NewTextMessage("user", "hi")},
+ })
+ if err == nil {
+ t.Fatal("expected a 404 error")
+ }
+ if !strings.Contains(err.Error(), tc.wantProvider) {
+ t.Fatalf("error missing actionable hint %q: %v", tc.wantProvider, err)
+ }
+ // The wrapped *APIError must remain reachable and non-retryable.
+ var apiErr *APIError
+ if !errors.As(err, &apiErr) || apiErr.StatusCode != 404 {
+ t.Fatalf("underlying *APIError(404) lost after wrapping: %v", err)
+ }
+ if apiErr.IsRetryable() {
+ t.Fatal("a 404 must stay non-retryable")
+ }
+ })
+ }
+}
+
+// The official Anthropic endpoint is unambiguous and must infer the anthropic
+// protocol even with a blank provider; arbitrary gateways stay openai-default.
+func TestInferFromBaseURLAnthropicOfficial(t *testing.T) {
+ if got := InferFromBaseURL("https://api.anthropic.com/v1"); got != "anthropic" {
+ t.Fatalf("InferFromBaseURL(api.anthropic.com) = %q, want anthropic", got)
+ }
+ if got := InferFromBaseURL("https://gateway.example.com/v1"); got != "openai" {
+ t.Fatalf("custom gateway must still default to openai, got %q", got)
+ }
+}
diff --git a/pkg/agent/provider/http.go b/pkg/agent/provider/http.go
index 6abbe735..b38f89cb 100644
--- a/pkg/agent/provider/http.go
+++ b/pkg/agent/provider/http.go
@@ -81,11 +81,26 @@ func (r *apiRequest) do(ctx context.Context, method, endpoint string, body []byt
return nil, wrapReadError(parentCtx, callTimedOut.Load(), r.timeout, "read response", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
- return nil, &APIError{StatusCode: resp.StatusCode, Message: string(data)}
+ return nil, &APIError{StatusCode: resp.StatusCode, Message: string(data), Header: resp.Header.Clone()}
}
return data, nil
}
+// hint404 enriches a 404 from a chat endpoint with an actionable cause. A 404 on
+// the chat POST almost always means the path does not exist: a wrong base_url,
+// or — most often with third-party gateways — a protocol mismatch, where the
+// endpoint does not speak this provider's wire protocol (e.g. an Anthropic-only
+// gateway has no /chat/completions). The underlying *APIError is preserved via
+// %w, so retry classification and errors.As stay intact.
+func hint404(err error, endpoint, altProtocol, altProvider string) error {
+ var apiErr *APIError
+ if !errors.As(err, &apiErr) || apiErr.StatusCode != 404 {
+ return err
+ }
+ return fmt.Errorf("%w — POST %s not found (404); verify llm.base_url, or if this gateway speaks the %s protocol set llm.provider=%s",
+ err, endpoint, altProtocol, altProvider)
+}
+
func doJSON(ctx context.Context, client *http.Client, timeout time.Duration, method, endpoint string, payload any, setHeaders func(*http.Request)) ([]byte, error) {
body, err := json.Marshal(payload)
if err != nil {
@@ -131,7 +146,7 @@ func streamSSE(
if readErr != nil {
return nil, wrapReadError(ctx, timedOut, timeout, "read response", readErr)
}
- return nil, &APIError{StatusCode: resp.StatusCode, Message: string(respBody)}
+ return nil, &APIError{StatusCode: resp.StatusCode, Message: string(respBody), Header: resp.Header.Clone()}
}
var stallDetected atomic.Bool
diff --git a/pkg/agent/provider/openai.go b/pkg/agent/provider/openai.go
index 19419f5a..12b08f3a 100644
--- a/pkg/agent/provider/openai.go
+++ b/pkg/agent/provider/openai.go
@@ -56,7 +56,7 @@ func (p *OpenAIProvider) ChatCompletion(ctx context.Context, req *ChatCompletion
ctx, "POST", p.completionEndpoint(), bodyBytes, p.setAuthHeaders,
)
if err != nil {
- return nil, err
+ return nil, hint404(err, p.completionEndpoint(), "Anthropic", "anthropic")
}
var result ChatCompletionResponse
@@ -83,12 +83,16 @@ func (p *OpenAIProvider) ChatCompletionStream(ctx context.Context, req *ChatComp
return nil, fmt.Errorf("marshal request: %w", err)
}
- return streamSSE(ctx, p.client, timeoutFromConfig(p.config.Timeout),
+ events, err := streamSSE(ctx, p.client, timeoutFromConfig(p.config.Timeout),
p.completionEndpoint(), bodyBytes, p.setAuthHeaders,
func(_ string, data []byte) (ChatCompletionStreamEvent, error) {
return parseOpenAIStreamChunk(data)
},
)
+ if err != nil {
+ return nil, hint404(err, p.completionEndpoint(), "Anthropic", "anthropic")
+ }
+ return events, nil
}
func (p *OpenAIProvider) completionEndpoint() string {
@@ -96,6 +100,38 @@ func (p *OpenAIProvider) completionEndpoint() string {
return base + "/chat/completions"
}
+func (p *OpenAIProvider) modelsEndpoint() string {
+ base := strings.TrimSuffix(p.config.BaseURL, "/")
+ return base + "/models"
+}
+
+// ListModels enumerates the model IDs the endpoint advertises via the
+// OpenAI-compatible GET /models route. Most third-party gateways implement it,
+// so the settings UI can offer a picklist instead of a free-text field.
+func (p *OpenAIProvider) ListModels(ctx context.Context) ([]string, error) {
+ data, err := (&apiRequest{client: p.client, timeout: timeoutFromConfig(p.config.Timeout)}).do(
+ ctx, "GET", p.modelsEndpoint(), nil, p.setAuthHeaders,
+ )
+ if err != nil {
+ return nil, err
+ }
+ var result struct {
+ Data []struct {
+ ID string `json:"id"`
+ } `json:"data"`
+ }
+ if err := json.Unmarshal(data, &result); err != nil {
+ return nil, fmt.Errorf("unmarshal models: %w", err)
+ }
+ ids := make([]string, 0, len(result.Data))
+ for _, m := range result.Data {
+ if id := strings.TrimSpace(m.ID); id != "" {
+ ids = append(ids, id)
+ }
+ }
+ return ids, nil
+}
+
func (p *OpenAIProvider) setAuthHeaders(req *http.Request) {
if p.config.APIKey != "" {
req.Header.Set("Authorization", "Bearer "+p.config.APIKey)
diff --git a/pkg/agent/provider/provider.go b/pkg/agent/provider/provider.go
index 123d4a92..87926f39 100644
--- a/pkg/agent/provider/provider.go
+++ b/pkg/agent/provider/provider.go
@@ -115,11 +115,18 @@ func inferImageSupport(provider, model string) bool {
return false
}
-// InferFromBaseURL returns a default provider type when --provider is not
-// set. Most third-party endpoints speak the OpenAI protocol, so "openai" is
-// the safest default. Users who need Anthropic protocol must pass --provider
-// explicitly.
-func InferFromBaseURL(_ string) string {
+// InferFromBaseURL guesses the wire protocol from the base URL when --provider
+// is not set. The official Anthropic endpoint is unambiguous, so it is detected
+// directly. Everything else — including custom third-party gateways — speaks the
+// OpenAI protocol in the common case, so "openai" stays the default. The
+// protocol genuinely cannot be sniffed for an arbitrary gateway (a gateway may
+// serve, e.g., glm models over the Anthropic protocol), so a wrong guess is
+// caught later as an actionable 404 from the provider (see hint404), not a
+// silent failure.
+func InferFromBaseURL(baseURL string) string {
+ if strings.Contains(strings.ToLower(baseURL), "anthropic.com") {
+ return "anthropic"
+ }
return "openai"
}
diff --git a/pkg/agent/provider/types.go b/pkg/agent/provider/types.go
index 977bb328..cf868ad7 100644
--- a/pkg/agent/provider/types.go
+++ b/pkg/agent/provider/types.go
@@ -3,8 +3,8 @@ package provider
import (
"encoding/json"
"fmt"
+ "net/http"
"strings"
-
)
// CacheRetention controls prompt caching behavior across providers.
@@ -236,10 +236,11 @@ func (u *Usage) UnmarshalJSON(data []byte) error {
}
type APIError struct {
- Message string `json:"message"`
- Type string `json:"type"`
- Code string `json:"code"`
- StatusCode int `json:"-"`
+ Message string `json:"message"`
+ Type string `json:"type"`
+ Code string `json:"code"`
+ StatusCode int `json:"-"`
+ Header http.Header `json:"-"`
}
func (e *APIError) Error() string {
@@ -254,7 +255,7 @@ func (e *APIError) Error() string {
func (e *APIError) IsRetryable() bool {
switch e.StatusCode {
- case 429, 500, 502, 503, 529:
+ case 408, 409, 429, 500, 502, 503, 529:
return true
default:
return false
diff --git a/pkg/agent/provider_swap_test.go b/pkg/agent/provider_swap_test.go
new file mode 100644
index 00000000..1cb24210
--- /dev/null
+++ b/pkg/agent/provider_swap_test.go
@@ -0,0 +1,70 @@
+package agent
+
+import (
+ "context"
+ "fmt"
+ "testing"
+)
+
+// TestSetProviderHotSwapsNextRun verifies a mid-conversation provider swap takes
+// effect on the next run (an in-flight run keeps its snapshotted provider).
+func TestSetProviderHotSwapsNextRun(t *testing.T) {
+ provA := &callbackProvider{fn: func(_ context.Context, _ *ChatCompletionRequest) (*ChatCompletionResponse, error) {
+ return chatResponse(NewTextMessage("assistant", "from-A")), nil
+ }}
+ provB := &callbackProvider{fn: func(_ context.Context, _ *ChatCompletionRequest) (*ChatCompletionResponse, error) {
+ return chatResponse(NewTextMessage("assistant", "from-B")), nil
+ }}
+
+ ag := NewAgent(Config{Provider: provA, Model: "model-a"})
+
+ res, err := ag.Run(context.Background(), "hi")
+ if err != nil {
+ t.Fatalf("run A: %v", err)
+ }
+ if res.Output != "from-A" {
+ t.Fatalf("run A output = %q, want from-A", res.Output)
+ }
+
+ ag.SetProvider(provB, "model-b")
+
+ res, err = ag.Run(context.Background(), "hi again")
+ if err != nil {
+ t.Fatalf("run B: %v", err)
+ }
+ if res.Output != "from-B" {
+ t.Fatalf("run B output = %q, want from-B", res.Output)
+ }
+ if ag.Cfg.Model != "model-b" {
+ t.Fatalf("model = %q, want model-b", ag.Cfg.Model)
+ }
+
+ // Empty model must not blank the current one (provider-only swap).
+ ag.SetProvider(provA, "")
+ if ag.Cfg.Model != "model-b" {
+ t.Fatalf("empty-model swap changed model to %q, want model-b", ag.Cfg.Model)
+ }
+}
+
+// TestSetProviderRaceWithRun exercises a config push swapping the provider while
+// runs execute; run under -race it proves the Cfg read/write are serialized.
+func TestSetProviderRaceWithRun(t *testing.T) {
+ prov := &callbackProvider{fn: func(_ context.Context, _ *ChatCompletionRequest) (*ChatCompletionResponse, error) {
+ return chatResponse(NewTextMessage("assistant", "ok")), nil
+ }}
+ ag := NewAgent(Config{Provider: prov, Model: "m"})
+
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ for i := 0; i < 50; i++ {
+ ag.SetProvider(prov, fmt.Sprintf("m-%d", i))
+ }
+ }()
+ for i := 0; i < 50; i++ {
+ if _, err := ag.Run(context.Background(), "hi"); err != nil {
+ t.Errorf("run %d: %v", i, err)
+ }
+ }
+ <-done
+}
diff --git a/pkg/agent/retry.go b/pkg/agent/retry.go
index b596dacf..2b35d997 100644
--- a/pkg/agent/retry.go
+++ b/pkg/agent/retry.go
@@ -4,7 +4,9 @@ import (
"context"
"errors"
"fmt"
+ "math/rand/v2"
"net"
+ "strconv"
"strings"
"time"
@@ -18,6 +20,12 @@ type imageDisabler interface {
var errEmptyResponse = errors.New("empty response from LLM")
+const (
+ baseRetryDelay = 500 * time.Millisecond
+ maxRetryDelay = 32 * time.Second
+ retryJitterFactor = 0.25
+)
+
func isRetryableError(err error) bool {
if err == nil {
return false
@@ -73,6 +81,10 @@ func isRetryableByMessage(err error) bool {
return false
}
+// RetryDelay returns the backoff duration for the given attempt index (0-based).
+// It keeps the original conservative policy (1s·2^attempt, capped at 10s) for
+// backward compatibility with external callers such as runner and webagent
+// reconnect logic.
func RetryDelay(attempt int) time.Duration {
delay := time.Second << uint(attempt)
if delay > 10*time.Second {
@@ -81,6 +93,57 @@ func RetryDelay(attempt int) time.Duration {
return delay
}
+// retryDelayFor computes the backoff for an LLM call retry. It honors a
+// Retry-After header when the error carries one (server directive wins and
+// bypasses both the backoff formula and the cap); otherwise it falls back to
+// exponential backoff with additive jitter: min(base·2^attempt, maxDelay) + jitter.
+func retryDelayFor(attempt int, err error) time.Duration {
+ if after := retryAfterFromError(err); after > 0 {
+ return after
+ }
+ return computeRetryDelay(attempt, rand.Float64())
+}
+
+// retryAfterFromError parses a Retry-After header (integer seconds form) from
+// an APIError, if present. Returns 0 when absent or unparseable.
+func retryAfterFromError(err error) time.Duration {
+ var apiErr *APIError
+ if !errors.As(err, &apiErr) {
+ return 0
+ }
+ if apiErr.Header == nil {
+ return 0
+ }
+ val := strings.TrimSpace(apiErr.Header.Get("Retry-After"))
+ if val == "" {
+ return 0
+ }
+ secs, perr := strconv.Atoi(val)
+ if perr != nil || secs < 0 {
+ return 0
+ }
+ return time.Duration(secs) * time.Second
+}
+
+// computeRetryDelay is the exponential backoff + additive jitter core used by
+// the LLM retry loop. Formula: min(baseRetryDelay·2^attempt, maxRetryDelay),
+// then add random jitter in [0, retryJitterFactor·delay).
+func computeRetryDelay(attempt int, jitterFrac float64) time.Duration {
+ if attempt < 0 {
+ attempt = 0
+ }
+ // baseDelay·2^attempt, capped at maxDelay
+ delay := baseRetryDelay << uint(attempt)
+ if delay > maxRetryDelay || delay <= 0 {
+ delay = maxRetryDelay
+ }
+ if jitterFrac > 0 {
+ // additive jitter: delay += random·[0, jitterFactor·delay)
+ delay += time.Duration(jitterFrac * retryJitterFactor * float64(delay))
+ }
+ return delay
+}
+
func requestWithRetry(ctx context.Context, cfg Config, bus emitter, messages []ChatMessage, tools []ToolDefinition, turn int) (ChatMessage, *Usage, error) {
var lastErr error
maxAttempts := cfg.MaxRetries + 1
@@ -89,7 +152,7 @@ func requestWithRetry(ctx context.Context, cfg Config, bus emitter, messages []C
}
for attempt := 0; attempt < maxAttempts; attempt++ {
if attempt > 0 {
- delay := RetryDelay(attempt - 1)
+ delay := retryDelayFor(attempt-1, lastErr)
cfg.Logger.Warnf("retrying LLM call (attempt %d/%d) after %s: %v", attempt+1, maxAttempts, delay, lastErr)
select {
case <-time.After(delay):
diff --git a/pkg/agent/retry_test.go b/pkg/agent/retry_test.go
index 3229fd40..e65207a1 100644
--- a/pkg/agent/retry_test.go
+++ b/pkg/agent/retry_test.go
@@ -3,8 +3,10 @@ package agent
import (
"context"
"fmt"
+ "net/http"
"strings"
"testing"
+ "time"
"github.com/chainreactors/aiscan/core/eventbus"
"github.com/chainreactors/aiscan/pkg/agent/provider"
@@ -379,3 +381,131 @@ func TestInferImageSupportModelRegistry(t *testing.T) {
})
}
}
+
+// --- Backoff & Retry-After parsing tests ---
+
+func TestRetryDelayBackoffSequence(t *testing.T) {
+ // RetryDelay keeps the original conservative policy (1s·2^attempt, cap 10s)
+ // for backward compatibility with external callers (runner, webagent).
+ want := []time.Duration{
+ 1 * time.Second,
+ 2 * time.Second,
+ 4 * time.Second,
+ 8 * time.Second,
+ 10 * time.Second, // cap reached
+ 10 * time.Second, // stays capped
+ }
+ for i, w := range want {
+ if got := RetryDelay(i); got != w {
+ t.Errorf("attempt %d: RetryDelay = %s, want %s", i, got, w)
+ }
+ }
+}
+
+func TestComputeRetryDelaySequence(t *testing.T) {
+ // New LLM retry policy: 0.5s base, doubling, capped at 32s (no jitter here).
+ want := []time.Duration{
+ 500 * time.Millisecond,
+ 1 * time.Second,
+ 2 * time.Second,
+ 4 * time.Second,
+ 8 * time.Second,
+ 16 * time.Second,
+ 32 * time.Second, // cap reached
+ 32 * time.Second, // stays capped
+ }
+ for i, w := range want {
+ if got := computeRetryDelay(i, 0); got != w {
+ t.Errorf("attempt %d: computeRetryDelay = %s, want %s", i, got, w)
+ }
+ }
+}
+
+func TestRetryDelayJitterBounds(t *testing.T) {
+ // With jitter, delay must fall in [base, base + 0.25·base] (inclusive upper
+ // bound because jitterFrac can equal 1.0 in the test).
+ for attempt := 0; attempt < 8; attempt++ {
+ base := baseRetryDelay << uint(attempt)
+ if base > maxRetryDelay {
+ base = maxRetryDelay
+ }
+ upper := base + time.Duration(retryJitterFactor*float64(base))
+ for i := 0; i < 50; i++ {
+ got := computeRetryDelay(attempt, 1.0) // max jitter
+ if got < base || got > upper {
+ t.Errorf("attempt %d sample %d: got %s, want in [%s, %s]", attempt, i, got, base, upper)
+ }
+ }
+ }
+}
+
+func TestRetryAfterFromError(t *testing.T) {
+ tests := []struct {
+ name string
+ err error
+ want time.Duration
+ }{
+ {
+ name: "seconds form",
+ err: &APIError{StatusCode: 429, Header: http.Header{"Retry-After": []string{"30"}}},
+ want: 30 * time.Second,
+ },
+ {
+ name: "header absent",
+ err: &APIError{StatusCode: 429},
+ want: 0,
+ },
+ {
+ name: "non-integer",
+ err: &APIError{StatusCode: 429, Header: http.Header{"Retry-After": []string{"Wed, 21 Oct 2015 07:28:00 GMT"}}},
+ want: 0,
+ },
+ {
+ name: "wrapped APIError",
+ err: fmt.Errorf("call failed: %w", &APIError{StatusCode: 429, Header: http.Header{"Retry-After": []string{"5"}}}),
+ want: 5 * time.Second,
+ },
+ {
+ name: "non-APIError",
+ err: fmt.Errorf("plain error"),
+ want: 0,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := retryAfterFromError(tt.err); got != tt.want {
+ t.Errorf("retryAfterFromError() = %s, want %s", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestRetryDelayForHonorsRetryAfter(t *testing.T) {
+ err := &APIError{StatusCode: 429, Header: http.Header{"Retry-After": []string{"60"}}}
+ // Retry-After bypasses both the formula and the 32s cap.
+ if got := retryDelayFor(0, err); got != 60*time.Second {
+ t.Errorf("retryDelayFor with Retry-After=60 = %s, want 60s", got)
+ }
+}
+
+func TestRetryDelayForFallsBackToBackoffWhenNoHeader(t *testing.T) {
+ err := &APIError{StatusCode: 500} // no Header
+ got := retryDelayFor(2, err)
+ // attempt 2 base = 2s; with jitter it must stay in [2s, 2.5s)
+ if got < 2*time.Second || got >= 2500*time.Millisecond {
+ t.Errorf("retryDelayFor(attempt=2, no header) = %s, want in [2s, 2.5s)", got)
+ }
+}
+
+func TestIsRetryableNowIncludes408409(t *testing.T) {
+ for _, code := range []int{408, 409, 429, 500, 502, 503, 529} {
+ if !isRetryableError(&APIError{StatusCode: code}) {
+ t.Errorf("status %d should be retryable", code)
+ }
+ }
+ for _, code := range []int{400, 401, 403, 404} {
+ if isRetryableError(&APIError{StatusCode: code}) {
+ t.Errorf("status %d should NOT be retryable", code)
+ }
+ }
+}
diff --git a/pkg/agent/session.go b/pkg/agent/session.go
index e4b295bb..b03cab36 100644
--- a/pkg/agent/session.go
+++ b/pkg/agent/session.go
@@ -5,6 +5,7 @@ import (
"fmt"
"os"
"path/filepath"
+ "sort"
"strings"
"time"
)
@@ -18,6 +19,16 @@ type SessionData struct {
Messages []ChatMessage `json:"messages"`
}
+type SessionInfo struct {
+ Path string
+ CreatedAt time.Time
+ UpdatedAt time.Time
+ ModTime time.Time
+ Model string
+ Provider string
+ Messages int
+}
+
const sessionVersion = 1
func SaveSession(dir string, data *SessionData) error {
@@ -43,10 +54,6 @@ func SaveSession(dir string, data *SessionData) error {
return fmt.Errorf("write session file: %w", err)
}
- latestPath := filepath.Join(dir, "latest.json")
- if err := os.WriteFile(latestPath, raw, 0o644); err != nil {
- return fmt.Errorf("write latest session: %w", err)
- }
return nil
}
@@ -62,8 +69,74 @@ func LoadSession(path string) (*SessionData, error) {
return &data, nil
}
-func LatestSessionPath(dir string) string {
- return filepath.Join(dir, "latest.json")
+type sessionMeta struct {
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ Model string `json:"model,omitempty"`
+ Provider string `json:"provider,omitempty"`
+ Messages []json.RawMessage `json:"messages"`
+}
+
+func ListSessions(dir string) ([]SessionInfo, error) {
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil, nil
+ }
+ return nil, fmt.Errorf("read session dir: %w", err)
+ }
+ sessions := make([]SessionInfo, 0, len(entries))
+ for _, entry := range entries {
+ if entry.IsDir() {
+ continue
+ }
+ if matched, _ := filepath.Match("session-*.json", entry.Name()); !matched {
+ continue
+ }
+ path := filepath.Join(dir, entry.Name())
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ continue
+ }
+ var meta sessionMeta
+ if err := json.Unmarshal(raw, &meta); err != nil {
+ continue
+ }
+ fi, _ := entry.Info()
+ modTime := time.Time{}
+ if fi != nil {
+ modTime = fi.ModTime()
+ }
+ sessions = append(sessions, SessionInfo{
+ Path: path,
+ CreatedAt: meta.CreatedAt,
+ UpdatedAt: meta.UpdatedAt,
+ ModTime: modTime,
+ Model: meta.Model,
+ Provider: meta.Provider,
+ Messages: len(meta.Messages),
+ })
+ }
+ sort.Slice(sessions, func(i, j int) bool {
+ left := sessions[i].SortTime()
+ right := sessions[j].SortTime()
+ if left.Equal(right) {
+ return sessions[i].Path > sessions[j].Path
+ }
+ return left.After(right)
+ })
+ return sessions, nil
+}
+
+func (s SessionInfo) SortTime() time.Time {
+ switch {
+ case !s.UpdatedAt.IsZero():
+ return s.UpdatedAt
+ case !s.CreatedAt.IsZero():
+ return s.CreatedAt
+ default:
+ return s.ModTime
+ }
}
func sanitizeMessagesForSave(messages []ChatMessage) []ChatMessage {
@@ -87,12 +160,7 @@ func sanitizeMessagesForSave(messages []ChatMessage) []ChatMessage {
ToolCallID: m.ToolCallID,
}
} else {
- out[i] = ChatMessage{
- Role: m.Role,
- Content: m.Content,
- ToolCalls: m.ToolCalls,
- ToolCallID: m.ToolCallID,
- }
+ out[i] = m
}
}
return out
diff --git a/pkg/agent/session_test.go b/pkg/agent/session_test.go
index cf9713d5..3db8ebc9 100644
--- a/pkg/agent/session_test.go
+++ b/pkg/agent/session_test.go
@@ -1,9 +1,11 @@
package agent
import (
+ "encoding/json"
"os"
"path/filepath"
"testing"
+ "time"
)
func TestSaveAndLoadSession(t *testing.T) {
@@ -32,12 +34,19 @@ func TestSaveAndLoadSession(t *testing.T) {
t.Fatalf("SaveSession: %v", err)
}
- latestPath := LatestSessionPath(dir)
- if _, err := os.Stat(latestPath); err != nil {
- t.Fatalf("latest.json not found: %v", err)
+ if _, err := os.Stat(filepath.Join(dir, "latest.json")); !os.IsNotExist(err) {
+ t.Fatalf("latest.json should not be written, err=%v", err)
}
- loaded, err := LoadSession(latestPath)
+ sessions, err := ListSessions(dir)
+ if err != nil {
+ t.Fatalf("ListSessions: %v", err)
+ }
+ if len(sessions) != 1 {
+ t.Fatalf("sessions len = %d, want 1", len(sessions))
+ }
+
+ loaded, err := LoadSession(sessions[0].Path)
if err != nil {
t.Fatalf("LoadSession: %v", err)
}
@@ -72,6 +81,55 @@ func TestSaveAndLoadSession(t *testing.T) {
}
}
+func TestListSessionsSortsNewestFirst(t *testing.T) {
+ dir := t.TempDir()
+ oldTime := time.Date(2026, 7, 12, 10, 0, 0, 0, time.UTC)
+ newTime := time.Date(2026, 7, 13, 10, 0, 0, 0, time.UTC)
+ writeSessionFile(t, filepath.Join(dir, "session-old.json"), SessionData{
+ Version: sessionVersion,
+ UpdatedAt: oldTime,
+ Model: "old",
+ Messages: []ChatMessage{NewTextMessage("user", "old")},
+ })
+ writeSessionFile(t, filepath.Join(dir, "session-new.json"), SessionData{
+ Version: sessionVersion,
+ UpdatedAt: newTime,
+ Model: "new",
+ Messages: []ChatMessage{NewTextMessage("user", "new")},
+ })
+ writeSessionFile(t, filepath.Join(dir, "latest.json"), SessionData{
+ Version: sessionVersion,
+ UpdatedAt: newTime.Add(time.Hour),
+ Model: "ignored",
+ Messages: []ChatMessage{NewTextMessage("user", "ignored")},
+ })
+
+ sessions, err := ListSessions(dir)
+ if err != nil {
+ t.Fatalf("ListSessions: %v", err)
+ }
+ if len(sessions) != 2 {
+ t.Fatalf("sessions len = %d, want 2", len(sessions))
+ }
+ if filepath.Base(sessions[0].Path) != "session-new.json" {
+ t.Fatalf("first session = %s, want session-new.json", sessions[0].Path)
+ }
+ if sessions[0].Messages != 1 || sessions[0].Model != "new" {
+ t.Fatalf("session metadata = %+v", sessions[0])
+ }
+}
+
+func writeSessionFile(t *testing.T, path string, data SessionData) {
+ t.Helper()
+ raw, err := json.Marshal(data)
+ if err != nil {
+ t.Fatalf("marshal session: %v", err)
+ }
+ if err := os.WriteFile(path, raw, 0o644); err != nil {
+ t.Fatalf("write session: %v", err)
+ }
+}
+
func TestSanitizeMessagesForSave(t *testing.T) {
text := "some text"
reasoning := "thinking..."
diff --git a/pkg/agent/subagent.go b/pkg/agent/subagent.go
index 5ddbc5a6..4594dbaf 100644
--- a/pkg/agent/subagent.go
+++ b/pkg/agent/subagent.go
@@ -12,6 +12,7 @@ import (
"github.com/chainreactors/aiscan/pkg/agent/inbox"
"github.com/chainreactors/aiscan/pkg/commands"
+ "github.com/chainreactors/aiscan/pkg/telemetry"
)
type AgentType struct {
@@ -32,12 +33,12 @@ type subAgentInfo struct {
}
type SubAgentTool struct {
- agent *Agent
- inbox inbox.Inbox
- messages func() []ChatMessage
- resolve AgentTypeResolver
- mu sync.Mutex
- running map[string]*subAgentInfo
+ agent *Agent
+ inbox inbox.Inbox
+ messages func() []ChatMessage
+ resolve AgentTypeResolver
+ mu sync.Mutex
+ running map[string]*subAgentInfo
}
func NewSubAgentTool(agent *Agent, parentInbox inbox.Inbox, resolve AgentTypeResolver) *SubAgentTool {
@@ -53,6 +54,21 @@ func (t *SubAgentTool) SetMessages(fn func() []ChatMessage) {
t.messages = fn
}
+func (t *SubAgentTool) InitLogger(logger telemetry.Logger) {
+ if t == nil || t.agent == nil {
+ return
+ }
+ if logger == nil {
+ logger = telemetry.NopLogger()
+ }
+ t.agent.mu.Lock()
+ t.agent.Cfg.Logger = logger
+ if t.agent.Cfg.LoopScheduler != nil {
+ t.agent.Cfg.LoopScheduler.SetLogger(logger)
+ }
+ t.agent.mu.Unlock()
+}
+
func (t *SubAgentTool) Name() string { return "subagent" }
func (t *SubAgentTool) Description() string {
@@ -267,12 +283,6 @@ func (t *SubAgentTool) sendMessage(name, message string) (string, error) {
return fmt.Sprintf("Message sent to subagent %q.", name), nil
}
-func (t *SubAgentTool) RunningCount() int {
- t.mu.Lock()
- defer t.mu.Unlock()
- return len(t.running)
-}
-
func (t *SubAgentTool) list() string {
t.mu.Lock()
defer t.mu.Unlock()
diff --git a/pkg/agent/truncate/clip.go b/pkg/agent/truncate/clip.go
index f572fff0..21c054f5 100644
--- a/pkg/agent/truncate/clip.go
+++ b/pkg/agent/truncate/clip.go
@@ -43,6 +43,12 @@ func ClipRunes(s string, maxRunes int) string {
func ClipLines(text string, maxLines, maxWidth int) ([]string, int) {
all := strings.Split(text, "\n")
total := len(all)
+ if maxLines <= 0 {
+ return nil, total
+ }
+ if maxWidth < 0 {
+ maxWidth = 0
+ }
if total > maxLines {
all = all[:maxLines]
}
diff --git a/pkg/agent/truncate/clip_test.go b/pkg/agent/truncate/clip_test.go
index d91d66d0..79ffdfe5 100644
--- a/pkg/agent/truncate/clip_test.go
+++ b/pkg/agent/truncate/clip_test.go
@@ -106,3 +106,15 @@ func TestClipLines_NoTruncation(t *testing.T) {
t.Fatalf("expected 2 lines 0 hidden, got %d lines %d hidden", len(lines), hidden)
}
}
+
+func TestClipLines_NonPositiveLimitsDoNotPanic(t *testing.T) {
+ lines, hidden := ClipLines("a\nb", 0, 100)
+ if len(lines) != 0 || hidden != 2 {
+ t.Fatalf("expected no lines and 2 hidden, got %d lines %d hidden", len(lines), hidden)
+ }
+
+ lines, hidden = ClipLines("abc", 1, -1)
+ if len(lines) != 1 || hidden != 0 || lines[0] != "…" {
+ t.Fatalf("unexpected negative width result: lines=%q hidden=%d", lines, hidden)
+ }
+}
diff --git a/pkg/agent/truncate/truncate.go b/pkg/agent/truncate/truncate.go
index 39f985a5..1e33e43c 100644
--- a/pkg/agent/truncate/truncate.go
+++ b/pkg/agent/truncate/truncate.go
@@ -221,6 +221,12 @@ func Tail(content string, opts Options) Result {
// Line truncates a single line to maxChars runes, appending "... [truncated]".
func Line(line string, maxChars int) (string, bool) {
+ if maxChars <= 0 {
+ if line == "" {
+ return line, false
+ }
+ return "... [truncated]", true
+ }
if utf8.RuneCountInString(line) <= maxChars {
return line, false
}
diff --git a/pkg/agent/truncate/truncate_test.go b/pkg/agent/truncate/truncate_test.go
index f3153f1e..b326abd8 100644
--- a/pkg/agent/truncate/truncate_test.go
+++ b/pkg/agent/truncate/truncate_test.go
@@ -177,6 +177,18 @@ func TestLine_Truncation(t *testing.T) {
}
}
+func TestLine_NonPositiveLimitDoesNotPanic(t *testing.T) {
+ text, trunc := Line("abc", 0)
+ if !trunc || text != "... [truncated]" {
+ t.Fatalf("unexpected: %q trunc=%v", text, trunc)
+ }
+
+ text, trunc = Line("", -1)
+ if trunc || text != "" {
+ t.Fatalf("unexpected empty result: %q trunc=%v", text, trunc)
+ }
+}
+
func TestFormatSize(t *testing.T) {
tests := []struct {
bytes int
diff --git a/pkg/agent/types.go b/pkg/agent/types.go
index 4bec4ee9..7e121b63 100644
--- a/pkg/agent/types.go
+++ b/pkg/agent/types.go
@@ -81,6 +81,9 @@ const (
EventEvalStart EventType = "eval_start"
EventEvalEnd EventType = "eval_end"
EventEvalError EventType = "eval_error"
+ EventCompactStart EventType = "compact_start"
+ EventCompactEnd EventType = "compact_end"
+ EventCompactError EventType = "compact_error"
)
type StopReason string
@@ -120,6 +123,10 @@ type Event struct {
EvalPass bool
EvalReason string
EvalError string
+
+ CompactTokensBefore int
+ CompactTokensAfter int
+ CompactKeptMessages int
}
type TransformContextFunc func([]ChatMessage) []ChatMessage
@@ -232,7 +239,7 @@ func (c Config) init() Config {
if c.Logger == nil {
c.Logger = telemetry.NopLogger()
}
- if c.MaxRetries < 0 {
+ if c.MaxRetries <= 0 {
c.MaxRetries = DefaultMaxRetries
}
if c.MaxResultSize <= 0 {
diff --git a/pkg/commands/bash_test.go b/pkg/commands/bash_test.go
index 09b8d06e..2cfe1c2d 100644
--- a/pkg/commands/bash_test.go
+++ b/pkg/commands/bash_test.go
@@ -11,6 +11,7 @@ import (
tmux "github.com/chainreactors/aiscan/pkg/agent/tmux"
"github.com/chainreactors/aiscan/pkg/commands"
+ "github.com/chainreactors/aiscan/pkg/telemetry"
)
// ---------------------------------------------------------------------------
@@ -58,9 +59,9 @@ func (c *outputCommand) Execute(_ context.Context, _ []string) error {
// panicTool is a test tool that always panics.
type panicTool struct{ msg string }
-func (t *panicTool) Name() string { return "panic_tool" }
-func (t *panicTool) Description() string { return "always panics" }
-func (t *panicTool) Definition() commands.ToolDefinition { return commands.ToolDefinition{} }
+func (t *panicTool) Name() string { return "panic_tool" }
+func (t *panicTool) Description() string { return "always panics" }
+func (t *panicTool) Definition() commands.ToolDefinition { return commands.ToolDefinition{} }
func (t *panicTool) Execute(_ context.Context, _ string) (commands.ToolResult, error) {
panic(t.msg)
}
@@ -68,13 +69,50 @@ func (t *panicTool) Execute(_ context.Context, _ string) (commands.ToolResult, e
// normalTool returns a result without panicking.
type normalTool struct{}
-func (t *normalTool) Name() string { return "normal_tool" }
-func (t *normalTool) Description() string { return "works fine" }
-func (t *normalTool) Definition() commands.ToolDefinition { return commands.ToolDefinition{} }
+func (t *normalTool) Name() string { return "normal_tool" }
+func (t *normalTool) Description() string { return "works fine" }
+func (t *normalTool) Definition() commands.ToolDefinition { return commands.ToolDefinition{} }
func (t *normalTool) Execute(_ context.Context, _ string) (commands.ToolResult, error) {
return commands.TextResult("hello"), nil
}
+type testLogger struct{}
+
+func (*testLogger) Debugf(string, ...any) {}
+func (*testLogger) Infof(string, ...any) {}
+func (*testLogger) Warnf(string, ...any) {}
+func (*testLogger) Errorf(string, ...any) {}
+func (*testLogger) Importantf(string, ...any) {}
+
+type loggerAwareCommand struct {
+ name string
+ logger telemetry.Logger
+}
+
+func (c *loggerAwareCommand) Name() string { return c.name }
+func (c *loggerAwareCommand) Usage() string { return c.name }
+func (c *loggerAwareCommand) Execute(_ context.Context, _ []string) error {
+ return nil
+}
+func (c *loggerAwareCommand) InitLogger(logger telemetry.Logger) {
+ c.logger = logger
+}
+
+type loggerAwareTool struct {
+ name string
+ logger telemetry.Logger
+}
+
+func (t *loggerAwareTool) Name() string { return t.name }
+func (t *loggerAwareTool) Description() string { return t.name }
+func (t *loggerAwareTool) Definition() commands.ToolDefinition { return commands.ToolDefinition{} }
+func (t *loggerAwareTool) Execute(_ context.Context, _ string) (commands.ToolResult, error) {
+ return commands.TextResult("ok"), nil
+}
+func (t *loggerAwareTool) InitLogger(logger telemetry.Logger) {
+ t.logger = logger
+}
+
func bashArgs(cmd string) string {
data, _ := json.Marshal(map[string]string{"command": cmd})
return string(data)
@@ -97,6 +135,24 @@ func newBashWithPseudo(dir string, cmds ...*outputCommand) *commands.BashTool {
return bash
}
+func TestCommandRegistrySetLoggerRebindsCommandsAndTools(t *testing.T) {
+ reg := commands.NewRegistry()
+ cmd := &loggerAwareCommand{name: "sample"}
+ tool := &loggerAwareTool{name: "sample_tool"}
+ logger := &testLogger{}
+
+ reg.Register(cmd, "test")
+ reg.RegisterTool(tool)
+ reg.SetLogger(logger)
+
+ if cmd.logger != logger {
+ t.Fatalf("command logger not rebound")
+ }
+ if tool.logger != logger {
+ t.Fatalf("tool logger not rebound")
+ }
+}
+
// ---------------------------------------------------------------------------
// Scanner tests (from bash_scanner_test.go)
// ---------------------------------------------------------------------------
diff --git a/pkg/commands/command.go b/pkg/commands/command.go
index 95091038..c7f0df08 100644
--- a/pkg/commands/command.go
+++ b/pkg/commands/command.go
@@ -9,7 +9,7 @@ import (
"sync"
"github.com/chainreactors/aiscan/pkg/agent/provider"
-
+ "github.com/chainreactors/aiscan/pkg/telemetry"
)
type ToolDefinition = provider.ToolDefinition
@@ -40,13 +40,17 @@ type WorkDirAware interface {
SetWorkDir(dir string)
}
+type LoggerAware interface {
+ InitLogger(telemetry.Logger)
+}
+
type CommandRegistry struct {
- mu sync.RWMutex
- items map[string]Command
- order []string
- groups map[string][]string
- workDir string
- output io.Writer
+ mu sync.RWMutex
+ items map[string]Command
+ order []string
+ groups map[string][]string
+ workDir string
+ output io.Writer
tools map[string]AgentTool
toolOrder []string
@@ -58,25 +62,29 @@ func (r *CommandRegistry) SetOutput(w io.Writer) {
r.output = w
}
+func (r *CommandRegistry) SetLogger(logger telemetry.Logger) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ for _, cmd := range r.items {
+ if aware, ok := cmd.(LoggerAware); ok {
+ aware.InitLogger(logger)
+ }
+ }
+ for _, tool := range r.tools {
+ if aware, ok := tool.(LoggerAware); ok {
+ aware.InitLogger(logger)
+ }
+ }
+}
+
func NewRegistry() *CommandRegistry {
return &CommandRegistry{
- items: make(map[string]Command),
+ items: make(map[string]Command),
groups: make(map[string][]string),
tools: make(map[string]AgentTool),
}
}
-func (r *CommandRegistry) CloneTools() *CommandRegistry {
- r.mu.RLock()
- defer r.mu.RUnlock()
- clone := NewRegistry()
- for _, name := range r.toolOrder {
- clone.tools[name] = r.tools[name]
- clone.toolOrder = append(clone.toolOrder, name)
- }
- return clone
-}
-
func (r *CommandRegistry) RegisterTool(t AgentTool) {
r.mu.Lock()
defer r.mu.Unlock()
diff --git a/pkg/headless/request.go b/pkg/headless/request.go
index cca62a5f..46beacab 100644
--- a/pkg/headless/request.go
+++ b/pkg/headless/request.go
@@ -258,9 +258,9 @@ func (r *Request) Match(data map[string]interface{}, matcher *operators.Matcher)
statusCode = int(v)
}
}
- return matcher.Result(matcher.MatchStatusCode(statusCode)), []string{}
+ return matcher.Result(matcher.MatchStatusCode(statusCode)), nil
case operators.SizeMatcher:
- return matcher.Result(matcher.MatchSize(len(itemStr))), []string{}
+ return matcher.Result(matcher.MatchSize(len(itemStr))), nil
case operators.WordsMatcher:
return matcher.ResultWithMatchedSnippet(matcher.MatchWords(itemStr, data))
case operators.RegexMatcher:
diff --git a/pkg/telemetry/logger.go b/pkg/telemetry/logger.go
index 9506aeb3..911a1c24 100644
--- a/pkg/telemetry/logger.go
+++ b/pkg/telemetry/logger.go
@@ -40,17 +40,39 @@ func NewLogger(cfg LogConfig) Logger {
} else {
base.SetOutput(os.Stderr)
}
- base.SetFormatter(map[logs.Level]string{
- logs.DebugLevel: "[debug] %s\n",
- logs.InfoLevel: "[info] %s\n",
- logs.WarnLevel: "[warn] %s\n",
- logs.ErrorLevel: "[error] %s\n",
- logs.ImportantLevel: "[info] %s\n",
- })
- base.SetColor(cfg.Color)
+ base.SetFormatter(logFormatter(cfg.Color))
+ base.SetColor(false)
return logsLogger{base: base}
}
+const logMark = "●"
+
+func logFormatter(color bool) map[logs.Level]string {
+ debugMark := logMark
+ infoMark := logMark
+ warnMark := logMark
+ errorMark := logMark
+ importantMark := logMark
+ if color {
+ debugMark = darkGray(logMark)
+ infoMark = logs.Green(logMark)
+ warnMark = logs.YellowBold(logMark)
+ errorMark = logs.RedBold(logMark)
+ importantMark = logs.PurpleBold(logMark)
+ }
+ return map[logs.Level]string{
+ logs.DebugLevel: debugMark + " %s\n",
+ logs.InfoLevel: infoMark + " %s\n",
+ logs.WarnLevel: warnMark + " %s\n",
+ logs.ErrorLevel: errorMark + " %s\n",
+ logs.ImportantLevel: importantMark + " %s\n",
+ }
+}
+
+func darkGray(s string) string {
+ return "\033[90m" + s + "\033[0m"
+}
+
func GlobalLogger(cfg LogConfig) Logger {
logger := NewLogger(cfg)
if adapter, ok := logger.(logsLogger); ok {
diff --git a/pkg/telemetry/logger_test.go b/pkg/telemetry/logger_test.go
index 7859cca6..5ff4b1c5 100644
--- a/pkg/telemetry/logger_test.go
+++ b/pkg/telemetry/logger_test.go
@@ -18,7 +18,7 @@ func TestActivateDebugUsesTelemetryLoggerAsGlobal(t *testing.T) {
logs.Log.Debugf("visible")
restore()
- if got := buf.String(); got != "[debug] visible\n" {
+ if got := buf.String(); got != "● visible\n" {
t.Fatalf("debug output = %q", got)
}
if logs.Log != oldGlobal {
@@ -42,7 +42,7 @@ func TestSuppressGlobalNonErrorsKeepsOnlyErrors(t *testing.T) {
if strings.Contains(got, "hidden") {
t.Fatalf("non-error logs were not suppressed: %q", got)
}
- if !strings.Contains(got, "[error] visible error") {
+ if !strings.Contains(got, "● visible error") {
t.Fatalf("error log missing after suppression: %q", got)
}
}
@@ -60,7 +60,21 @@ func TestErrorOnlyLoggerSuppressesNonErrors(t *testing.T) {
if strings.Contains(got, "debug") || strings.Contains(got, "info") || strings.Contains(got, "warn") || strings.Contains(got, "important") {
t.Fatalf("non-error logs were not suppressed: %q", got)
}
- if !strings.Contains(got, "[error] error") {
+ if !strings.Contains(got, "● error") {
t.Fatalf("error log missing: %q", got)
}
}
+
+func TestLoggerColorStylesOnlyMarker(t *testing.T) {
+ var buf bytes.Buffer
+ logger := NewLogger(LogConfig{Debug: true, Output: &buf, Color: true})
+ logger.Infof("ready")
+
+ got := buf.String()
+ if !strings.Contains(got, "\x1b[0;32m●\x1b[0m ready") {
+ t.Fatalf("colored marker missing: %q", got)
+ }
+ if strings.Contains(got, "\x1b[0;32m● ready") {
+ t.Fatalf("entire line appears colored: %q", got)
+ }
+}
diff --git a/pkg/telemetry/recover.go b/pkg/telemetry/recover.go
index 02473a48..fd11a5aa 100644
--- a/pkg/telemetry/recover.go
+++ b/pkg/telemetry/recover.go
@@ -1,6 +1,7 @@
package telemetry
import (
+ "fmt"
"runtime/debug"
"github.com/chainreactors/logs"
@@ -27,6 +28,19 @@ func SafeRun(name string, fn func()) {
fn()
}
+// RecoverAsError is designed for tool Execute methods. Call as:
+//
+// defer telemetry.RecoverAsError("toolname", &err)
+//
+// It converts a panic into a returned error so the process stays alive.
+func RecoverAsError(name string, errp *error) {
+ if r := recover(); r != nil {
+ stack := debug.Stack()
+ logs.Log.Errorf("[%s] panic recovered: %v\n%s", name, r, stack)
+ *errp = fmt.Errorf("[%s] panic: %v", name, r)
+ }
+}
+
// SDKGoRecover recovers from a panic inside a goroutine that processes SDK
// results. It logs the panic; the deferred close(out) in the caller signals
// the consumer that the stream ended.
diff --git a/pkg/telemetry/startup.go b/pkg/telemetry/startup.go
new file mode 100644
index 00000000..47a07ce7
--- /dev/null
+++ b/pkg/telemetry/startup.go
@@ -0,0 +1,39 @@
+package telemetry
+
+import (
+ "fmt"
+ "strings"
+)
+
+func StartupOK(component, detail string) string {
+ return startupLine("", component, detail)
+}
+
+func StartupLine(status, component, detail string) string {
+ status = strings.TrimSpace(status)
+ if status == "" {
+ status = "info"
+ }
+ if status == "ok" {
+ return StartupOK(component, detail)
+ }
+ return startupLine(status, component, detail)
+}
+
+func startupLine(status, component, detail string) string {
+ component = strings.TrimSpace(component)
+ detail = strings.TrimSpace(detail)
+ if component == "" {
+ component = "-"
+ }
+ if detail == "" {
+ if status == "" {
+ return component
+ }
+ return fmt.Sprintf("%-4s %s", status, component)
+ }
+ if status == "" {
+ return fmt.Sprintf("%-12s %s", component, detail)
+ }
+ return fmt.Sprintf("%-4s %-12s %s", status, component, detail)
+}
diff --git a/pkg/telemetry/startup_test.go b/pkg/telemetry/startup_test.go
new file mode 100644
index 00000000..ccff9f9c
--- /dev/null
+++ b/pkg/telemetry/startup_test.go
@@ -0,0 +1,23 @@
+package telemetry
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestStartupLineUsesTextStatus(t *testing.T) {
+ got := StartupOK("llm", "openai/gpt-test")
+ if !strings.HasPrefix(got, "llm") {
+ t.Fatalf("StartupOK() = %q", got)
+ }
+
+ got = StartupLine("ok", "llm", "openai/gpt-test")
+ if !strings.HasPrefix(got, "llm") {
+ t.Fatalf("StartupLine(ok) = %q", got)
+ }
+
+ got = StartupLine("fail", "llm", "unauthorized")
+ if !strings.HasPrefix(got, "fail llm") {
+ t.Fatalf("StartupLine(fail) = %q", got)
+ }
+}
diff --git a/pkg/tools/gogo/gogo.go b/pkg/tools/gogo/gogo.go
index 4ce92aa5..bfc832d1 100644
--- a/pkg/tools/gogo/gogo.go
+++ b/pkg/tools/gogo/gogo.go
@@ -64,6 +64,7 @@ func (c *Command) QuickReference() string {
}
func (c *Command) Execute(ctx context.Context, args []string) (err error) {
+ defer telemetry.RecoverAsError("gogo", &err)
args = c.normalizeArgs(args)
args = c.injectProxy(args)
@@ -89,7 +90,7 @@ func (c *Command) Execute(ctx context.Context, args []string) (err error) {
return c.engine.Init()
},
OnResult: func(r *parsers.GOGOResult) {
- c.EmitData("gogo", output.ToolDataService, r.GetTarget(), r)
+ c.EmitDataCtx(ctx, "gogo", output.ToolDataService, r.GetTarget(), r)
},
}
if err := gogocore.RunWithArgs(ctx, args, opts); err != nil {
diff --git a/pkg/tools/gogo/gogo_test.go b/pkg/tools/gogo/gogo_test.go
index 12b4badd..94fc6979 100644
--- a/pkg/tools/gogo/gogo_test.go
+++ b/pkg/tools/gogo/gogo_test.go
@@ -45,7 +45,7 @@ func TestExecuteDebugActivatesTelemetryLogger(t *testing.T) {
if err := cmd.Execute(context.Background(), []string{"--debug", "--help"}); err != nil {
t.Fatalf("Execute() error = %v", err)
}
- if got := logs.String(); !strings.Contains(got, "[debug] gogo debug enabled") {
+ if got := logs.String(); !strings.Contains(got, "● gogo debug enabled") {
t.Fatalf("debug logs = %q", got)
}
}
@@ -84,4 +84,3 @@ func TestNormalizeArgsConvertsValuelessJSONFlag(t *testing.T) {
t.Fatalf("normalizeArgs() = %#v, want %#v", got, want)
}
}
-
diff --git a/pkg/tools/ioa/commands.go b/pkg/tools/ioa/commands.go
index 81b126ce..f559d16a 100644
--- a/pkg/tools/ioa/commands.go
+++ b/pkg/tools/ioa/commands.go
@@ -9,6 +9,7 @@ import (
"sync"
"github.com/chainreactors/aiscan/pkg/commands"
+ "github.com/chainreactors/aiscan/pkg/telemetry"
"github.com/chainreactors/ioa/protocols"
)
@@ -96,7 +97,8 @@ nodes Show nodes in the current space
topics Show root messages (conversation starters) in the current space`
}
-func (c *spaceCommand) Execute(ctx context.Context, args []string) error {
+func (c *spaceCommand) Execute(ctx context.Context, args []string) (err error) {
+ defer telemetry.RecoverAsError("ioa_space", &err)
sub := ""
if len(args) > 0 && !strings.HasPrefix(args[0], "--") {
sub = args[0]
@@ -245,7 +247,8 @@ Options:
--status Verification status: confirmed, not_confirmed, info, inconclusive (checkpoint)`
}
-func (c *sendCommand) Execute(ctx context.Context, args []string) error {
+func (c *sendCommand) Execute(ctx context.Context, args []string) (err error) {
+ defer telemetry.RecoverAsError("ioa_send", &err)
spaceID := c.binding.get()
if spaceID == "" {
return fmt.Errorf("no space joined. Use ioa_space join first")
@@ -345,7 +348,8 @@ Options:
--id Message ID for thread context`
}
-func (c *readCommand) Execute(ctx context.Context, args []string) error {
+func (c *readCommand) Execute(ctx context.Context, args []string) (err error) {
+ defer telemetry.RecoverAsError("ioa_read", &err)
spaceID := c.binding.get()
if spaceID == "" {
return fmt.Errorf("no space joined. Use ioa_space join first")
diff --git a/pkg/tools/katana/katana.go b/pkg/tools/katana/katana.go
index f9d8f07a..00a07835 100644
--- a/pkg/tools/katana/katana.go
+++ b/pkg/tools/katana/katana.go
@@ -11,6 +11,8 @@ import (
"sync"
"time"
+ "github.com/chainreactors/aiscan/core/eventbus"
+ "github.com/chainreactors/aiscan/core/output"
"github.com/chainreactors/aiscan/pkg/commands"
"github.com/chainreactors/aiscan/pkg/telemetry"
"github.com/chainreactors/aiscan/pkg/tools/toolargs"
@@ -49,6 +51,11 @@ func (c *Command) WithProxy(proxy string) *Command {
return c
}
+func (c *Command) WithDataBus(bus *eventbus.Bus[output.ToolDataEvent]) *Command {
+ c.DataBus = bus
+ return c
+}
+
func (c *Command) Name() string { return "katana" }
func (c *Command) Usage() string {
@@ -108,6 +115,7 @@ Examples:
}
func (c *Command) Execute(ctx context.Context, args []string) (err error) {
+ defer telemetry.RecoverAsError("katana", &err)
args = c.resolveRelativePaths(args)
if toolargs.BoolFlagEnabled(args, "--debug") {
@@ -152,6 +160,9 @@ func (c *Command) Execute(ctx context.Context, args []string) (err error) {
options.OnResult = func(r katanaoutput.Result) {
collector.collect(&r)
+ if r.Request != nil && r.Request.URL != "" {
+ c.EmitDataCtx(ctx, "katana", output.ToolDataWeb, r.Request.URL, &r)
+ }
}
// Suppress gologger during crawl.
diff --git a/pkg/tools/katana/register.go b/pkg/tools/katana/register.go
index e53f59b6..30ae4b8e 100644
--- a/pkg/tools/katana/register.go
+++ b/pkg/tools/katana/register.go
@@ -11,7 +11,7 @@ func init() {
Group: "scanner",
Build: func(deps *commands.Deps, reg *commands.CommandRegistry) {
logger := deps.GetLogger()
- reg.Register(New().WithLogger(logger).WithProxy(deps.ScannerProxy), "scanner")
+ reg.Register(New().WithLogger(logger).WithProxy(deps.ScannerProxy).WithDataBus(deps.DataBus), "scanner")
},
})
}
diff --git a/pkg/tools/neutron/neutron.go b/pkg/tools/neutron/neutron.go
index d3f281fe..6732ba88 100644
--- a/pkg/tools/neutron/neutron.go
+++ b/pkg/tools/neutron/neutron.go
@@ -12,6 +12,8 @@ import (
"strings"
"time"
+ "github.com/chainreactors/aiscan/core/eventbus"
+ "github.com/chainreactors/aiscan/core/output"
"github.com/chainreactors/aiscan/pkg/commands"
"github.com/chainreactors/aiscan/pkg/telemetry"
"github.com/chainreactors/aiscan/pkg/tools/toolargs"
@@ -96,6 +98,11 @@ func (c *Command) WithProxy(proxy string) *Command {
return c
}
+func (c *Command) WithDataBus(bus *eventbus.Bus[output.ToolDataEvent]) *Command {
+ c.DataBus = bus
+ return c
+}
+
func (c *Command) SetProxy(proxy string) {
c.Base.SetProxy(proxy)
scanengine.ApplyNeutronProxy(proxy)
@@ -142,11 +149,12 @@ Examples:
neutron -u http://target.com -t ./pocs --id shiro-detect -j -o loots.jsonl`
}
-func (c *Command) Execute(ctx context.Context, args []string) error {
+func (c *Command) Execute(ctx context.Context, args []string) (err error) {
+ defer telemetry.RecoverAsError("neutron", &err)
args = c.resolveRelativePaths(args)
var flags neutronFlags
parser := goflags.NewParser(&flags, goflags.Default&^goflags.PrintErrors)
- _, err := parser.ParseArgs(normalizeNucleiStyleArgs(args))
+ _, err = parser.ParseArgs(normalizeNucleiStyleArgs(args))
if err != nil {
if flagsErr, ok := err.(*goflags.Error); ok && flagsErr.Type == goflags.ErrHelp {
fmt.Fprint(commands.Output, c.Usage()+"\n")
@@ -255,6 +263,7 @@ func (c *Command) Execute(ctx context.Context, args []string) error {
record := neutronResultFromExecution(target, result)
if record.Matched {
summary.Matched++
+ c.EmitDataCtx(ctx, "neutron", output.ToolDataVuln, target, &record)
}
if shouldPrintNeutronResult(record, flags) {
sb.WriteString(formatNeutronResult(record, jsonOutput))
@@ -452,8 +461,8 @@ func neutronResultFromExecution(target string, result *sdkneutron.ExecuteResult)
record.Tags = cleanTemplateTags(tmpl)
record.Fingers = append([]string(nil), tmpl.Fingers...)
}
- if opResult := result.Result(); opResult != nil && opResult.Result != nil {
- record.Extracts = append([]string(nil), opResult.Result.OutputExtracts()...)
+ if opResult := result.Value(); opResult != nil {
+ record.Extracts = append([]string(nil), opResult.OutputExtracts()...)
}
if err := result.Error(); err != nil {
record.Error = err.Error()
diff --git a/pkg/tools/neutron/register.go b/pkg/tools/neutron/register.go
index 4a24ef35..cd67ad60 100644
--- a/pkg/tools/neutron/register.go
+++ b/pkg/tools/neutron/register.go
@@ -14,7 +14,7 @@ func init() {
return
}
reg.Register(
- New(es.Neutron, es.Index).WithLogger(deps.GetLogger()).WithProxy(deps.ScannerProxy),
+ New(es.Neutron, es.Index).WithLogger(deps.GetLogger()).WithProxy(deps.ScannerProxy).WithDataBus(deps.DataBus),
"scanner",
)
},
diff --git a/pkg/tools/passive/passive.go b/pkg/tools/passive/passive.go
index d2044abd..f9cf876b 100644
--- a/pkg/tools/passive/passive.go
+++ b/pkg/tools/passive/passive.go
@@ -45,10 +45,14 @@ func New(eng *engine.UncoverEngine) *Command {
}
func (c *Command) WithLogger(l telemetry.Logger) *Command {
+ c.InitLogger(l)
+ return c
+}
+
+func (c *Command) InitLogger(l telemetry.Logger) {
if l != nil {
c.logger = l
}
- return c
}
func (c *Command) Name() string { return "passive" }
@@ -73,7 +77,8 @@ Options:
-h Show this help`, availStr)
}
-func (c *Command) Execute(ctx context.Context, args []string) error {
+func (c *Command) Execute(ctx context.Context, args []string) (err error) {
+ defer telemetry.RecoverAsError("passive", &err)
src, rest, help, err := splitSource(args)
if err != nil {
return err
diff --git a/pkg/tools/playwright/browser.go b/pkg/tools/playwright/browser.go
index bc27fbd2..bcfc3c79 100644
--- a/pkg/tools/playwright/browser.go
+++ b/pkg/tools/playwright/browser.go
@@ -16,6 +16,7 @@ import (
"github.com/chainreactors/aiscan/pkg/agent/truncate"
"github.com/chainreactors/aiscan/pkg/commands"
+ "github.com/chainreactors/aiscan/pkg/telemetry"
"github.com/go-rod/rod"
"github.com/go-rod/rod/lib/launcher"
"github.com/go-rod/rod/lib/proto"
@@ -237,7 +238,8 @@ Examples:
}
// Execute dispatches to the appropriate sub-command.
-func (c *Command) Execute(ctx context.Context, args []string) error {
+func (c *Command) Execute(ctx context.Context, args []string) (err error) {
+ defer telemetry.RecoverAsError("playwright", &err)
if len(args) == 0 {
return fmt.Errorf("playwright: subcommand required\n\n%s", c.Usage())
}
@@ -269,7 +271,6 @@ func (c *Command) Execute(ctx context.Context, args []string) error {
}
var result string
- var err error
switch sub {
// --- Unified URL/session commands (Playwright-aligned) ---
diff --git a/pkg/tools/proton/command.go b/pkg/tools/proton/command.go
index 334f701a..32f3e20b 100644
--- a/pkg/tools/proton/command.go
+++ b/pkg/tools/proton/command.go
@@ -14,6 +14,8 @@ import (
"sync/atomic"
"time"
+ "github.com/chainreactors/aiscan/core/eventbus"
+ "github.com/chainreactors/aiscan/core/output"
"github.com/chainreactors/aiscan/pkg/commands"
"github.com/chainreactors/aiscan/pkg/telemetry"
"github.com/chainreactors/aiscan/pkg/tools/toolargs"
@@ -46,6 +48,11 @@ func (c *Command) WithProxy(proxy string) *Command {
return c
}
+func (c *Command) WithDataBus(bus *eventbus.Bus[output.ToolDataEvent]) *Command {
+ c.DataBus = bus
+ return c
+}
+
func (c *Command) WithResourceProvider(provider func(string) []byte) *Command {
c.resourceProvider = provider
return c
@@ -134,7 +141,8 @@ type protonFlags struct {
Debug bool `long:"debug" description:"enable debug logging"`
}
-func (c *Command) Execute(ctx context.Context, args []string) error {
+func (c *Command) Execute(ctx context.Context, args []string) (err error) {
+ defer telemetry.RecoverAsError("proton", &err)
args = c.resolveRelativePaths(args)
var flags protonFlags
parser := goflags.NewParser(&flags, goflags.Default&^goflags.PrintErrors)
@@ -240,6 +248,7 @@ func (c *Command) Execute(ctx context.Context, args []string) error {
if uf.Class == "extract" {
atomic.AddInt64(&extractCount, 1)
}
+ c.EmitDataCtx(ctx, "proton", output.ToolDataVuln, uf.FilePath, &uf)
writeFinding(commands.Output, uf, flags.JSON, inputs[0])
if fileOut != nil {
writeFinding(fileOut, uf, flags.JSON, inputs[0])
@@ -361,9 +370,35 @@ func (c *Command) resolveRelativePaths(args []string) []string {
// --- output ---
+// jsonFinding is proton's nuclei-style JSON contract. It deliberately uses
+// hyphenated keys (template-id/template-name) rather than marshaling
+// file.Finding directly, which would leak the internal parsers.FindingResult
+// tags (template_id/template_name) and break consumers expecting nuclei output.
+type jsonFinding struct {
+ TemplateID string `json:"template-id"`
+ TemplateName string `json:"template-name,omitempty"`
+ Severity string `json:"severity,omitempty"`
+ Tags []string `json:"tags,omitempty"`
+ Matched bool `json:"matched"`
+ Extracted bool `json:"extracted"`
+ Class string `json:"class,omitempty"`
+ File string `json:"file,omitempty"`
+ Events any `json:"events,omitempty"`
+}
+
func writeFinding(w interface{ Write([]byte) (int, error) }, f file.Finding, jsonMode bool, baseDir string) {
if jsonMode {
- data, _ := json.Marshal(f)
+ data, _ := json.Marshal(jsonFinding{
+ TemplateID: f.TemplateID,
+ TemplateName: f.TemplateName,
+ Severity: f.Severity,
+ Tags: f.Tags,
+ Matched: f.Matched,
+ Extracted: f.Extracted,
+ Class: f.Class,
+ File: f.FilePath,
+ Events: f.Events,
+ })
fmt.Fprintln(w, string(data))
return
}
@@ -473,7 +508,7 @@ func walkAndScan(ctx context.Context, scanner *file.Scanner, target string, call
}
return nil
}); walkErr != nil && ctx.Err() == nil {
- fmt.Fprintf(os.Stderr, "proton: walk %s: %v\n", target, walkErr)
+ fmt.Fprintf(commands.Output, "proton: walk %s: %v\n", target, walkErr)
}
close(jobCh)
wg.Wait()
diff --git a/pkg/tools/proton/register.go b/pkg/tools/proton/register.go
index 40b8990d..14c61174 100644
--- a/pkg/tools/proton/register.go
+++ b/pkg/tools/proton/register.go
@@ -10,7 +10,7 @@ func init() {
Group: "proton",
Build: func(deps *commands.Deps, reg *commands.CommandRegistry) {
logger := deps.GetLogger()
- cmd := New().WithLogger(logger).WithProxy(deps.ScannerProxy)
+ cmd := New().WithLogger(logger).WithProxy(deps.ScannerProxy).WithDataBus(deps.DataBus)
if rs, ok := deps.Resources.(*resources.Set); ok && rs != nil {
cmd.WithResourceProvider(rs.ProtonConfig)
}
diff --git a/pkg/tools/proxy/command.go b/pkg/tools/proxy/command.go
index 4fac815d..f0bfb764 100644
--- a/pkg/tools/proxy/command.go
+++ b/pkg/tools/proxy/command.go
@@ -7,6 +7,7 @@ import (
"strings"
"github.com/chainreactors/aiscan/pkg/commands"
+ "github.com/chainreactors/aiscan/pkg/telemetry"
"github.com/chainreactors/proxyclient"
"github.com/chainreactors/proxyclient/extra/clash"
goflags "github.com/jessevdk/go-flags"
@@ -56,7 +57,8 @@ Auto mode options:
--strategy,-s adaptive Load balance strategy (adaptive, url-test, round-robin, random)`
}
-func (c *Command) Execute(ctx context.Context, args []string) error {
+func (c *Command) Execute(ctx context.Context, args []string) (err error) {
+ defer telemetry.RecoverAsError("proxy", &err)
if len(args) == 0 {
fmt.Fprint(commands.Output, c.Usage())
return nil
@@ -65,7 +67,6 @@ func (c *Command) Execute(ctx context.Context, args []string) error {
rest := args[1:]
var result string
- var err error
if strings.Contains(args[0], "://") {
result, err = c.execPassthrough(ctx, args[0], rest)
@@ -387,8 +388,9 @@ func (c *Command) formatNodeList(nodes []clash.ProxyNode, activeName string) str
}
func truncate(s string, max int) string {
- if len(s) <= max {
- return s
+ runes := []rune(s)
+ if len(runes) > max {
+ return string(runes[:max]) + "..."
}
- return s[:max-3] + "..."
+ return s
}
diff --git a/pkg/tools/proxy/mitm.go b/pkg/tools/proxy/mitm.go
index 3c521031..60c35135 100644
--- a/pkg/tools/proxy/mitm.go
+++ b/pkg/tools/proxy/mitm.go
@@ -10,6 +10,7 @@ import (
"time"
"github.com/chainreactors/aiscan/pkg/commands"
+ "github.com/chainreactors/aiscan/pkg/telemetry"
mitmproxy "github.com/chainreactors/utils/mitmproxy/proxy"
goflags "github.com/jessevdk/go-flags"
)
@@ -55,14 +56,14 @@ Examples:
mitm analyze --host example.com`
}
-func (c *MitmCommand) Execute(ctx context.Context, args []string) error {
+func (c *MitmCommand) Execute(ctx context.Context, args []string) (err error) {
+ defer telemetry.RecoverAsError("mitm", &err)
if len(args) == 0 {
fmt.Fprint(commands.Output, c.Usage())
return nil
}
var result string
- var err error
switch args[0] {
case "flows":
diff --git a/pkg/tools/proxy/mitm_test.go b/pkg/tools/proxy/mitm_test.go
index 491cf125..f737e5e3 100644
--- a/pkg/tools/proxy/mitm_test.go
+++ b/pkg/tools/proxy/mitm_test.go
@@ -177,6 +177,9 @@ func TestMITMCapture_NonHTTP_Fallback(t *testing.T) {
}
func TestMITMCapture_ServerFirst_Fallback(t *testing.T) {
+ if raceEnabled {
+ t.Skip("flaky under -race: mitmproxy internal goroutine scheduling causes i/o timeout on CI")
+ }
// Server-first protocol (like SSH): server sends banner, client waits.
// MITM should timeout on Peek and fallback to raw transfer.
tcpServer, err := net.Listen("tcp", "127.0.0.1:0")
@@ -206,7 +209,7 @@ func TestMITMCapture_ServerFirst_Fallback(t *testing.T) {
t.Fatal(err)
}
- ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
conn, err := dial(ctx, "tcp", tcpServer.Addr().String())
if err != nil {
@@ -215,7 +218,10 @@ func TestMITMCapture_ServerFirst_Fallback(t *testing.T) {
defer conn.Close()
buf := make([]byte, 64)
- conn.SetReadDeadline(time.Now().Add(8 * time.Second))
+ // The banner only arrives after the MITM's ~3s peek timeout expires and it
+ // falls back to raw transfer. Keep this deadline well above that timeout so
+ // scheduling jitter under -race / CI load can't race it (was 8s → flaky).
+ conn.SetReadDeadline(time.Now().Add(30 * time.Second))
n, err := io.ReadAtLeast(conn, buf, 3)
if err != nil {
t.Fatalf("expected SSH banner data, got error: %v", err)
diff --git a/pkg/tools/proxy/race_norace_test.go b/pkg/tools/proxy/race_norace_test.go
new file mode 100644
index 00000000..84a78f40
--- /dev/null
+++ b/pkg/tools/proxy/race_norace_test.go
@@ -0,0 +1,5 @@
+//go:build !race
+
+package proxy
+
+const raceEnabled = false
diff --git a/pkg/tools/proxy/race_test.go b/pkg/tools/proxy/race_test.go
new file mode 100644
index 00000000..b624eb94
--- /dev/null
+++ b/pkg/tools/proxy/race_test.go
@@ -0,0 +1,5 @@
+//go:build race
+
+package proxy
+
+const raceEnabled = true
diff --git a/pkg/tools/scan/adapter.go b/pkg/tools/scan/adapter.go
index a3936390..4abb30c6 100644
--- a/pkg/tools/scan/adapter.go
+++ b/pkg/tools/scan/adapter.go
@@ -170,7 +170,7 @@ func (c *Command) runPOCCapability(ctx context.Context, flags flags, input targe
if result == nil || !result.Matched() {
continue
}
- emit(lootEvent(capNeutronPOC, vulnLoot(result.VulnResult(target.Target))))
+ emit(lootEvent(capNeutronPOC, vulnLoot(result.TemplateResult(target.Target))))
}
}
diff --git a/pkg/tools/scan/aggregate.go b/pkg/tools/scan/aggregate.go
index 28a8f33a..7c1a3e30 100644
--- a/pkg/tools/scan/aggregate.go
+++ b/pkg/tools/scan/aggregate.go
@@ -9,8 +9,8 @@ import (
"strings"
"github.com/chainreactors/aiscan/core/output"
- "github.com/chainreactors/utils/parsers"
sdktypes "github.com/chainreactors/sdk/pkg/types"
+ "github.com/chainreactors/utils/parsers"
)
var firstURLPattern = regexp.MustCompile(`https?://[^\s"'<>]+`)
@@ -60,6 +60,18 @@ func AggregateStructuredResult(result *output.Result) []output.Asset {
for _, err := range result.Errors {
builder.addError(err)
}
+ // Older stored results may already contain AI notes/responses. Rebuilding the
+ // service/path buckets should not discard those supplemental items.
+ for _, asset := range result.Assets {
+ for _, item := range asset.Items {
+ switch item.Kind {
+ case output.AssetItemService, output.AssetItemPath, output.AssetItemFingerprint, output.AssetItemLoot, output.AssetItemError:
+ continue
+ }
+ target := output.FirstNonEmpty(item.Target, asset.Target, "Scan")
+ builder.addItem(target, targetKeys(asset.Key, asset.Target, item.Target), itemIdentity(item), item)
+ }
+ }
return builder.assets()
}
@@ -121,6 +133,8 @@ func (b *assetBuilder) addWebProbe(probe *sdktypes.SprayResult) {
"status", probe.Status,
"length", probe.BodyLength,
"title", probe.Title,
+ "content_type", probe.ContentType,
+ "redirect_url", probe.RedirectURL,
"fingers", fingerNames,
"validated", isSprayValidated(sourceName),
)
@@ -358,9 +372,6 @@ func addTargetKeys(keys map[string]struct{}, value string) {
if origin := urlOrigin(withoutHost); origin != "" {
addCanonicalKey(keys, origin)
}
- if host := urlHost(withoutHost); host != "" {
- addCanonicalKey(keys, host)
- }
if normalized := normalizedURL(withoutHost); normalized != "" {
addCanonicalKey(keys, normalized)
}
@@ -405,14 +416,6 @@ func urlOrigin(value string) string {
return strings.ToLower(parsed.Scheme + "://" + stripDefaultPort(parsed))
}
-func urlHost(value string) string {
- parsed, err := url.Parse(strings.TrimSpace(value))
- if err != nil || parsed.Host == "" {
- return ""
- }
- return strings.ToLower(stripDefaultPort(parsed))
-}
-
func stripDefaultPort(u *url.URL) string {
host := u.Hostname()
port := u.Port()
diff --git a/pkg/tools/scan/aggregate_test.go b/pkg/tools/scan/aggregate_test.go
new file mode 100644
index 00000000..8bc86ee2
--- /dev/null
+++ b/pkg/tools/scan/aggregate_test.go
@@ -0,0 +1,45 @@
+package scan
+
+import (
+ "testing"
+
+ "github.com/chainreactors/aiscan/core/output"
+ "github.com/chainreactors/utils/parsers"
+)
+
+func TestAggregateStructuredResultKeepsHTTPOriginsSeparate(t *testing.T) {
+ result := &output.Result{
+ Services: []*parsers.GOGOResult{
+ {Ip: "111.63.65.103", Port: "80", Protocol: "http"},
+ {Ip: "111.63.65.103", Port: "443", Protocol: "https"},
+ {Ip: "111.63.65.103", Port: "icmp", Protocol: "icmp"},
+ },
+ WebProbes: []*parsers.SprayResult{
+ {UrlString: "http://111.63.65.103/admin", Status: 200, Source: parsers.CheckSource},
+ {UrlString: "https://111.63.65.103/login", Status: 301, Source: parsers.CheckSource},
+ },
+ }
+
+ assets := AggregateStructuredResult(result)
+ if len(assets) != 3 {
+ t.Fatalf("got %d assets, want separate http, https, and icmp services: %#v", len(assets), assets)
+ }
+ for _, asset := range assets {
+ services, paths := 0, 0
+ for _, item := range asset.Items {
+ switch item.Kind {
+ case output.AssetItemService:
+ services++
+ case output.AssetItemPath:
+ paths++
+ }
+ }
+ wantPaths := 1
+ if asset.Target == "111.63.65.103:icmp" {
+ wantPaths = 0
+ }
+ if services != 1 || paths != wantPaths {
+ t.Fatalf("asset %q has %d services and %d paths, want 1 service and %d paths", asset.Target, services, paths, wantPaths)
+ }
+ }
+}
diff --git a/pkg/tools/scan/bridge.go b/pkg/tools/scan/bridge.go
index bbf84d7a..fd6ae0fa 100644
--- a/pkg/tools/scan/bridge.go
+++ b/pkg/tools/scan/bridge.go
@@ -3,9 +3,9 @@ package scan
import (
"context"
"fmt"
- "os"
"github.com/chainreactors/aiscan/core/eventbus"
+ "github.com/chainreactors/aiscan/pkg/commands"
"github.com/chainreactors/aiscan/pkg/tools/scan/pipeline"
)
@@ -26,7 +26,7 @@ func subscribePipeline(bus *eventbus.Bus[pipeline.Observation], coll *collector,
bus.Subscribe(func(obs pipeline.Observation) {
e, _ := obs.Event.(event)
if trace := formatTraceEvent(pipelineEvent{Action: obs.Action, Capability: obs.Capability, Event: e}); trace != "" {
- fmt.Fprintln(os.Stderr, trace)
+ fmt.Fprintln(commands.Output, trace)
}
})
}
diff --git a/pkg/tools/scan/collector.go b/pkg/tools/scan/collector.go
index 14f655ef..303692c4 100644
--- a/pkg/tools/scan/collector.go
+++ b/pkg/tools/scan/collector.go
@@ -9,9 +9,9 @@ import (
"github.com/chainreactors/aiscan/core/output"
"github.com/chainreactors/aiscan/pkg/tools/scan/pipeline"
- "github.com/chainreactors/utils/parsers"
sdktypes "github.com/chainreactors/sdk/pkg/types"
"github.com/chainreactors/utils"
+ "github.com/chainreactors/utils/parsers"
)
type sprayObservation struct {
@@ -186,10 +186,6 @@ func (c *collector) AssetReport() string {
return output.FormatAssetReport(c.StructuredResult(), false)
}
-func (c *collector) LootReport() string {
- return c.AssetReport()
-}
-
type statsSnapshot struct {
StartedAt time.Time
FinishedAt time.Time
diff --git a/pkg/tools/scan/command.go b/pkg/tools/scan/command.go
index 13816839..097a59df 100644
--- a/pkg/tools/scan/command.go
+++ b/pkg/tools/scan/command.go
@@ -7,14 +7,14 @@ import (
"os"
"path/filepath"
- "github.com/chainreactors/aiscan/pkg/agent"
- "github.com/chainreactors/aiscan/pkg/commands"
- "github.com/chainreactors/aiscan/pkg/tools/toolargs"
"github.com/chainreactors/aiscan/core/eventbus"
"github.com/chainreactors/aiscan/core/output"
+ "github.com/chainreactors/aiscan/pkg/agent"
+ "github.com/chainreactors/aiscan/pkg/commands"
"github.com/chainreactors/aiscan/pkg/telemetry"
"github.com/chainreactors/aiscan/pkg/tools/scan/engine"
"github.com/chainreactors/aiscan/pkg/tools/scan/pipeline"
+ "github.com/chainreactors/aiscan/pkg/tools/toolargs"
goflags "github.com/jessevdk/go-flags"
)
@@ -41,7 +41,6 @@ type flags struct {
AssetReportFile string `short:"F" long:"format" description:"Write aggregated asset report to file"`
NoColor bool `long:"no-color" description:"Disable ANSI colors in terminal output"`
Ports string `long:"ports" description:"Ports for gogo scanning; defaults to all in quick and - in full"`
- Port string `long:"port" hidden:"true" description:"Alias for --ports"`
Threads int // derived from Thread; not a CLI flag
Timeout int `long:"timeout" description:"Per-probe timeout in seconds" default:"5"`
SprayThreads int // derived from Thread; not a CLI flag
@@ -57,7 +56,6 @@ type flags struct {
MaxNeutronPerFP int `long:"max-neutron-per-finger" description:"Maximum neutron templates per fingerprint" default:"20"`
BroadPOC bool `long:"broad-poc" description:"Run POC templates even without matching fingerprints"`
Verify string `long:"verify" description:"Use AI to verify loots at priority threshold: auto, off, low, medium, high, or critical"`
- VerifyTimeout int `long:"verify-timeout" hidden:"true" description:"Deprecated compatibility option; ignored" default:"120"`
}
func New(engineSet *engine.Set, opts ...Option) *Command {
@@ -71,6 +69,13 @@ func New(engineSet *engine.Set, opts ...Option) *Command {
return cmd
}
+func (c *Command) InitLogger(logger telemetry.Logger) {
+ c.Base.InitLogger(logger)
+ if c.parent != nil {
+ c.parent.Cfg.Logger = c.Logger
+ }
+}
+
func (c *Command) Name() string { return "scan" }
func (c *Command) Usage() string {
@@ -133,7 +138,8 @@ Examples:
scan -l targets.txt --mode full --zombie-top 5`
}
-func (c *Command) Execute(ctx context.Context, args []string) error {
+func (c *Command) Execute(ctx context.Context, args []string) (err error) {
+ defer telemetry.RecoverAsError("scan", &err)
out, _, err := c.execute(ctx, c.resolveRelativePaths(args), nil)
if err != nil {
return err
diff --git a/pkg/tools/scan/command_test.go b/pkg/tools/scan/command_test.go
index 98cb9610..51942709 100644
--- a/pkg/tools/scan/command_test.go
+++ b/pkg/tools/scan/command_test.go
@@ -135,13 +135,12 @@ func TestScanOptionsResolveDiscoveryFlags(t *testing.T) {
flagValues := flags{
Mode: scanModeFull,
Ports: "top100",
- Port: "80,443",
Threads: 77, // set internally by derivePerInvocationThreads
Timeout: 6,
}
opts = resolveScanOptions(flagValues)
- if opts.Discovery.Ports != "80,443" {
- t.Fatalf("discovery ports = %q, want --port override", opts.Discovery.Ports)
+ if opts.Discovery.Ports != "top100" {
+ t.Fatalf("discovery ports = %q, want --ports override", opts.Discovery.Ports)
}
if opts.Discovery.Threads != 77 || opts.Discovery.Timeout != 6 {
t.Fatalf("discovery options = %#v", opts.Discovery)
@@ -188,20 +187,6 @@ func TestScanRejectsRemovedAIFlag(t *testing.T) {
}
}
-func TestScanAcceptsDeprecatedCompatibilityFlags(t *testing.T) {
- cmd := New(&engine.Set{})
- commands.Output.Reset(nil)
- err := cmd.Execute(context.Background(), []string{
- "-i", "http://127.0.0.1",
- "--verify-timeout", "1",
- "--port", "top100",
- "--no-color",
- })
- if err != nil {
- t.Fatalf("Execute() with deprecated compatibility flags error = %v", err)
- }
-}
-
func TestScanOptionsResolveWebStrategyFlags(t *testing.T) {
flags := flags{
Dictionaries: []string{"paths.txt", "api.txt"},
@@ -824,7 +809,7 @@ func TestLootPriorityDefaults(t *testing.T) {
if got := wp.Priority; got != string(priorityHigh) {
t.Fatalf("weakpass priority = %s, want %s", got, priorityHigh)
}
- vl := vulnLoot(&sdktypes.VulnResult{Target: "http://127.0.0.1", TemplateID: "test", Severity: "high", TemplateName: "test high"})
+ vl := vulnLoot(&sdktypes.TemplateResult{Target: "http://127.0.0.1", TemplateID: "test", Severity: "high", TemplateName: "test high"})
if got := vl.Priority; got != string(priorityHigh) {
t.Fatalf("vuln priority = %s, want %s", got, priorityHigh)
}
diff --git a/pkg/tools/scan/engine/gogo.go b/pkg/tools/scan/engine/gogo.go
index b4249066..c5e9460c 100644
--- a/pkg/tools/scan/engine/gogo.go
+++ b/pkg/tools/scan/engine/gogo.go
@@ -34,11 +34,11 @@ func GogoScanStream(ctx context.Context, eng *gogo.Engine, opts GogoScanOptions)
runOpt := buildGogoRunnerOption(opts)
gogoCtx := gogo.NewContext().
WithContext(ctx).
- SetThreads(opts.Threads).
- SetOption(runOpt).
- SetStatsHandler(opts.OnStats)
+ WithThreads(opts.Threads).
+ WithOption(runOpt).
+ WithStatsHandler(opts.OnStats)
if opts.Proxy != "" {
- gogoCtx = gogoCtx.SetProxy(opts.Proxy)
+ gogoCtx = gogoCtx.WithProxy(opts.Proxy)
}
resultCh, err := eng.Execute(gogoCtx, gogo.NewScanTask(opts.Target, opts.Ports))
if err != nil {
diff --git a/pkg/tools/scan/engine/set.go b/pkg/tools/scan/engine/set.go
index 7ca2626d..61e744f8 100644
--- a/pkg/tools/scan/engine/set.go
+++ b/pkg/tools/scan/engine/set.go
@@ -2,12 +2,15 @@ package engine
import (
"context"
+ "fmt"
"net/http"
"net/url"
+ "strings"
"sync"
"github.com/chainreactors/aiscan/core/resources"
"github.com/chainreactors/aiscan/pkg/telemetry"
+ "github.com/chainreactors/aiscan/pkg/util"
"github.com/chainreactors/fingers/alias"
fingersLib "github.com/chainreactors/fingers/fingers"
neutronhttp "github.com/chainreactors/neutron/protocols/http"
@@ -90,36 +93,39 @@ func initWithCapacity(ctx context.Context, opts resources.Options, caps Capacity
}
set.Resources = resourceSet
if resourceSet.RemoteEnabled {
- logger.Infof("resources source=cyberhub mode=%s fingers=%d neutron=%d", resourceSet.Mode, resourceSet.RemoteFingers, resourceSet.RemoteNeutron)
+ logger.Infof("%s", telemetry.StartupOK("cyberhub", fmt.Sprintf("%s · %s fingers · %s neutron",
+ resourceSet.Mode,
+ util.FormatNumber(resourceSet.RemoteFingers),
+ util.FormatNumber(resourceSet.RemoteNeutron))))
if resourceSet.RemoteFingersErr != nil {
- logger.Warnf("resources source=cyberhub type=fingers error=%q fallback=local", resourceSet.RemoteFingersErr)
+ logger.Warnf("%s", telemetry.StartupLine("skip", "cyberhub", fmt.Sprintf("fingers fallback=local reason=%q", resourceSet.RemoteFingersErr)))
} else if resourceSet.RemoteFingers == 0 {
- logger.Warnf("resources source=cyberhub type=fingers count=0 fallback=local")
+ logger.Warnf("%s", telemetry.StartupLine("skip", "cyberhub", "fingers fallback=local reason=\"empty\""))
}
if resourceSet.RemoteNeutronErr != nil {
- logger.Warnf("resources source=cyberhub type=neutron error=%q fallback=local", resourceSet.RemoteNeutronErr)
+ logger.Warnf("%s", telemetry.StartupLine("skip", "cyberhub", fmt.Sprintf("neutron fallback=local reason=%q", resourceSet.RemoteNeutronErr)))
} else if resourceSet.RemoteNeutron == 0 {
- logger.Warnf("resources source=cyberhub type=neutron count=0 fallback=local")
+ logger.Warnf("%s", telemetry.StartupLine("skip", "cyberhub", "neutron fallback=local reason=\"empty\""))
}
}
fEngine := resourceSet.Fingers
if fEngine == nil {
- logger.Warnf("engine=fingers templates=0 action=disable")
+ logger.Warnf("%s", telemetry.StartupLine("skip", "fingers", "no templates"))
} else if fEngine.Count() > 0 {
set.Fingers = fEngine
- logger.Infof("engine=fingers status=ready templates=%d", fEngine.Count())
+ logger.Infof("%s", telemetry.StartupOK("fingers", util.FormatNumber(fEngine.Count())+" templates"))
} else {
- logger.Warnf("engine=fingers templates=0 action=disable")
+ logger.Warnf("%s", telemetry.StartupLine("skip", "fingers", "no templates"))
_ = fEngine.Close()
}
nEngine := resourceSet.Neutron
if nEngine != nil && nEngine.Count() > 0 {
set.Neutron = nEngine
- logger.Infof("engine=neutron status=ready templates=%d", nEngine.Count())
+ logger.Infof("%s", telemetry.StartupOK("neutron", util.FormatNumber(nEngine.Count())+" templates"))
} else {
- logger.Warnf("engine=neutron templates=0 action=disable")
+ logger.Warnf("%s", telemetry.StartupLine("skip", "neutron", "no templates"))
if nEngine != nil {
_ = nEngine.Close()
}
@@ -134,8 +140,7 @@ func initWithCapacity(ctx context.Context, opts resources.Options, caps Capacity
aliases = set.Fingers.Aliases()
}
set.Index.BuildWithFingers(fingers, aliases, set.Neutron.Get())
- logger.Infof("index=finger_poc status=ready fingers=%d aliases=%d templates=%d",
- len(fingers), len(aliases), len(set.Neutron.Get()))
+ logger.Infof("%s", telemetry.StartupOK("finger-poc", fingerPOCDetail(len(fingers), len(aliases), len(set.Neutron.Get()))))
}
gogoConfig := gogo.NewConfig()
@@ -152,12 +157,17 @@ func initWithCapacity(ctx context.Context, opts resources.Options, caps Capacity
if proxy != "" {
gogoConfig.WithProxy(proxy)
}
- gogoEngine, err := gogo.NewEngine(gogoConfig)
+ var gogoEngine *gogo.Engine
+ func() {
+ restoreLogs := telemetry.SuppressGlobalNonErrors()
+ defer restoreLogs()
+ gogoEngine, err = gogo.NewEngine(gogoConfig)
+ }()
if err != nil {
- logger.Warnf("engine=gogo status=disabled error=%q", err)
+ logger.Warnf("%s", telemetry.StartupLine("fail", "gogo", err.Error()))
} else {
set.Gogo = gogoEngine
- logger.Infof("engine=gogo status=ready")
+ logger.Infof("%s", telemetry.StartupOK("gogo", ""))
}
sprayConfig := spray.NewConfig()
@@ -171,12 +181,17 @@ func initWithCapacity(ctx context.Context, opts resources.Options, caps Capacity
if proxy != "" {
sprayConfig.WithProxy(proxy)
}
- sprayEngine, err := spray.NewEngine(sprayConfig)
+ var sprayEngine *spray.Engine
+ func() {
+ restoreLogs := telemetry.SuppressGlobalNonErrors()
+ defer restoreLogs()
+ sprayEngine, err = spray.NewEngine(sprayConfig)
+ }()
if err != nil {
- logger.Warnf("engine=spray status=disabled error=%q", err)
+ logger.Warnf("%s", telemetry.StartupLine("fail", "spray", err.Error()))
} else {
set.Spray = sprayEngine
- logger.Infof("engine=spray status=ready")
+ logger.Infof("%s", telemetry.StartupOK("spray", ""))
}
zombieConfig := sdkzombie.NewConfig()
@@ -189,10 +204,10 @@ func initWithCapacity(ctx context.Context, opts resources.Options, caps Capacity
}
zombieEngine, err := sdkzombie.NewEngine(zombieConfig)
if err != nil {
- logger.Warnf("engine=zombie status=disabled error=%q", err)
+ logger.Warnf("%s", telemetry.StartupLine("fail", "zombie", err.Error()))
} else {
set.Zombie = zombieEngine
- logger.Infof("engine=zombie status=ready")
+ logger.Infof("%s", telemetry.StartupOK("zombie", ""))
}
if set.Neutron != nil && caps.Neutron > 0 {
@@ -207,6 +222,17 @@ func initWithCapacity(ctx context.Context, opts resources.Options, caps Capacity
return set, nil
}
+func fingerPOCDetail(fingers, aliases, templates int) string {
+ parts := []string{
+ util.FormatNumber(fingers) + " fingers",
+ util.FormatNumber(templates) + " templates",
+ }
+ if aliases > 0 {
+ parts = append(parts, util.FormatNumber(aliases)+" aliases")
+ }
+ return strings.Join(parts, " · ")
+}
+
// ApplyNeutronProxy sets neutron DefaultOption/DefaultTransport proxy. The
// published neutron SDK does not yet support per-Config proxy, so we set the
// process-wide defaults. Each neutron execution creates its own transport clone,
diff --git a/pkg/tools/scan/engine/set_test.go b/pkg/tools/scan/engine/set_test.go
index ebd07478..c80ce2c3 100644
--- a/pkg/tools/scan/engine/set_test.go
+++ b/pkg/tools/scan/engine/set_test.go
@@ -7,6 +7,7 @@ import (
"time"
"github.com/chainreactors/aiscan/pkg/telemetry"
+ gogopkg "github.com/chainreactors/gogo/v2/pkg"
"github.com/chainreactors/neutron/operators"
neutronhttp "github.com/chainreactors/neutron/protocols/http"
"github.com/chainreactors/neutron/templates"
@@ -428,8 +429,8 @@ func TestGogoStatsHandlerSafeAfterCancel(t *testing.T) {
var statsCalled atomic.Int32
gogoCtx := gogo.NewContext().
WithContext(ctx).
- SetThreads(1).
- SetStatsHandler(func(s sdktypes.Stats) {
+ WithThreads(1).
+ WithStatsHandler(func(s sdktypes.Stats) {
statsCalled.Add(1)
})
@@ -454,7 +455,7 @@ func TestSprayStatsHandlerSafeAfterCancel(t *testing.T) {
sprayCtx := spray.NewContext().
WithContext(ctx).
- SetStatsHandler(func(s sdktypes.Stats) {})
+ WithStatsHandler(func(s sdktypes.Stats) {})
ch, err := eng.Execute(sprayCtx, spray.NewCheckTask([]string{"http://127.0.0.1:1"}))
if err != nil {
@@ -476,9 +477,9 @@ func TestZombieStatsHandlerSafeAfterCancel(t *testing.T) {
zCtx := sdkzombie.NewContext().
WithContext(ctx).
- SetThreads(1).
- SetTimeout(1).
- SetStatsHandler(func(s sdktypes.Stats) {})
+ WithThreads(1).
+ WithTimeout(1).
+ WithStatsHandler(func(s sdktypes.Stats) {})
ch, err := eng.Execute(zCtx, sdkzombie.NewBruteTask([]sdkzombie.Target{{IP: "127.0.0.1", Port: "1", Service: "ssh"}}))
if err != nil {
@@ -594,3 +595,30 @@ func testNeutronTemplate(id string) *templates.Template {
},
}
}
+
+// Regression: aiscan drives gogo through the SDK, whose applyInjectedNeutron
+// populates gogo's pkg.TemplateMap but must also populate pkg.ChainExec.
+// engine.NeutronScan calls pkg.ChainExec.Execute on every open host when
+// Exploit != "none" (aiscan's default is "auto"); a nil ChainExec turns that
+// into a nil-receiver panic caught only by the ants pool ("worker exits from
+// panic"), silently dropping the host's result.
+func TestGogoEngineInjectsChainExecutor(t *testing.T) {
+ gogopkg.ChainExec = nil
+
+ neu, err := neutron.NewEngineWithTemplates(
+ (neutron.Templates{}).Merge([]*templates.Template{testNeutronTemplate("chainexec-regression")}),
+ )
+ if err != nil {
+ t.Fatalf("build neutron engine: %v", err)
+ }
+
+ eng, err := gogo.NewEngine(gogo.NewConfig().WithNeutronEngine(neu))
+ if err != nil {
+ t.Fatalf("build gogo engine: %v", err)
+ }
+ defer eng.Close()
+
+ if gogopkg.ChainExec == nil {
+ t.Fatal("pkg.ChainExec is nil after gogo engine init with injected neutron templates; exploit(-e) scan would nil-panic in engine.NeutronScan")
+ }
+}
diff --git a/pkg/tools/scan/engine/set_uncover_recon.go b/pkg/tools/scan/engine/set_uncover_recon.go
index 29b2fade..778f9072 100644
--- a/pkg/tools/scan/engine/set_uncover_recon.go
+++ b/pkg/tools/scan/engine/set_uncover_recon.go
@@ -2,7 +2,11 @@
package engine
-import "github.com/chainreactors/aiscan/pkg/telemetry"
+import (
+ "strings"
+
+ "github.com/chainreactors/aiscan/pkg/telemetry"
+)
func (e *Set) SetupUncover(opts ReconOptions, logger telemetry.Logger) {
if logger == nil {
@@ -17,7 +21,7 @@ func (e *Set) SetupUncover(opts ReconOptions, logger telemetry.Logger) {
_ = e.Uncover.Close()
}
e.Uncover = eng
- logger.Infof("engine=uncover status=ready sources=%v", e.Uncover.Sources())
+ logger.Infof("%s", telemetry.StartupOK("uncover", strings.Join(e.Uncover.Sources(), ",")))
}
func mergeReconOptions(base, next ReconOptions) ReconOptions {
diff --git a/pkg/tools/scan/engine/spray.go b/pkg/tools/scan/engine/spray.go
index 082da3a6..662be303 100644
--- a/pkg/tools/scan/engine/spray.go
+++ b/pkg/tools/scan/engine/spray.go
@@ -46,8 +46,8 @@ func SprayCheckStream(ctx context.Context, eng *spray.Engine, opts SprayCheckOpt
runCtx, cancel := sprayInvocationContext(ctx, opts)
sprayCtx := spray.NewContext().
WithContext(runCtx).
- SetOption(buildSprayOption(opts)).
- SetStatsHandler(opts.OnStats)
+ WithOption(buildSprayOption(opts)).
+ WithStatsHandler(opts.OnStats)
var resultCh <-chan sdktypes.Result
var err error
diff --git a/pkg/tools/scan/engine/zombie.go b/pkg/tools/scan/engine/zombie.go
index 84e85a99..3c09bb98 100644
--- a/pkg/tools/scan/engine/zombie.go
+++ b/pkg/tools/scan/engine/zombie.go
@@ -31,12 +31,12 @@ func ZombieWeakpassStream(ctx context.Context, eng *sdkzombie.Engine, opts Zombi
}
zctx := sdkzombie.NewContext().
WithContext(ctx).
- SetThreads(opts.Threads).
- SetTimeout(opts.Timeout).
- SetTop(opts.Top).
- SetStatsHandler(opts.OnStats)
+ WithThreads(opts.Threads).
+ WithTimeout(opts.Timeout).
+ WithTop(opts.Top).
+ WithStatsHandler(opts.OnStats)
if opts.Proxy != "" {
- zctx = zctx.SetProxy(opts.Proxy)
+ zctx = zctx.WithProxy(opts.Proxy)
}
task := sdkzombie.NewBruteTask(opts.Targets)
diff --git a/pkg/tools/scan/event.go b/pkg/tools/scan/event.go
index b699559f..fc4db17d 100644
--- a/pkg/tools/scan/event.go
+++ b/pkg/tools/scan/event.go
@@ -205,7 +205,7 @@ func weakpassLoot(result *parsers.ZombieResult) output.Loot {
}
}
-func vulnLoot(result *sdktypes.VulnResult) output.Loot {
+func vulnLoot(result *sdktypes.TemplateResult) output.Loot {
pri := string(priorityHigh)
switch result.Severity {
case "critical":
diff --git a/pkg/tools/scan/jsonl_writer.go b/pkg/tools/scan/jsonl_writer.go
index 51f0398c..f160b6db 100644
--- a/pkg/tools/scan/jsonl_writer.go
+++ b/pkg/tools/scan/jsonl_writer.go
@@ -3,12 +3,10 @@ package scan
import (
"strings"
- "github.com/chainreactors/aiscan/pkg/agent"
"github.com/chainreactors/aiscan/core/eventbus"
"github.com/chainreactors/aiscan/core/output"
+ "github.com/chainreactors/aiscan/pkg/agent"
"github.com/chainreactors/aiscan/pkg/tools/scan/pipeline"
- "github.com/chainreactors/utils/parsers"
- sdktypes "github.com/chainreactors/sdk/pkg/types"
)
type scanJSONLWriter struct {
@@ -109,23 +107,3 @@ func capabilityRecordType(source string) output.RecordType {
return output.RecordType(source)
}
}
-
-func ObservationToRecord(obs pipeline.Observation) *output.Record {
- if obs.Action != pipeline.ActionAccept {
- return nil
- }
- e, ok := obs.Event.(event)
- if !ok {
- return nil
- }
- records := observationToRecords(e)
- if len(records) == 0 {
- return nil
- }
- return &records[0]
-}
-
-type ServiceResult = parsers.GOGOResult
-type SprayResult = parsers.SprayResult
-type ZombieResult = parsers.ZombieResult
-type VulnResult = sdktypes.VulnResult
diff --git a/pkg/tools/scan/scan_options.go b/pkg/tools/scan/scan_options.go
index 206cd52e..3dcc5f80 100644
--- a/pkg/tools/scan/scan_options.go
+++ b/pkg/tools/scan/scan_options.go
@@ -43,13 +43,10 @@ type credentialOptions struct {
func resolveScanOptions(flags flags) scanOptions {
ports := defaultDiscoveryPorts(flags.Mode)
- explicitDiscovery := flags.Ports != "" || flags.Port != ""
+ explicitDiscovery := flags.Ports != ""
if flags.Ports != "" {
ports = flags.Ports
}
- if flags.Port != "" {
- ports = flags.Port
- }
return scanOptions{
Discovery: discoveryOptions{
Ports: ports,
diff --git a/pkg/tools/scan/sco.go b/pkg/tools/scan/sco.go
new file mode 100644
index 00000000..ed8a1b47
--- /dev/null
+++ b/pkg/tools/scan/sco.go
@@ -0,0 +1,39 @@
+//go:build cstx_native
+
+package scan
+
+import (
+ "encoding/json"
+
+ "github.com/chainreactors/aiscan/core/output"
+ "github.com/chainreactors/libcstx/go"
+)
+
+func buildSCONodes(result *output.Result) []json.RawMessage {
+ var allNodes []cstx.SCONode
+
+ if len(result.Services) > 0 {
+ if nodes, err := cstx.Parse("gogo", result.Services); err == nil {
+ allNodes = append(allNodes, nodes...)
+ }
+ }
+ if len(result.WebProbes) > 0 {
+ if nodes, err := cstx.Parse("spray", result.WebProbes); err == nil {
+ allNodes = append(allNodes, nodes...)
+ }
+ }
+
+ seen := make(map[string]struct{}, len(allNodes))
+ out := make([]json.RawMessage, 0, len(allNodes))
+ for _, n := range allNodes {
+ id := n.CstxID()
+ if _, ok := seen[id]; ok {
+ continue
+ }
+ seen[id] = struct{}{}
+ if raw, err := json.Marshal(n); err == nil {
+ out = append(out, raw)
+ }
+ }
+ return out
+}
diff --git a/pkg/tools/scan/sco_stub.go b/pkg/tools/scan/sco_stub.go
new file mode 100644
index 00000000..b2d9c606
--- /dev/null
+++ b/pkg/tools/scan/sco_stub.go
@@ -0,0 +1,11 @@
+//go:build !cstx_native
+
+package scan
+
+import (
+ "encoding/json"
+
+ "github.com/chainreactors/aiscan/core/output"
+)
+
+func buildSCONodes(_ *output.Result) []json.RawMessage { return nil }
diff --git a/pkg/tools/scan/sco_test.go b/pkg/tools/scan/sco_test.go
new file mode 100644
index 00000000..b0ac1f88
--- /dev/null
+++ b/pkg/tools/scan/sco_test.go
@@ -0,0 +1,85 @@
+//go:build cstx_native
+
+package scan
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/chainreactors/aiscan/core/eventbus"
+ "github.com/chainreactors/aiscan/pkg/tools/scan/pipeline"
+ "github.com/chainreactors/utils/parsers"
+)
+
+func TestStructuredResultContainsSCONodes(t *testing.T) {
+ bus := eventbus.New[pipeline.Observation]()
+ coll := newCollector([]string{"192.168.1.0/24"}, nil, false, false)
+ subscribePipeline(bus, coll, false)
+
+ // Simulate gogo finding a service
+ gogoResult := &parsers.GOGOResult{
+ Ip: "192.168.1.1", Port: "80", Protocol: "tcp",
+ Status: "200", Uri: "http://192.168.1.1/",
+ Title: "Test", Midware: "nginx",
+ Frameworks: parsers.Frameworks{
+ "nginx": &parsers.Framework{Name: "nginx"},
+ },
+ }
+ coll.Observe(pipelineEvent{
+ Action: pipeline.ActionAccept,
+ Event: targetEvent(capGogoPortscan, "", newServiceTarget("", gogoResult)),
+ })
+
+ // Simulate spray finding a web page
+ sprayResult := &parsers.SprayResult{
+ UrlString: "http://192.168.1.1/", Title: "Test",
+ Status: 200, Host: "192.168.1.1",
+ BodyLength: 1024, ContentType: "text/html",
+ }
+ coll.Observe(pipelineEvent{
+ Action: pipeline.ActionAccept,
+ Event: targetEvent(capSprayCheck, "", newWebProbeTarget("", capSprayCheck, "", sprayResult)),
+ })
+
+ coll.Finish()
+ result := coll.StructuredResult()
+
+ // Verify nodes exist
+ if len(result.Nodes) == 0 {
+ t.Fatal("StructuredResult().Nodes is empty — SCO conversion did not run")
+ }
+
+ // Parse nodes back and check types
+ types := make(map[string]int)
+ for _, raw := range result.Nodes {
+ var header struct {
+ Type string `json:"cstx_type"`
+ ID string `json:"cstx_id"`
+ }
+ if err := json.Unmarshal(raw, &header); err != nil {
+ t.Errorf("failed to unmarshal node: %v", err)
+ continue
+ }
+ types[header.Type]++
+ t.Logf(" %s %s", header.Type, header.ID)
+ }
+
+ // Should have at least ip, port, app from gogo + url, app from spray
+ for _, expect := range []string{"ip", "port", "app"} {
+ if types[expect] == 0 {
+ t.Errorf("missing SCO node type: %s", expect)
+ }
+ }
+
+ t.Logf("total SCO nodes: %d, types: %v", len(result.Nodes), types)
+
+ // Verify JSON serialization round-trip
+ resultJSON, err := json.Marshal(result)
+ if err != nil {
+ t.Fatalf("failed to marshal result: %v", err)
+ }
+ if len(resultJSON) == 0 {
+ t.Fatal("empty result JSON")
+ }
+ t.Logf("Result JSON size: %d bytes", len(resultJSON))
+}
diff --git a/pkg/tools/scan/structured.go b/pkg/tools/scan/structured.go
index c93a6731..39b2c079 100644
--- a/pkg/tools/scan/structured.go
+++ b/pkg/tools/scan/structured.go
@@ -45,5 +45,6 @@ func (c *collector) StructuredResult() *output.Result {
}
result.Assets = AggregateStructuredResult(result)
+ result.Nodes = buildSCONodes(result)
return result
}
diff --git a/pkg/tools/search/fetch.go b/pkg/tools/search/fetch.go
index 60ad7bf4..9a5b3ecb 100644
--- a/pkg/tools/search/fetch.go
+++ b/pkg/tools/search/fetch.go
@@ -14,6 +14,7 @@ import (
"github.com/chainreactors/aiscan/pkg/agent/truncate"
"github.com/chainreactors/aiscan/pkg/commands"
+ "github.com/chainreactors/aiscan/pkg/telemetry"
)
const (
@@ -158,7 +159,8 @@ func NewFetchCommand() *FetchCommand {
func (c *FetchCommand) ClearCache() { c.cache.Clear() }
-func (c *FetchCommand) Execute(ctx context.Context, args []string) error {
+func (c *FetchCommand) Execute(ctx context.Context, args []string) (err error) {
+ defer telemetry.RecoverAsError("fetch", &err)
rawURL, extract, err := parseFetchArgs(args)
if err != nil {
return err
@@ -550,8 +552,9 @@ var (
allTagRe = regexp.MustCompile(`<[^>]+>`)
- multiNewlineRe = regexp.MustCompile(`\n{4,}`)
- multiSpaceRe = regexp.MustCompile(`[ \t]{2,}`)
+ multiNewlineRe = regexp.MustCompile(`\n{4,}`)
+ multiSpaceRe = regexp.MustCompile(`[ \t]{2,}`)
+ blankLineSplitRe = regexp.MustCompile(`\n\s*\n`)
commentRe = regexp.MustCompile(`(?s)`)
)
@@ -696,7 +699,7 @@ func extractTerms(hint string) []string {
}
func splitContentBlocks(content string) []string {
- parts := regexp.MustCompile(`\n\s*\n`).Split(content, -1)
+ parts := blankLineSplitRe.Split(content, -1)
blocks := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
diff --git a/pkg/tools/search/tavily.go b/pkg/tools/search/tavily.go
index 46fe586b..339b3944 100644
--- a/pkg/tools/search/tavily.go
+++ b/pkg/tools/search/tavily.go
@@ -49,7 +49,7 @@ func NewTavilySearch(builtinKeys string) *TavilySearch {
c := &TavilySearch{
client: &http.Client{
Timeout: searchTimeout,
- Transport: &http.Transport{Proxy: http.ProxyFromEnvironment},
+ Transport: proxyTransport(""),
},
}
@@ -82,21 +82,23 @@ func NewTavilySearch(builtinKeys string) *TavilySearch {
return c
}
+// proxyTransport builds an HTTP transport that routes through proxy when it is a
+// valid URL, falling back to the environment proxy for an empty or unparseable
+// value.
+func proxyTransport(proxy string) *http.Transport {
+ t := &http.Transport{Proxy: http.ProxyFromEnvironment}
+ if proxy != "" {
+ if u, err := url.Parse(proxy); err == nil {
+ t.Proxy = http.ProxyURL(u)
+ }
+ }
+ return t
+}
+
func (c *TavilySearch) SetProxy(proxyURLStr string) {
c.mu.Lock()
defer c.mu.Unlock()
- transport := &http.Transport{}
- if proxyURLStr != "" {
- proxyURL, err := url.Parse(proxyURLStr)
- if err == nil {
- transport.Proxy = http.ProxyURL(proxyURL)
- } else {
- transport.Proxy = http.ProxyFromEnvironment
- }
- } else {
- transport.Proxy = http.ProxyFromEnvironment
- }
- c.client.Transport = transport
+ c.client.Transport = proxyTransport(proxyURLStr)
}
func (c *TavilySearch) rotateKey() bool {
@@ -110,6 +112,16 @@ func (c *TavilySearch) rotateKey() bool {
return true
}
+// currentAPIKey snapshots the active key index and value under the lock. The
+// agent runs tool calls in parallel, so a shared *TavilySearch may have Execute
+// racing rotateKey; callers must read the key through here rather than touch
+// c.apiKey/c.currentKey directly.
+func (c *TavilySearch) currentAPIKey() (int, string) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return c.currentKey, c.apiKey
+}
+
func (c *TavilySearch) Execute(ctx context.Context, args []string) (string, error) {
query, num, err := parseTavilyArgs(args)
if err != nil {
@@ -118,9 +130,9 @@ func (c *TavilySearch) Execute(ctx context.Context, args []string) (string, erro
switch c.backend {
case backendTavily:
- startKey := c.currentKey
+ startKey, key := c.currentAPIKey()
for {
- result, err := c.searchTavily(ctx, query, num)
+ result, err := c.searchTavily(ctx, query, num, key)
if err == nil {
return result, nil
}
@@ -130,7 +142,8 @@ func (c *TavilySearch) Execute(ctx context.Context, args []string) (string, erro
if !c.rotateKey() {
break
}
- if c.currentKey == startKey {
+ var idx int
+ if idx, key = c.currentAPIKey(); idx == startKey {
break
}
}
@@ -220,46 +233,77 @@ type tavilyResult struct {
Score float64 `json:"score"`
}
-func (c *TavilySearch) searchTavily(ctx context.Context, query string, num int) (string, error) {
- reqBody := tavilyRequest{
- Query: query,
- MaxResults: num,
- SearchDepth: "basic",
- IncludeAnswer: true,
- IncludeRawContent: false,
- }
+// doTavilyRequest issues a single Tavily search and decodes the response. Both
+// searchTavily (with DuckDuckGo fallback) and ProbeTavily (no fallback) share
+// this request/response plumbing; only the client and the result shaping differ.
+func doTavilyRequest(ctx context.Context, client *http.Client, apiKey string, reqBody tavilyRequest) (tavilyResponse, error) {
+ var out tavilyResponse
bodyBytes, err := json.Marshal(reqBody)
if err != nil {
- return "", fmt.Errorf("marshal request: %w", err)
+ return out, fmt.Errorf("marshal request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tavilySearchURL, strings.NewReader(string(bodyBytes)))
if err != nil {
- return "", err
+ return out, err
}
req.Header.Set("Content-Type", "application/json")
- req.Header.Set("Authorization", "Bearer "+c.apiKey)
+ req.Header.Set("Authorization", "Bearer "+apiKey)
- resp, err := c.client.Do(req)
+ resp, err := client.Do(req)
if err != nil {
- return "", fmt.Errorf("tavily request failed: %w", err)
+ return out, fmt.Errorf("tavily request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody))
if err != nil {
- return "", fmt.Errorf("read tavily response: %w", err)
+ return out, fmt.Errorf("read tavily response: %w", err)
}
if resp.StatusCode != http.StatusOK {
- return "", fmt.Errorf("tavily returned HTTP %d: %s", resp.StatusCode, string(body))
+ return out, fmt.Errorf("tavily returned HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
+ }
+ if err := json.Unmarshal(body, &out); err != nil {
+ return out, fmt.Errorf("parse tavily response: %w", err)
+ }
+ return out, nil
+}
+
+func (c *TavilySearch) searchTavily(ctx context.Context, query string, num int, apiKey string) (string, error) {
+ resp, err := doTavilyRequest(ctx, c.client, apiKey, tavilyRequest{
+ Query: query,
+ MaxResults: num,
+ SearchDepth: "basic",
+ IncludeAnswer: true,
+ })
+ if err != nil {
+ return "", err
}
+ return formatTavilyResults(resp, query), nil
+}
- var tavilyResp tavilyResponse
- if err := json.Unmarshal(body, &tavilyResp); err != nil {
- return "", fmt.Errorf("parse tavily response: %w", err)
+// ProbeTavily verifies a single Tavily API key by issuing a minimal search
+// directly against the Tavily API. Unlike Execute, it never falls back to
+// DuckDuckGo, so an invalid or exhausted key surfaces as an error instead of
+// being silently masked by the fallback. A non-empty proxy routes the request.
+// On success it returns a short human-readable detail (e.g. "1 result").
+func ProbeTavily(ctx context.Context, apiKey, proxy string) (string, error) {
+ apiKey = strings.TrimSpace(apiKey)
+ if apiKey == "" {
+ return "", fmt.Errorf("tavily api key is empty")
}
- return formatTavilyResults(tavilyResp, query), nil
+ client := &http.Client{Timeout: searchTimeout, Transport: proxyTransport(proxy)}
+
+ resp, err := doTavilyRequest(ctx, client, apiKey, tavilyRequest{
+ Query: "ping",
+ MaxResults: 1,
+ SearchDepth: "basic",
+ })
+ if err != nil {
+ return "", err
+ }
+ return fmt.Sprintf("%d results", len(resp.Results)), nil
}
func formatTavilyResults(resp tavilyResponse, query string) string {
diff --git a/pkg/tools/spray/spray.go b/pkg/tools/spray/spray.go
index 3daf1d54..c041566f 100644
--- a/pkg/tools/spray/spray.go
+++ b/pkg/tools/spray/spray.go
@@ -64,6 +64,7 @@ func (c *Command) QuickReference() string {
}
func (c *Command) Execute(ctx context.Context, args []string) (err error) {
+ defer telemetry.RecoverAsError("spray", &err)
args = c.resolveRelativePaths(args)
var buf bytes.Buffer
debug := toolargs.BoolFlagEnabled(args, "--debug")
@@ -105,7 +106,7 @@ func (c *Command) Execute(ctx context.Context, args []string) (err error) {
return nil
},
OnResult: func(r *parsers.SprayResult) {
- c.EmitData("spray", output.ToolDataWeb, r.UrlString, r)
+ c.EmitDataCtx(ctx, "spray", output.ToolDataWeb, r.UrlString, r)
},
}
if err := spraycore.RunWithArgs(ctx, withDefaultScannerFlags(args), runOpts); err != nil {
diff --git a/pkg/tools/spray/spray_test.go b/pkg/tools/spray/spray_test.go
index 1a756a56..b1b68978 100644
--- a/pkg/tools/spray/spray_test.go
+++ b/pkg/tools/spray/spray_test.go
@@ -133,7 +133,7 @@ func TestExecuteDebugActivatesTelemetryLogger(t *testing.T) {
if err := cmd.Execute(context.Background(), []string{"--debug", "--help"}); err != nil {
t.Fatalf("Execute() error = %v", err)
}
- if got := logs.String(); !strings.Contains(got, "[debug] spray debug enabled") {
+ if got := logs.String(); !strings.Contains(got, "● spray debug enabled") {
t.Fatalf("debug logs = %q", got)
}
}
diff --git a/pkg/tools/toolargs/base.go b/pkg/tools/toolargs/base.go
index b75fd9a2..a83b0552 100644
--- a/pkg/tools/toolargs/base.go
+++ b/pkg/tools/toolargs/base.go
@@ -1,6 +1,7 @@
package toolargs
import (
+ "context"
"time"
"github.com/chainreactors/aiscan/core/eventbus"
@@ -27,6 +28,10 @@ func (b *Base) InitLogger(logger telemetry.Logger) {
}
func (b *Base) EmitData(tool, kind, target string, data any) {
+ b.EmitDataCtx(context.Background(), tool, kind, target, data)
+}
+
+func (b *Base) EmitDataCtx(ctx context.Context, tool, kind, target string, data any) {
if b.DataBus == nil {
return
}
@@ -35,6 +40,7 @@ func (b *Base) EmitData(tool, kind, target string, data any) {
Kind: kind,
Target: target,
Data: data,
+ CallID: output.CallIDFromContext(ctx),
Timestamp: time.Now(),
})
}
diff --git a/pkg/tools/toolargs/resolve.go b/pkg/tools/toolargs/resolve.go
index 06b3350d..87eadae3 100644
--- a/pkg/tools/toolargs/resolve.go
+++ b/pkg/tools/toolargs/resolve.go
@@ -16,7 +16,7 @@ func ResolveRelativePaths(args []string, fileFlags map[string]bool, workDir stri
arg := args[i]
if key, value, ok := strings.Cut(arg, "="); ok {
if fileFlags[key] {
- out = append(out, key+"="+ResolvePath(value, workDir))
+ out = append(out, key+"="+resolvePath(value, workDir))
continue
}
out = append(out, arg)
@@ -25,7 +25,7 @@ func ResolveRelativePaths(args []string, fileFlags map[string]bool, workDir stri
if fileFlags[arg] && i+1 < len(args) {
out = append(out, arg)
i++
- out = append(out, ResolvePath(args[i], workDir))
+ out = append(out, resolvePath(args[i], workDir))
continue
}
out = append(out, arg)
@@ -33,9 +33,9 @@ func ResolveRelativePaths(args []string, fileFlags map[string]bool, workDir stri
return out
}
-// ResolvePath resolves a single path relative to workDir.
+// resolvePath resolves a single path relative to workDir.
// Returns value unchanged if empty, absolute, or starts with "-".
-func ResolvePath(value, workDir string) string {
+func resolvePath(value, workDir string) string {
if value == "" || filepath.IsAbs(value) || strings.HasPrefix(value, "-") {
return value
}
diff --git a/pkg/tools/zombie/zombie.go b/pkg/tools/zombie/zombie.go
index c000a67e..2e7de6ee 100644
--- a/pkg/tools/zombie/zombie.go
+++ b/pkg/tools/zombie/zombie.go
@@ -46,7 +46,8 @@ func (c *Command) Usage() string {
return zombiecore.Help()
}
-func (c *Command) Execute(ctx context.Context, args []string) error {
+func (c *Command) Execute(ctx context.Context, args []string) (err error) {
+ defer telemetry.RecoverAsError("zombie", &err)
args = c.resolveRelativePaths(args)
var buf bytes.Buffer
if toolargs.BoolFlagEnabled(args, "--debug") {
diff --git a/pkg/tools/zombie/zombie_test.go b/pkg/tools/zombie/zombie_test.go
index 0cbb0c2f..6bdc3ae9 100644
--- a/pkg/tools/zombie/zombie_test.go
+++ b/pkg/tools/zombie/zombie_test.go
@@ -20,7 +20,7 @@ func TestExecuteDebugActivatesTelemetryLogger(t *testing.T) {
if err := cmd.Execute(context.Background(), []string{"--debug", "--help"}); err != nil {
t.Fatalf("Execute() error = %v", err)
}
- if got := logs.String(); !strings.Contains(got, "[debug] zombie debug enabled") {
+ if got := logs.String(); !strings.Contains(got, "● zombie debug enabled") {
t.Fatalf("debug logs = %q", got)
}
}
diff --git a/pkg/tui/banner.go b/pkg/tui/banner.go
index 6c327cbf..a6b83955 100644
--- a/pkg/tui/banner.go
+++ b/pkg/tui/banner.go
@@ -8,6 +8,7 @@ import (
cfg "github.com/chainreactors/aiscan/core/config"
outputpkg "github.com/chainreactors/aiscan/core/output"
+ runewidth "github.com/mattn/go-runewidth"
"golang.org/x/term"
)
@@ -71,11 +72,14 @@ func (r *AgentConsole) bannerWidth() int {
maxWidth = 78
)
width := defaultWidth
+ resolved := false
if r != nil && r.terminal != nil && r.terminal.Control != nil {
if columns, _ := r.terminal.Control.Size(); columns > 0 {
width = columns - 4
+ resolved = true
}
- } else if r != nil && r.output != nil && r.output.Stderr() != nil {
+ }
+ if !resolved && r != nil && r.output != nil && r.output.Stderr() != nil {
if columns := writerTerminalWidth(r.output.Stderr()); columns > 0 {
width = columns - 4
}
@@ -105,24 +109,22 @@ func bannerKV(label, value string, colorEnabled bool) string {
return ansiDim(fmt.Sprintf("%-9s", label), colorEnabled) + value
}
+// renderFixedBox draws body inside a fixed-width box of width columns. Lines
+// longer than the inner width are clipped with an ellipsis rather than widening
+// the box, so a long path or URL can never blow the frame past the terminal.
func renderFixedBox(body string, width int, colorEnabled bool) string {
const minInnerWidth = 16
innerWidth := width - 4
if innerWidth < minInnerWidth {
innerWidth = minInnerWidth
}
- lines := strings.Split(body, "\n")
- for _, line := range lines {
- if n := visibleRuneLen(line); n > innerWidth {
- innerWidth = n
- }
- }
border := func(s string) string { return ansiDim(s, colorEnabled) }
var b strings.Builder
fmt.Fprintf(&b, "%s\n", border("╭"+strings.Repeat("─", innerWidth+2)+"╮"))
- for _, line := range lines {
- padding := innerWidth - visibleRuneLen(line)
+ for _, line := range strings.Split(body, "\n") {
+ line = clipVisible(line, innerWidth)
+ padding := innerWidth - visibleWidth(line)
if padding < 0 {
padding = 0
}
@@ -136,8 +138,103 @@ func renderFixedBox(body string, width int, colorEnabled bool) string {
return b.String()
}
-func visibleRuneLen(s string) int {
- return len([]rune(outputpkg.StripANSI(s)))
+// visibleWidth returns the terminal cell width of s, ignoring ANSI escapes and
+// counting East Asian wide runes (CJK, fullwidth) as two columns so borders line
+// up under Chinese text.
+func visibleWidth(s string) int {
+ return runewidth.StringWidth(outputpkg.StripANSI(s))
+}
+
+// clipVisible truncates s to at most maxWidth terminal columns, preserving ANSI
+// escape sequences (zero width) and counting wide runes as two columns. When
+// content is dropped it appends "…" and closes any open color with a reset.
+func clipVisible(s string, maxWidth int) string {
+ if maxWidth <= 0 {
+ return ""
+ }
+ if visibleWidth(s) <= maxWidth {
+ return s
+ }
+ runes := []rune(s)
+ var b strings.Builder
+ width, limit := 0, maxWidth-1 // reserve one column for the ellipsis
+ sawEscape := false
+ for i := 0; i < len(runes); {
+ if runes[i] == 0x1b { // copy the escape sequence verbatim (zero width)
+ j := i + 1
+ if j < len(runes) && runes[j] == '[' {
+ for j++; j < len(runes) && (runes[j] < 0x40 || runes[j] > 0x7e); j++ {
+ }
+ if j < len(runes) {
+ j++ // include the final byte
+ }
+ }
+ b.WriteString(string(runes[i:j]))
+ sawEscape = true
+ i = j
+ continue
+ }
+ rw := runewidth.RuneWidth(runes[i])
+ if width+rw > limit {
+ break
+ }
+ b.WriteRune(runes[i])
+ width += rw
+ i++
+ }
+ b.WriteString("…")
+ if sawEscape {
+ b.WriteString(outputpkg.ANSIReset)
+ }
+ return b.String()
+}
+
+// truncMiddle shortens s to about max columns by dropping the middle, keeping the
+// head and the (usually more informative) tail — used for long filesystem paths.
+func truncMiddle(s string, max int) string {
+ if visibleWidth(s) <= max || max < 5 {
+ return s
+ }
+ ellipsisWidth := visibleWidth("…")
+ keep := max - ellipsisWidth
+ headBudget := keep / 2
+ tailBudget := keep - headBudget
+ return visiblePrefix(s, headBudget) + "…" + visibleSuffix(s, tailBudget)
+}
+
+func visiblePrefix(s string, maxWidth int) string {
+ if maxWidth <= 0 {
+ return ""
+ }
+ var b strings.Builder
+ width := 0
+ for _, r := range s {
+ rw := runewidth.RuneWidth(r)
+ if width+rw > maxWidth {
+ break
+ }
+ b.WriteRune(r)
+ width += rw
+ }
+ return b.String()
+}
+
+func visibleSuffix(s string, maxWidth int) string {
+ if maxWidth <= 0 {
+ return ""
+ }
+ runes := []rune(s)
+ width := 0
+ start := len(runes)
+ for start > 0 {
+ rw := runewidth.RuneWidth(runes[start-1])
+ if width+rw > maxWidth {
+ break
+ }
+ start--
+ width += rw
+ }
+ return string(runes[start:])
}
func ansiWrap(s, code string, enabled bool) string {
@@ -199,7 +296,7 @@ func (r *AgentConsole) sessionSummary() string {
}
func (r *AgentConsole) providerModel() (string, string) {
- if r.appInfo.Commands == nil {
+ if r == nil {
return "", ""
}
pc := r.appInfo.ProviderConfig
@@ -225,12 +322,13 @@ func (r *AgentConsole) renderHelp() string {
func (r *AgentConsole) renderStatus() string {
colorEnabled := r.output != nil && r.output.color.Enabled
info := CollectStatus(r.replSession(), r.sessionSummary(), agentConsoleHistoryPath())
+ detailBudget := r.bannerWidth() - 4 - helpRowCommandWidth
rows := []helpRow{
{Command: "model", Detail: info.Provider + " / " + info.Model},
{Command: "render", Detail: info.Mode},
{Command: "task", Detail: info.Task},
- {Command: "ioa", Detail: info.IOA},
- {Command: "history", Detail: info.History},
+ {Command: "server", Detail: info.IOA},
+ {Command: "history", Detail: truncMiddle(info.History, detailBudget)},
}
if info.Skills != "" {
rows = append(rows, helpRow{Command: "skills", Detail: info.Skills})
@@ -252,13 +350,69 @@ func renderHelpRows(rows []helpRow, colorEnabled bool) string {
b.WriteByte('\n')
continue
}
- command := ansiAccent(fmt.Sprintf("%-*s", helpRowCommandWidth, row.Command), colorEnabled)
+ pad := helpRowCommandWidth - visibleWidth(row.Command)
+ if pad < 1 {
+ pad = 1
+ }
+ command := ansiAccent(row.Command+strings.Repeat(" ", pad), colorEnabled)
detail := ansiDim(row.Detail, colorEnabled)
fmt.Fprintf(&b, "%s%s\n", command, detail)
}
return strings.TrimRight(b.String(), "\n")
}
+// renderBoxTable lays rows out as display-width-aligned columns for use as the
+// body of renderPanel — so list commands (/spaces, /nodes, /messages) match the
+// boxed panels instead of dumping bare tabwriter tables. Column 1 (the name) is
+// accented, the rest dimmed; the final column is left unpadded and renderFixedBox
+// clips any line that would exceed the frame.
+func renderBoxTable(rows [][]string, colorEnabled bool) string {
+ const maxColumnWidth = 24
+ cols := 0
+ for _, row := range rows {
+ if len(row) > cols {
+ cols = len(row)
+ }
+ }
+ widths := make([]int, cols)
+ for _, row := range rows {
+ for i, cell := range row {
+ w := visibleWidth(cell)
+ if i < cols-1 && w > maxColumnWidth {
+ w = maxColumnWidth
+ }
+ if w > widths[i] {
+ widths[i] = w
+ }
+ }
+ }
+ var b strings.Builder
+ for ri, row := range rows {
+ if ri > 0 {
+ b.WriteByte('\n')
+ }
+ for i := 0; i < cols; i++ {
+ cell := ""
+ if i < len(row) {
+ cell = row[i]
+ }
+ if i < cols-1 {
+ cell = clipVisible(cell, widths[i])
+ }
+ styled := ansiDim(cell, colorEnabled)
+ if i == 1 {
+ styled = ansiAccent(cell, colorEnabled)
+ }
+ if i == cols-1 {
+ b.WriteString(styled)
+ } else {
+ b.WriteString(styled + strings.Repeat(" ", widths[i]-visibleWidth(cell)+2))
+ }
+ }
+ }
+ return b.String()
+}
+
func (r *AgentConsole) renderPanel(title, body string, colorEnabled bool) string {
title = strings.TrimSpace(title)
if title == "" {
diff --git a/pkg/tui/banner_render_test.go b/pkg/tui/banner_render_test.go
new file mode 100644
index 00000000..11ed20f5
--- /dev/null
+++ b/pkg/tui/banner_render_test.go
@@ -0,0 +1,192 @@
+package tui
+
+import (
+ "strings"
+ "testing"
+
+ outputpkg "github.com/chainreactors/aiscan/core/output"
+ "github.com/chainreactors/aiscan/pkg/agent"
+)
+
+// assertUniformWidth checks every line of a rendered box has the same visible
+// width — the box is a rectangle, nothing overflows the frame.
+func assertUniformWidth(t *testing.T, box string) {
+ t.Helper()
+ lines := strings.Split(box, "\n")
+ want := visibleWidth(lines[0])
+ for i, ln := range lines {
+ if got := visibleWidth(ln); got != want {
+ t.Errorf("line %d width = %d, want %d: %q", i, got, want, outputpkg.StripANSI(ln))
+ }
+ }
+}
+
+// A long URL/path used to widen the whole box past the terminal. It must now be
+// clipped so the frame stays exactly `width` columns.
+func TestRenderFixedBoxNeverOverflows(t *testing.T) {
+ const width = 60
+ longURL := "http://" + strings.Repeat("a", 120) + "@127.0.0.1:3000/ioa"
+ body := "status\nmodel anthropic / glm-5.2\nioa " + longURL
+ box := renderFixedBox(body, width, false)
+ for _, ln := range strings.Split(box, "\n") {
+ if w := visibleWidth(ln); w != width {
+ t.Fatalf("line width %d != box width %d: %q", w, width, ln)
+ }
+ }
+}
+
+// CJK runes are double-width; before the fix the right border drifted right under
+// Chinese text because padding counted runes, not cells.
+func TestRenderFixedBoxAlignsCJK(t *testing.T) {
+ body := "状态\n模型 anthropic / glm-5.2\n技能 /aiscan /passive 中文说明文字很长很长很长"
+ assertUniformWidth(t, renderFixedBox(body, 44, false))
+}
+
+func TestVisibleWidthCJK(t *testing.T) {
+ if w := visibleWidth("中文"); w != 4 {
+ t.Errorf("visibleWidth(中文) = %d, want 4", w)
+ }
+ if w := visibleWidth("\x1b[2mabc\x1b[0m"); w != 3 {
+ t.Errorf("visibleWidth(colored abc) = %d, want 3", w)
+ }
+}
+
+func TestClipVisiblePreservesANSIAndWidth(t *testing.T) {
+ line := "\x1b[2mhttp://SECRETTOKEN@example.invalid/really/long/path/that/overflows\x1b[0m"
+ out := clipVisible(line, 20)
+ if w := visibleWidth(out); w > 20 {
+ t.Errorf("clipVisible width = %d, want <= 20", w)
+ }
+ if !strings.HasPrefix(out, "\x1b[2m") {
+ t.Errorf("clipVisible dropped opening color: %q", out)
+ }
+ if !strings.HasSuffix(out, outputpkg.ANSIReset) {
+ t.Errorf("clipVisible left color open: %q", out)
+ }
+ if got := clipVisible("abc", 20); got != "abc" {
+ t.Errorf("clipVisible(short) = %q, want abc", got)
+ }
+}
+
+func TestRedactIOAURL(t *testing.T) {
+ raw := "http://be7c1b68264bae5a37570e2785fea0725dd26760f49037d7081939178e81098f@127.0.0.1:3000/ioa"
+ got := redactIOAURL(raw)
+ if strings.Contains(got, "be7c1b68") {
+ t.Errorf("redactIOAURL leaked token: %q", got)
+ }
+ if got != "http://127.0.0.1:3000/ioa" {
+ t.Errorf("redactIOAURL = %q, want http://127.0.0.1:3000/ioa", got)
+ }
+ if got := redactIOAURL("http://127.0.0.1:3000/ioa"); got != "http://127.0.0.1:3000/ioa" {
+ t.Errorf("redactIOAURL(no token) = %q", got)
+ }
+}
+
+func TestRedactIOAURLFallbackOnMalformedURL(t *testing.T) {
+ raw := "http://super-secret-token@127.0.0.1:3000/ioa/%zz"
+ got := redactIOAURL(raw)
+ if strings.Contains(got, "super-secret-token") {
+ t.Fatalf("redactIOAURL malformed leaked token: %q", got)
+ }
+ if !strings.Contains(got, "127.0.0.1:3000") {
+ t.Fatalf("redactIOAURL malformed dropped host: %q", got)
+ }
+}
+
+func TestTruncMiddleKeepsTail(t *testing.T) {
+ p := "/var/lib/cloud-cli-proxy/hosts/57dfa9df-9093-4bcb/home/aiscan/dist/.aiscan/agent_history"
+ got := truncMiddle(p, 40)
+ if n := visibleWidth(got); n > 40 {
+ t.Errorf("truncMiddle width = %d, want <= 40", n)
+ }
+ if !strings.HasSuffix(got, "agent_history") {
+ t.Errorf("truncMiddle dropped tail: %q", got)
+ }
+ if !strings.HasPrefix(got, "/var/lib") {
+ t.Errorf("truncMiddle dropped head: %q", got)
+ }
+ if !strings.Contains(got, "…") {
+ t.Errorf("truncMiddle missing ellipsis: %q", got)
+ }
+}
+
+func TestTruncMiddleUsesVisibleWidth(t *testing.T) {
+ p := "/tmp/中文中文中文中文中文/agent_history"
+ got := truncMiddle(p, 18)
+ if n := visibleWidth(got); n > 18 {
+ t.Fatalf("truncMiddle CJK width = %d, want <= 18: %q", n, got)
+ }
+ if !strings.HasSuffix(got, "history") {
+ t.Fatalf("truncMiddle CJK dropped useful tail: %q", got)
+ }
+}
+
+// The IOA list commands now render as boxed panels; columns must stay aligned
+// (including under CJK names) and nothing overflows the frame.
+func TestRenderBoxTableAligns(t *testing.T) {
+ rows := [][]string{
+ {"e8fa5859", "default", "1 node", "0 msgs"},
+ {"de12abca", "作战一号-很长的中文名字", "3 nodes", "12 msgs"},
+ {"b3a3e964", "local-1", "0 nodes", "0 msgs"},
+ }
+ box := renderFixedBox("spaces\n"+renderBoxTable(rows, false), 48, false)
+ assertUniformWidth(t, box)
+ t.Log("\n" + box)
+}
+
+func TestRenderBoxTableNodesSample(t *testing.T) {
+ rows := [][]string{
+ {"de12abca", "aiscan-tui"},
+ {"b3a3e964", "local-1"},
+ }
+ box := renderFixedBox("nodes\n"+renderBoxTable(rows, false), 44, false)
+ assertUniformWidth(t, box)
+ t.Log("\n" + box)
+}
+
+func TestRenderBoxTableClipsWideIntermediateColumns(t *testing.T) {
+ rows := [][]string{
+ {"de12abca", strings.Repeat("非常长", 20), "3 nodes", "12 msgs"},
+ }
+ table := renderBoxTable(rows, false)
+ if !strings.Contains(table, "12 msgs") {
+ t.Fatalf("wide middle column hid the final column: %q", table)
+ }
+ if visibleWidth(strings.Split(table, "\n")[0]) > 80 {
+ t.Fatalf("wide middle column was not clipped: width=%d table=%q", visibleWidth(table), table)
+ }
+}
+
+func TestProviderModelDoesNotDependOnCommands(t *testing.T) {
+ r := &AgentConsole{}
+ r.appInfo.ProviderConfig = agent.ProviderConfig{Provider: "anthropic", Model: "claude-test"}
+ provider, model := r.providerModel()
+ if provider != "anthropic" || model != "claude-test" {
+ t.Fatalf("providerModel = %q/%q, want anthropic/claude-test", provider, model)
+ }
+}
+
+func TestShortID(t *testing.T) {
+ if got := shortID("de12abca01d7a92f1630e21f642a37e0"); got != "de12abca" {
+ t.Errorf("shortID = %q, want de12abca", got)
+ }
+ if got := shortID("abc"); got != "abc" {
+ t.Errorf("shortID(short) = %q, want abc", got)
+ }
+}
+
+// TestStatusSampleRender prints a realistic /status box (color off) so the fix
+// is visible in `go test -v` output.
+func TestStatusSampleRender(t *testing.T) {
+ rows := []helpRow{
+ {Command: "model", Detail: "anthropic / glm-5.2"},
+ {Command: "render", Detail: "static · plain · space default"},
+ {Command: "task", Detail: "idle"},
+ {Command: "ioa", Detail: redactIOAURL("http://be7c1b68264bae5a37570e2785fea0725dd26760f49037d7081939178e81098f@127.0.0.1:3000/ioa") + " · space default"},
+ {Command: "history", Detail: truncMiddle("/var/lib/cloud-cli-proxy/hosts/57dfa9df-9093-4bcb-80e8-bafaf96927ee/home/aiscan/dist/.aiscan/agent_history", 64-4-helpRowCommandWidth)},
+ {Command: "skills", Detail: "/aiscan /passive"},
+ }
+ box := renderFixedBox("status\n"+renderHelpRows(rows, false), 64, false)
+ t.Log("\n" + box)
+ assertUniformWidth(t, box)
+}
diff --git a/pkg/tui/commands.go b/pkg/tui/commands.go
index 6bc74a58..e78e2528 100644
--- a/pkg/tui/commands.go
+++ b/pkg/tui/commands.go
@@ -3,11 +3,14 @@ package tui
import (
"context"
"fmt"
+ "net/url"
"strings"
cfg "github.com/chainreactors/aiscan/core/config"
"github.com/chainreactors/aiscan/pkg/agent"
"github.com/chainreactors/aiscan/pkg/commands"
+ "github.com/chainreactors/aiscan/pkg/telemetry"
+ "github.com/chainreactors/aiscan/pkg/webproto"
"github.com/chainreactors/aiscan/skills"
)
@@ -19,6 +22,7 @@ type AppInfo struct {
Commands *commands.CommandRegistry
Skills *skills.Store
OnProviderChange func(agent.Provider, agent.ProviderConfig)
+ OnLoggerChange func(telemetry.Logger)
}
// Session holds the dependencies commands need to operate on.
@@ -151,7 +155,7 @@ func CollectStatus(s *Session, mode, historyPath string) StatusInfo {
}
info.IOA = "disabled"
if s.Option != nil && strings.TrimSpace(s.Option.IOAURL) != "" {
- info.IOA = strings.TrimSpace(s.Option.IOAURL)
+ info.IOA = redactIOAURL(strings.TrimSpace(s.Option.IOAURL))
if s.Option.Space != "" {
info.IOA += " · space " + s.Option.Space
}
@@ -173,3 +177,56 @@ func CollectStatus(s *Session, mode, historyPath string) StatusInfo {
}
return info
}
+
+// redactIOAURL strips the access token that the IOA URL carries as userinfo
+// (http://@host/ioa) so /status never prints the secret to the terminal
+// or into a shared screenshot. If URL parsing fails it still conservatively
+// strips userinfo from a scheme://userinfo@host authority; token-less URLs are
+// returned unchanged.
+func redactIOAURL(raw string) string {
+ u, err := url.Parse(raw)
+ if err != nil {
+ return redactURLUserinfoFallback(raw)
+ }
+ if u.User == nil {
+ return redactURLUserinfoFallback(raw)
+ }
+ u.User = nil
+ return u.String()
+}
+
+func redactURLUserinfoFallback(raw string) string {
+ scheme := strings.Index(raw, "://")
+ if scheme < 0 {
+ return raw
+ }
+ authorityStart := scheme + len("://")
+ authorityEnd := len(raw)
+ if rel := strings.IndexAny(raw[authorityStart:], "/?#"); rel >= 0 {
+ authorityEnd = authorityStart + rel
+ }
+ at := strings.LastIndex(raw[authorityStart:authorityEnd], "@")
+ if at < 0 {
+ return raw
+ }
+ return raw[:authorityStart] + raw[authorityStart+at+1:]
+}
+
+// WebMenuSpecs extracts the web-visible command metadata from a Command list.
+// Run-control commands (/stop, /followup, /eval, /loop, /exit) are excluded
+// because the web expresses those through UI controls, not slash text.
+func WebMenuSpecs(cmds []Command) []webproto.CommandSpec {
+ hidden := map[string]bool{"/stop": true, "/continue": true, "/followup": true, "/eval": true, "/loop": true, "/exit": true}
+ var specs []webproto.CommandSpec
+ for _, c := range cmds {
+ if c.Hidden || hidden[c.Name] {
+ continue
+ }
+ specs = append(specs, webproto.CommandSpec{
+ Name: c.Name,
+ Aliases: c.Aliases,
+ Description: c.Description,
+ })
+ }
+ return specs
+}
diff --git a/pkg/tui/completion.go b/pkg/tui/completion.go
new file mode 100644
index 00000000..58b9b38f
--- /dev/null
+++ b/pkg/tui/completion.go
@@ -0,0 +1,92 @@
+package tui
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "unicode"
+
+ "github.com/carapace-sh/carapace"
+ "github.com/chainreactors/tui/readline"
+)
+
+// wrapCompleterForFuzzyAt wraps the shell's Completer so that when the word
+// under the cursor starts with '@', the PREFIX is shortened to just '@'. This
+// prevents readline's FilterPrefix from discarding fuzzy (non-prefix) matches
+// produced by atFuzzyFileAction.
+func wrapCompleterForFuzzyAt(shell *readline.Shell) {
+ if shell == nil || shell.Completer == nil {
+ return
+ }
+ inner := shell.Completer
+ shell.Completer = func(line []rune, cursor int) readline.Completions {
+ comps := inner(line, cursor)
+ if wordAtCursorIsAtRef(line, cursor) {
+ comps.PREFIX = "@"
+ }
+ return comps
+ }
+}
+
+func wordAtCursorIsAtRef(line []rune, cursor int) bool {
+ if cursor > len(line) {
+ cursor = len(line)
+ }
+ start := cursor
+ for start > 0 && !unicode.IsSpace(line[start-1]) {
+ start--
+ }
+ return start < cursor && line[start] == '@'
+}
+
+func atFuzzyFileAction(raw string) carapace.Action {
+ return carapace.ActionCallback(func(_ carapace.Context) carapace.Action {
+ dirPart, query, sep := splitCompletionPath(raw)
+ dir := dirPart
+ if dir == "" {
+ dir = "."
+ }
+ entries, err := os.ReadDir(filepath.FromSlash(dir))
+ if err != nil {
+ return carapace.ActionValues()
+ }
+ var values []string
+ for _, entry := range entries {
+ name := entry.Name()
+ if query == "" || fuzzySubsequence(query, name) {
+ if entry.IsDir() {
+ name += sep
+ }
+ values = append(values, "@"+dirPart+name)
+ }
+ }
+ return carapace.ActionValues(values...).NoSpace()
+ })
+}
+
+func splitCompletionPath(raw string) (dir, query, sep string) {
+ sep = string(os.PathSeparator)
+ if strings.Contains(raw, "/") {
+ sep = "/"
+ }
+ if strings.Contains(raw, "\\") {
+ sep = "\\"
+ }
+ idx := strings.LastIndexAny(raw, `/\`)
+ if idx < 0 {
+ return "", raw, sep
+ }
+ return raw[:idx+1], raw[idx+1:], sep
+}
+
+func fuzzySubsequence(query, value string) bool {
+ query = strings.ToLower(query)
+ value = strings.ToLower(value)
+ j := 0
+ for _, r := range value {
+ if j < len(query) && rune(query[j]) == r {
+ j++
+ }
+ }
+ return j == len(query)
+}
diff --git a/pkg/tui/console.go b/pkg/tui/console.go
index bc36bc4f..c0b3edc0 100644
--- a/pkg/tui/console.go
+++ b/pkg/tui/console.go
@@ -9,6 +9,7 @@ import (
"io"
"os"
"path/filepath"
+ "strconv"
"strings"
"sync"
"sync/atomic"
@@ -19,6 +20,8 @@ import (
"github.com/chainreactors/aiscan/core/eventbus"
outputpkg "github.com/chainreactors/aiscan/core/output"
"github.com/chainreactors/aiscan/pkg/agent"
+ "github.com/chainreactors/aiscan/pkg/agent/probe"
+ "github.com/chainreactors/aiscan/pkg/telemetry"
ioaclient "github.com/chainreactors/ioa/client"
"github.com/chainreactors/tui/console"
rlterm "github.com/chainreactors/tui/readline/terminal"
@@ -26,6 +29,7 @@ import (
)
const agentPromptCommandName = "__prompt"
+const agentConsoleCompleteCommandName = "aiscan-complete"
const agentConsoleInterruptCommandName = "aiscan-interrupt"
const agentConsoleCtrlCCommandName = "aiscan-ctrl-c"
const agentConsoleToggleVerbosityCommandName = "aiscan-toggle-verbosity"
@@ -54,10 +58,14 @@ type AgentConsole struct {
// an IOA-unavailable degradation warning). Set by the caller before Start.
startupNotice string
evalCriteria string
+ sessionDir string
directMu sync.Mutex
directCancel context.CancelFunc
pendingExit atomic.Bool
+ onExit func()
+
+ split *SplitTerminal
}
func NewAgentConsole(ctx context.Context, option *cfg.Option, appInfo AppInfo, session *agent.Agent, output *AgentOutput, bus ...*eventbus.Bus[agent.Event]) *AgentConsole {
@@ -68,24 +76,56 @@ func NewAgentConsoleWithTerminal(ctx context.Context, option *cfg.Option, appInf
if t == nil {
t = rlterm.Local()
}
- c := console.NewWithTerminal("aiscan", t)
- c.NewlineAfter = true
+
+ // Determine whether to activate the split-pane layout. When enabled,
+ // readline uses an input-area writer (serialised via the same mutex)
+ // while all agent output routes to the upper scroll region.
+ var split *SplitTerminal
+ isTerminal := t.Control != nil && t.Control.IsTerminal()
+ useSplit := isTerminal && splitEnabled(int(os.Stdout.Fd()), resolveRenderMode())
+
+ var consoleTerminal *rlterm.Terminal
+ if useSplit {
+ split = NewSplitTerminal(t.Out, int(os.Stdout.Fd()))
+ // Readline gets a terminal whose Out/Err go through the split
+ // input writer so that prompt rendering is serialised with output.
+ consoleTerminal = rlterm.Stream(t.In, split.InputWriter(), split.InputWriter(), t.Control)
+ } else {
+ consoleTerminal = t
+ }
+
+ c := console.NewWithTerminal("aiscan", consoleTerminal)
+ if useSplit {
+ c.NewlineAfter = false
+ } else {
+ c.NewlineAfter = true
+ }
configureAgentReadline(c)
c.EnablePasteReferences(console.PasteReferenceConfig{Enabled: true})
+
stdout := t.Out
stderr := t.Err
if output == nil {
if t.Control == nil {
output = NewAgentOutput(option)
} else {
- output = NewAgentOutputWithWriters(option, stdout, stderr, t.Control.IsTerminal())
+ output = NewAgentOutputWithWriters(option, stdout, stderr, isTerminal)
}
}
- if stdout == nil {
- stdout = output.Stdout()
- }
- if stderr == nil {
- stderr = output.Stderr()
+
+ // In split mode, redirect AgentOutput into the scroll region and
+ // point console stdout/stderr there too.
+ if split != nil {
+ output.SetSplitMode(split)
+ stdout = split.OutputWriter()
+ stderr = split.OutputWriter()
+ } else {
+ if stdout == nil {
+ stdout = output.Stdout()
+ }
+ if stderr == nil {
+ stderr = output.Stderr()
+ }
}
menu := c.NewMenu("agent")
@@ -109,11 +149,9 @@ func NewAgentConsoleWithTerminal(ctx context.Context, option *cfg.Option, appInf
output: output,
stdout: stdout,
stderr: stderr,
+ split: split,
}
menu.Prompt().Primary = func() string {
- if repl.pendingExit.Load() {
- return ""
- }
return agentPromptString(output)
}
if len(bus) > 0 && bus[0] != nil {
@@ -124,6 +162,7 @@ func NewAgentConsoleWithTerminal(ctx context.Context, option *cfg.Option, appInf
}
repl.controller = newInteractiveRunController(ctx, repl.agent, output)
repl.controller.SetOnFinish(repl.refreshPromptAfterAsyncRun)
+ repl.configureCompletionKey()
repl.configureInterruptKey()
repl.configureCtrlCKey()
repl.configureVerbosityToggleKey()
@@ -160,6 +199,11 @@ func (r *AgentConsole) ExecuteLineAndWait(line string) (bool, error) {
}
func (r *AgentConsole) Start() error {
+ if r.split != nil {
+ r.split.Setup()
+ r.activateSplitLogger()
+ defer r.split.Teardown()
+ }
r.renderBanner()
defer r.stopController()
if r.fastInputEnabled() {
@@ -168,6 +212,27 @@ func (r *AgentConsole) Start() error {
return r.startReadline()
}
+func (r *AgentConsole) activateSplitLogger() {
+ if r == nil || r.split == nil {
+ return
+ }
+ splitLogger := telemetry.GlobalLogger(telemetry.LogConfig{
+ Debug: r.option != nil && r.option.Debug,
+ Quiet: r.option != nil && r.option.Quiet,
+ Output: r.stderr,
+ Color: r.option == nil || !r.option.NoColor,
+ })
+ if r.appInfo.OnLoggerChange != nil {
+ r.appInfo.OnLoggerChange(splitLogger)
+ }
+ if r.agent != nil {
+ r.agent.SetLogger(splitLogger)
+ }
+ if r.appInfo.Commands != nil {
+ r.appInfo.Commands.SetLogger(splitLogger)
+ }
+}
+
func (r *AgentConsole) startFastInput() error {
reader := bufio.NewReader(r.terminal.In)
for {
@@ -175,8 +240,12 @@ func (r *AgentConsole) startFastInput() error {
return nil //nolint:nilerr // context cancellation is clean shutdown
}
+ r.promptCompactIfNeeded()
+
fmt.Fprint(r.stderr, r.promptString())
+ r.setReadlineActive(true)
line, err := readFastInputLine(r.ctx, reader)
+ r.setReadlineActive(false)
if err != nil && !errors.Is(err, io.EOF) {
if errors.Is(err, context.Canceled) {
fmt.Fprintln(r.stdout)
@@ -256,9 +325,15 @@ func (r *AgentConsole) startReadline() error {
return nil //nolint:nilerr // context cancellation is clean shutdown
}
- r.readlineActive.Store(true)
+ r.promptCompactIfNeeded()
+
+ if r.split != nil {
+ r.split.PrepareInputArea()
+ }
+
+ r.setReadlineActive(true)
line, err := r.console.Readline()
- r.readlineActive.Store(false)
+ r.setReadlineActive(false)
if err != nil {
switch {
case errors.Is(err, io.EOF):
@@ -288,6 +363,16 @@ func (r *AgentConsole) startReadline() error {
}
}
+func (r *AgentConsole) setReadlineActive(active bool) {
+ if r == nil {
+ return
+ }
+ r.readlineActive.Store(active)
+ if r.output != nil {
+ r.output.SetInteractiveInputActive(active && (r.controller == nil || !r.controller.Running()))
+ }
+}
+
func (r *AgentConsole) resolvePastedText(input string) (string, string) {
if r == nil || r.console == nil || input == "" {
return input, input
@@ -428,6 +513,17 @@ func (r *AgentConsole) allCommands() []Command {
return cmds
}
+// StaticCommands returns the non-skill REPL commands (builtin + provider + IOA).
+// Safe to call on a zero-value receiver — the returned Command.Run closures are
+// unusable, but the metadata (Name, Aliases, Description, Hidden) is correct.
+func (r *AgentConsole) StaticCommands() []Command {
+ var cmds []Command
+ cmds = append(cmds, r.builtinCommands()...)
+ cmds = append(cmds, r.providerCommands()...)
+ cmds = append(cmds, r.ioaCommands()...)
+ return cmds
+}
+
func (r *AgentConsole) builtinCommands() []Command {
return []Command{
{
@@ -439,7 +535,7 @@ func (r *AgentConsole) builtinCommands() []Command {
},
},
{
- Name: "/status", Description: "查看模型、渲染模式、IOA 和 skills",
+ Name: "/status", Description: "查看模型、渲染模式、Server 和 skills",
Args: ArgsNone,
Run: func(_ context.Context, _ *Session, _ []string) error {
fmt.Fprint(r.stdout, r.renderStatus())
@@ -458,6 +554,33 @@ func (r *AgentConsole) builtinCommands() []Command {
return nil
},
},
+ {
+ Name: "/resume", Description: "恢复已保存会话 (/resume 选择,/resume )",
+ Args: ArgsOptional,
+ Run: func(_ context.Context, _ *Session, args []string) error {
+ raw := strings.TrimSpace(strings.Join(args, " "))
+ if raw == "" {
+ if r.interactivePickerEnabled() {
+ return r.resumeSessionInteractive()
+ }
+ sessions, err := r.renderSessions()
+ if err != nil {
+ return err
+ }
+ fmt.Fprint(r.stdout, sessions)
+ return nil
+ }
+ if raw == "list" {
+ sessions, err := r.renderSessions()
+ if err != nil {
+ return err
+ }
+ fmt.Fprint(r.stdout, sessions)
+ return nil
+ }
+ return r.resumeSession(raw)
+ },
+ },
{
Name: "/stop", Description: "停止当前正在运行的任务",
Args: ArgsNone,
@@ -468,6 +591,16 @@ func (r *AgentConsole) builtinCommands() []Command {
return nil
},
},
+ {
+ Name: "/continue", Description: "继续当前会话",
+ Args: ArgsNone,
+ Run: func(_ context.Context, s *Session, _ []string) error {
+ if s.Controller == nil {
+ return fmt.Errorf("agent controller is not configured")
+ }
+ return s.Controller.Continue()
+ },
+ },
{
Name: "/followup", Description: "排队到当前任务结束后再发送",
Args: ArgsExact1,
@@ -503,6 +636,29 @@ func (r *AgentConsole) builtinCommands() []Command {
return nil
},
},
+ {
+ Name: "/compact", Description: "压缩当前会话上下文 (/compact [focus instructions])",
+ Args: ArgsOptional,
+ Run: func(ctx context.Context, s *Session, args []string) error {
+ if s.Controller != nil && s.Controller.Running() {
+ return fmt.Errorf("task is running — use /stop first")
+ }
+ if len(s.Agent.MessagesSnapshot()) < 4 {
+ fmt.Fprintln(r.stdout, "Nothing to compact (too few messages).")
+ return nil
+ }
+ instructions := strings.TrimSpace(strings.Join(args, " "))
+ result, err := s.Agent.Compact(ctx, agent.CompactConfig{
+ CustomInstructions: instructions,
+ })
+ if err != nil {
+ return err
+ }
+ fmt.Fprintf(r.stdout, "Compacted: ~%d → ~%d tokens (%d messages kept)\n",
+ result.TokensBefore, result.TokensAfter, result.KeptMessages)
+ return nil
+ },
+ },
{
Name: "/loop", Description: "定时循环任务 (/loop 30s | /loop list | /loop stop )",
Args: ArgsOptional,
@@ -548,20 +704,55 @@ func (r *AgentConsole) providerCommands() []Command {
return nil
},
},
+ {
+ Name: "/model",
+ Description: "查看/切换当前 provider 的模型",
+ Args: ArgsOptional,
+ Run: func(ctx context.Context, _ *Session, args []string) error {
+ fields := splitArgs(args)
+ if len(fields) == 0 {
+ if r.interactivePickerEnabled() {
+ return r.configureModelInteractive(ctx)
+ }
+ models, err := r.renderModels(ctx)
+ if err != nil {
+ return err
+ }
+ fmt.Fprint(r.stdout, models)
+ return nil
+ }
+ if len(fields) == 1 && fields[0] == "list" {
+ models, err := r.renderModels(ctx)
+ if err != nil {
+ return err
+ }
+ fmt.Fprint(r.stdout, models)
+ return nil
+ }
+ switch fields[0] {
+ case "set", "use":
+ fields = fields[1:]
+ }
+ if len(fields) != 1 {
+ return fmt.Errorf("usage: /model [list||#index]")
+ }
+ return r.configureModel(ctx, fields[0])
+ },
+ },
}
}
func (r *AgentConsole) ioaCommands() []Command {
return []Command{
{
- Name: "/spaces", Description: "List all IOA spaces",
+ Name: "/spaces", Description: "List all spaces",
Args: ArgsNone,
Run: func(ctx context.Context, _ *Session, _ []string) error {
client, err := r.ioaClient()
if err != nil {
return err
}
- return RunIOASpaces(ctx, client, r.option, r.stdout, r.stderr)
+ return r.renderIOASpaces(ctx, client)
},
},
{
@@ -572,7 +763,7 @@ func (r *AgentConsole) ioaCommands() []Command {
if err != nil {
return err
}
- return RunIOAMessages(ctx, client, r.option, cfg.IOAClientArgs{Space: args[0]}, r.stdout, r.stderr)
+ return r.renderIOAMessages(ctx, client, args[0])
},
},
{
@@ -598,11 +789,11 @@ func (r *AgentConsole) ioaCommands() []Command {
if err != nil {
return err
}
- var a cfg.IOAClientArgs
+ space := ""
if len(args) > 0 {
- a.Space = args[0]
+ space = args[0]
}
- return RunIOANodes(ctx, client, r.option, a, r.stdout, r.stderr)
+ return r.renderIOANodes(ctx, client, space)
},
},
}
@@ -699,6 +890,39 @@ func (r *AgentConsole) refreshPromptAfterAsyncRun() {
r.console.Shell().Refresh()
}
+func (r *AgentConsole) promptCompactIfNeeded() {
+ c := r.controller
+ if c == nil {
+ return
+ }
+ c.mu.Lock()
+ ctxTokens, ctxWindow := c.compactContextTokens, c.compactContextWindow
+ c.compactContextTokens, c.compactContextWindow = 0, 0
+ c.mu.Unlock()
+ if ctxTokens == 0 {
+ return
+ }
+
+ fmt.Fprintf(r.stderr,
+ "\n⚠ Context usage: %d%% (%dK/%dK tokens). Compact now? [y/N] ",
+ ctxTokens*100/ctxWindow, ctxTokens/1000, ctxWindow/1000)
+
+ answer := ""
+ if r.terminal != nil && r.terminal.In != nil {
+ line, _ := bufio.NewReader(r.terminal.In).ReadString('\n')
+ answer = strings.TrimSpace(strings.ToLower(line))
+ }
+ if answer == "y" || answer == "yes" {
+ result, err := r.agent.Compact(r.ctx, agent.CompactConfig{})
+ if err != nil {
+ fmt.Fprintf(r.stderr, "Compact failed: %s\n", err)
+ } else {
+ fmt.Fprintf(r.stderr, "Compacted: ~%d → ~%d tokens (%d messages kept)\n",
+ result.TokensBefore, result.TokensAfter, result.KeptMessages)
+ }
+ }
+}
+
func (r *AgentConsole) setDirectCancel(fn context.CancelFunc) {
r.directMu.Lock()
r.directCancel = fn
@@ -727,10 +951,22 @@ func (r *AgentConsole) stopController() {
}
}
+func (r *AgentConsole) SetOnExit(fn func()) {
+ r.onExit = fn
+}
+
+func (r *AgentConsole) forceExit() {
+ r.stopController()
+ if r.onExit != nil {
+ r.onExit()
+ }
+ os.Exit(0)
+}
+
func (r *AgentConsole) ioaClient() (*ioaclient.Client, error) {
ioaURL := r.option.IOAURL
if ioaURL == "" {
- return nil, fmt.Errorf("IOA not configured: use --ioa-url")
+ return nil, fmt.Errorf("server not configured: use --server-url")
}
client, err := ioaclient.NewClient(ioaURL, "")
if err != nil {
@@ -738,7 +974,7 @@ func (r *AgentConsole) ioaClient() (*ioaclient.Client, error) {
}
if client.AccessKey() != "" {
if err := client.EnsureRegistered(context.Background(), "aiscan-tui", "", nil); err != nil {
- return nil, fmt.Errorf("IOA auth: %w", err)
+ return nil, fmt.Errorf("server auth: %w", err)
}
}
return client, nil
@@ -801,14 +1037,325 @@ func (r *AgentConsole) configureProvider(args []string) error {
}
}
- resolved, err := agent.ResolveProvider(&pc)
+ resolved, err := r.applyProviderConfig(pc)
if err != nil {
return err
}
- prov, err := agent.NewProviderFromResolved(resolved)
+ if resolved.Model != "" {
+ fmt.Fprintf(r.stdout, "Provider ready: %s / %s\n", resolved.Provider, resolved.Model)
+ } else {
+ fmt.Fprintf(r.stdout, "Provider ready: %s\n", resolved.Provider)
+ }
+ return nil
+}
+
+const modelListTimeout = 10 * time.Second
+
+func (r *AgentConsole) resumeSession(path string) error {
+ if r.controller != nil && r.controller.Running() {
+ return fmt.Errorf("cannot resume while a task is running")
+ }
+ if r.agent == nil {
+ return fmt.Errorf("agent session is not configured")
+ }
+ path, err := r.resolveSessionSelection(path)
+ if err != nil {
+ return err
+ }
+ data, err := agent.LoadSession(path)
+ if err != nil {
+ return err
+ }
+ r.agent.LoadMessages(data.Messages)
+ fmt.Fprintf(r.stdout, "Resumed %d messages from %s\n", len(data.Messages), path)
+ return nil
+}
+
+func (r *AgentConsole) renderSessions() (string, error) {
+ colorEnabled := r.output != nil && r.output.color.Enabled
+ sessions, err := r.listSavedSessions()
+ if err != nil {
+ return "", err
+ }
+ if len(sessions) == 0 {
+ return r.renderPanel("sessions", renderHelpRows([]helpRow{
+ {Command: "sessions", Detail: "none saved"},
+ }, colorEnabled), colorEnabled), nil
+ }
+ rows := make([]helpRow, 0, len(sessions))
+ for i, session := range sessions {
+ rows = append(rows, helpRow{
+ Command: fmt.Sprintf("#%d", i+1),
+ Detail: filepath.Base(session.Path) + " " + sessionDetail(session),
+ })
+ }
+ return r.renderPanel("sessions", renderHelpRows(rows, colorEnabled), colorEnabled), nil
+}
+
+func (r *AgentConsole) listSavedSessions() ([]agent.SessionInfo, error) {
+ dir := r.sessionDir
+ if dir == "" {
+ dir = cfg.DataSubDir("sessions")
+ }
+ return agent.ListSessions(dir)
+}
+
+func (r *AgentConsole) resumeSessionInteractive() error {
+ if r.controller != nil && r.controller.Running() {
+ return fmt.Errorf("cannot resume while a task is running")
+ }
+ if r.agent == nil {
+ return fmt.Errorf("agent session is not configured")
+ }
+ sessions, err := r.listSavedSessions()
+ if err != nil {
+ return err
+ }
+ if len(sessions) == 0 {
+ rendered, err := r.renderSessions()
+ if err != nil {
+ return err
+ }
+ fmt.Fprint(r.stdout, rendered)
+ return nil
+ }
+
+ choices := make([]choiceItem, 0, len(sessions))
+ for _, session := range sessions {
+ choices = append(choices, choiceItem{
+ value: session.Path,
+ title: filepath.Base(session.Path),
+ desc: sessionDetail(session),
+ })
+ }
+ width, height := r.pickerSize()
+ selected, ok, err := runChoicePicker("sessions", choices, "", width, height)
if err != nil {
return err
}
+ if !ok {
+ return nil
+ }
+ return r.resumeSession(selected)
+}
+
+func (r *AgentConsole) resolveSessionSelection(selector string) (string, error) {
+ selector = strings.TrimSpace(strings.TrimPrefix(selector, "#"))
+ if selector == "" {
+ return "", fmt.Errorf("usage: /resume [list||#index]")
+ }
+ sessions, err := r.listSavedSessions()
+ if err != nil {
+ return "", err
+ }
+ if idx, err := strconv.Atoi(selector); err == nil {
+ if idx < 1 || idx > len(sessions) {
+ return "", fmt.Errorf("session index out of range: %d", idx)
+ }
+ return sessions[idx-1].Path, nil
+ }
+ for _, session := range sessions {
+ if selector == session.Path || selector == filepath.Base(session.Path) {
+ return session.Path, nil
+ }
+ }
+ return selector, nil
+}
+
+func sessionDetail(session agent.SessionInfo) string {
+ parts := make([]string, 0, 4)
+ if ts := session.SortTime(); !ts.IsZero() {
+ parts = append(parts, ts.Local().Format("2006-01-02 15:04:05"))
+ }
+ model := strings.Trim(strings.TrimSpace(session.Provider)+"/"+strings.TrimSpace(session.Model), "/")
+ if model != "" {
+ parts = append(parts, model)
+ }
+ parts = append(parts, fmt.Sprintf("%d messages", session.Messages))
+ return strings.Join(parts, " ")
+}
+
+func (r *AgentConsole) renderModels(ctx context.Context) (string, error) {
+ colorEnabled := r.output != nil && r.output.color.Enabled
+ models, err := r.listProviderModels(ctx)
+ if err != nil {
+ return "", err
+ }
+ if len(models) == 0 {
+ return r.renderPanel("models", renderHelpRows([]helpRow{
+ {Command: "current", Detail: r.appInfo.ProviderConfig.Provider + " / " + r.appInfo.ProviderConfig.Model},
+ {Command: "models", Detail: "none returned"},
+ }, colorEnabled), colorEnabled), nil
+ }
+
+ current := strings.TrimSpace(r.appInfo.ProviderConfig.Model)
+ rows := []helpRow{
+ {Command: "current", Detail: r.appInfo.ProviderConfig.Provider + " / " + valueOrDash(current)},
+ }
+ for i, model := range models {
+ command := fmt.Sprintf("#%d", i+1)
+ detail := model
+ if model == current {
+ detail += " active"
+ }
+ rows = append(rows, helpRow{Command: command, Detail: detail})
+ }
+ return r.renderPanel("models", renderHelpRows(rows, colorEnabled), colorEnabled), nil
+}
+
+func (r *AgentConsole) configureModel(ctx context.Context, selector string) error {
+ if r.controller != nil && r.controller.Running() {
+ return fmt.Errorf("cannot change model while a task is running")
+ }
+ selector = strings.TrimSpace(strings.TrimPrefix(selector, "#"))
+ if selector == "" {
+ return fmt.Errorf("usage: /model [list||#index]")
+ }
+ models, err := r.listProviderModels(ctx)
+ if err != nil {
+ return err
+ }
+ model, err := resolveModelSelection(models, selector)
+ if err != nil {
+ return err
+ }
+
+ return r.applyModel(model)
+}
+
+func (r *AgentConsole) configureModelInteractive(ctx context.Context) error {
+ if r.controller != nil && r.controller.Running() {
+ return fmt.Errorf("cannot change model while a task is running")
+ }
+ models, err := r.listProviderModels(ctx)
+ if err != nil {
+ return err
+ }
+ if len(models) == 0 {
+ rendered, err := r.renderModels(ctx)
+ if err != nil {
+ return err
+ }
+ fmt.Fprint(r.stdout, rendered)
+ return nil
+ }
+ width, height := r.pickerSize()
+ selected, ok, err := runModelPicker(models, r.appInfo.ProviderConfig.Model, width, height)
+ if err != nil {
+ return err
+ }
+ if !ok {
+ return nil
+ }
+ return r.applyModel(selected)
+}
+
+func (r *AgentConsole) applyModel(model string) error {
+ pc := r.appInfo.ProviderConfig
+ pc.Model = model
+ resolved, err := r.applyProviderConfig(pc)
+ if err != nil {
+ return err
+ }
+ fmt.Fprintf(r.stdout, "Model ready: %s / %s\n", resolved.Provider, resolved.Model)
+ return nil
+}
+
+func (r *AgentConsole) listProviderModels(ctx context.Context) ([]string, error) {
+ pc := r.appInfo.ProviderConfig
+ if strings.TrimSpace(pc.Provider) == "" && strings.TrimSpace(pc.BaseURL) == "" {
+ return nil, fmt.Errorf("provider not configured")
+ }
+ req := probe.LLMProbeRequest{
+ Provider: pc.Provider,
+ BaseURL: pc.BaseURL,
+ APIKey: pc.APIKey,
+ Proxy: pc.Proxy,
+ }
+ listCtx, cancel := context.WithTimeout(ctx, modelListTimeout)
+ defer cancel()
+ result, err := probe.ListLLMModels(listCtx, req, "")
+ if err != nil {
+ return nil, err
+ }
+ if !result.OK {
+ if strings.TrimSpace(result.Error) == "" {
+ return nil, fmt.Errorf("list models failed")
+ }
+ return nil, fmt.Errorf("list models: %s", result.Error)
+ }
+ return result.Models, nil
+}
+
+func resolveModelSelection(models []string, selector string) (string, error) {
+ if idx, err := strconv.Atoi(selector); err == nil {
+ if idx < 1 || idx > len(models) {
+ return "", fmt.Errorf("model index out of range: %d", idx)
+ }
+ return models[idx-1], nil
+ }
+ for _, model := range models {
+ if model == selector {
+ return model, nil
+ }
+ }
+ for _, model := range models {
+ if strings.EqualFold(model, selector) {
+ return model, nil
+ }
+ }
+ return "", fmt.Errorf("model %q is not in the provider model list", selector)
+}
+
+func valueOrDash(value string) string {
+ if strings.TrimSpace(value) == "" {
+ return "-"
+ }
+ return value
+}
+
+func (r *AgentConsole) interactivePickerEnabled() bool {
+ return r != nil &&
+ r.terminal != nil &&
+ r.terminal.Control != nil &&
+ r.terminal.Control.IsTerminal() &&
+ r.terminal.In == os.Stdin &&
+ r.terminal.Out == os.Stdout
+}
+
+func (r *AgentConsole) pickerSize() (int, int) {
+ width, height := 80, 18
+ if r == nil || r.terminal == nil || r.terminal.Control == nil {
+ return width, height
+ }
+ cols, rows := r.terminal.Control.Size()
+ if cols > 0 {
+ width = cols
+ }
+ if rows > 0 {
+ height = rows - 4
+ }
+ if height < 10 {
+ height = 10
+ }
+ if height > 24 {
+ height = 24
+ }
+ return width, height
+}
+
+func (r *AgentConsole) applyProviderConfig(pc agent.ProviderConfig) (agent.ProviderConfig, error) {
+ if pc.Model != r.appInfo.ProviderConfig.Model {
+ pc.Images = nil
+ }
+ resolved, err := agent.ResolveProvider(&pc)
+ if err != nil {
+ return agent.ProviderConfig{}, err
+ }
+ prov, err := agent.NewProviderFromResolved(resolved)
+ if err != nil {
+ return agent.ProviderConfig{}, err
+ }
r.appInfo.Provider = prov
r.appInfo.ProviderConfig = *resolved
@@ -825,12 +1372,7 @@ func (r *AgentConsole) configureProvider(args []string) error {
}
r.syncEvalToController()
- if resolved.Model != "" {
- fmt.Fprintf(r.stdout, "Provider ready: %s / %s\n", resolved.Provider, resolved.Model)
- } else {
- fmt.Fprintf(r.stdout, "Provider ready: %s\n", resolved.Provider)
- }
- return nil
+ return *resolved, nil
}
func (r *AgentConsole) pseudoCommandNames() []string {
@@ -919,8 +1461,9 @@ func (r *AgentConsole) atCompleteAction(c carapace.Context) carapace.Action {
if !strings.HasPrefix(c.Value, "@") {
return carapace.ActionValues()
}
- c.Value = c.Value[1:]
- fileAction := carapace.ActionFiles().Invoke(c).Prefix("@").ToA().NoSpace()
+ raw := c.Value[1:]
+ fileAction := atFuzzyFileAction(raw)
+ c.Value = raw
nodeAction := r.atNodeCompleteAction(c)
return carapace.Batch(fileAction, nodeAction).ToA()
}
diff --git a/pkg/tui/console_test.go b/pkg/tui/console_test.go
index fbc49a95..f56cb638 100644
--- a/pkg/tui/console_test.go
+++ b/pkg/tui/console_test.go
@@ -1,14 +1,40 @@
package tui
import (
+ "bytes"
"context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
"reflect"
+ "strings"
"testing"
+ "time"
cfg "github.com/chainreactors/aiscan/core/config"
+ "github.com/chainreactors/aiscan/pkg/agent"
"github.com/chainreactors/tui/readline/inputrc"
)
+type captureConsoleProvider struct {
+ requests []*agent.ChatCompletionRequest
+}
+
+func (p *captureConsoleProvider) Name() string { return "capture" }
+
+func (p *captureConsoleProvider) ChatCompletion(_ context.Context, req *agent.ChatCompletionRequest) (*agent.ChatCompletionResponse, error) {
+ cp := *req
+ cp.Messages = append([]agent.ChatMessage(nil), req.Messages...)
+ p.requests = append(p.requests, &cp)
+ return &agent.ChatCompletionResponse{
+ Choices: []agent.Choice{{
+ Message: agent.NewTextMessage("assistant", "ok"),
+ }},
+ }, nil
+}
+
func TestAgentConsoleArgsForLineBangCommand(t *testing.T) {
got, err := AgentConsoleArgsForLine("!echo chat_pass")
if err != nil {
@@ -23,6 +49,12 @@ func TestAgentConsoleArgsForLineBangCommand(t *testing.T) {
func TestAgentReadlineBackspaceBindings(t *testing.T) {
repl := NewAgentConsole(context.Background(), &cfg.Option{}, AppInfo{}, nil, nil)
shell := repl.console.Shell()
+ if !shell.Config.GetBool("menu-complete-display-prefix") {
+ t.Fatal("menu-complete-display-prefix should stay enabled so completion replaces the typed prefix")
+ }
+ if shell.Config.GetBool("autocomplete-select") {
+ t.Fatal("autocomplete-select should stay disabled so typing does not hijack arrow keys before Tab")
+ }
for _, keymap := range []string{"emacs", "emacs-standard", "vi-insert"} {
for _, seq := range []string{inputrc.Unescape(`\C-h`), inputrc.Unescape(`\C-?`)} {
bind, ok := shell.Config.Binds[keymap][seq]
@@ -33,6 +65,13 @@ func TestAgentReadlineBackspaceBindings(t *testing.T) {
t.Fatalf("%s %q action = %q", keymap, inputrc.Escape(seq), bind.Action)
}
}
+ tabBind, ok := shell.Config.Binds[keymap][`\t`]
+ if !ok {
+ t.Fatalf("%s missing bind for tab", keymap)
+ }
+ if tabBind.Action != "menu-complete" {
+ t.Fatalf("%s tab action = %q, want menu-complete", keymap, tabBind.Action)
+ }
}
}
@@ -62,3 +101,234 @@ func TestAgentReadlinePendingMultilinePasteReference(t *testing.T) {
t.Fatalf("resolved paste = %q", resolved)
}
}
+
+func TestFuzzySubsequenceMatching(t *testing.T) {
+ tests := []struct {
+ query, value string
+ want bool
+ }{
+ {"af", "abcdef", true},
+ {"abc", "abcdef", true},
+ {"adf", "abcdef", true},
+ {"xyz", "abcdef", false},
+ {"AF", "abcdef", true},
+ {"", "anything", true},
+ }
+ for _, tt := range tests {
+ if got := fuzzySubsequence(tt.query, tt.value); got != tt.want {
+ t.Errorf("fuzzySubsequence(%q, %q) = %v, want %v", tt.query, tt.value, got, tt.want)
+ }
+ }
+}
+
+func TestSplitCompletionPath(t *testing.T) {
+ dir, query, _ := splitCompletionPath("src/ma")
+ if dir != "src/" || query != "ma" {
+ t.Fatalf("splitCompletionPath(\"src/ma\") = %q, %q", dir, query)
+ }
+ dir, query, _ = splitCompletionPath("ab")
+ if dir != "" || query != "ab" {
+ t.Fatalf("splitCompletionPath(\"ab\") = %q, %q", dir, query)
+ }
+}
+
+func TestReadlineDoesNotSuppressLiveStatusWhileTaskRuns(t *testing.T) {
+ var stdout, stderr bytes.Buffer
+ repl := NewAgentConsoleWithWriters(context.Background(), &cfg.Option{}, AppInfo{}, agent.NewAgent(agent.Config{}), &stdout, &stderr)
+ repl.controller.mu.Lock()
+ repl.controller.running = true
+ repl.controller.mu.Unlock()
+
+ repl.setReadlineActive(true)
+
+ repl.output.mu.Lock()
+ active := repl.output.interactiveInputActive
+ repl.output.mu.Unlock()
+ if active {
+ t.Fatal("running task should keep live status enabled")
+ }
+}
+
+func TestAgentConsoleCtrlCWarnsAndClearsInput(t *testing.T) {
+ var stdout, stderr bytes.Buffer
+ repl := NewAgentConsoleWithWriters(context.Background(), &cfg.Option{}, AppInfo{}, nil, &stdout, &stderr)
+ repl.console.Shell().Line().Set([]rune("exit")...)
+
+ repl.handleCtrlC()
+
+ if !repl.pendingExit.Load() {
+ t.Fatal("pending exit was not set")
+ }
+ if got := string(*repl.console.Shell().Line()); got != "" {
+ t.Fatalf("input line = %q, want empty", got)
+ }
+ out := stdout.String() + stderr.String()
+ if !strings.Contains(out, "Press Ctrl+C again to exit") {
+ t.Fatalf("missing Ctrl+C hint:\n%s", out)
+ }
+ if strings.Contains(stripANSI(out), "aiscan> exit") {
+ t.Fatalf("Ctrl+C leaked input as output:\n%s", out)
+ }
+}
+
+func TestAgentConsoleModelCommandListsAndSwitches(t *testing.T) {
+ mux := http.NewServeMux()
+ mux.HandleFunc("/v1/models", func(w http.ResponseWriter, r *http.Request) {
+ if got := r.Header.Get("Authorization"); got != "Bearer sk-test" {
+ t.Fatalf("Authorization = %q", got)
+ }
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "data": []map[string]string{
+ {"id": "model-a"},
+ {"id": "model-b"},
+ },
+ })
+ })
+ srv := httptest.NewServer(mux)
+ defer srv.Close()
+
+ var stdout, stderr bytes.Buffer
+ option := &cfg.Option{}
+ session := agent.NewAgent(agent.Config{Model: "model-a"})
+ var changed agent.ProviderConfig
+ repl := NewAgentConsoleWithWriters(context.Background(), option, AppInfo{
+ ProviderConfig: agent.ProviderConfig{
+ Provider: "openai",
+ BaseURL: srv.URL + "/v1",
+ APIKey: "sk-test",
+ Model: "model-a",
+ },
+ OnProviderChange: func(_ agent.Provider, providerConfig agent.ProviderConfig) {
+ changed = providerConfig
+ },
+ }, session, &stdout, &stderr)
+
+ if _, err := repl.ExecuteLineAndWait("/model"); err != nil {
+ t.Fatalf("/model: %v\nstderr=%s", err, stderr.String())
+ }
+ if out := stdout.String(); !strings.Contains(out, "model-a active") || !strings.Contains(out, "model-b") {
+ t.Fatalf("/model output missing models:\n%s", out)
+ }
+
+ stdout.Reset()
+ stderr.Reset()
+ if _, err := repl.ExecuteLineAndWait("/model 2"); err != nil {
+ t.Fatalf("/model 2: %v\nstderr=%s", err, stderr.String())
+ }
+ if changed.Model != "model-b" {
+ t.Fatalf("changed model = %q, want model-b", changed.Model)
+ }
+ if option.Model != "model-b" {
+ t.Fatalf("option model = %q, want model-b", option.Model)
+ }
+ if session.Cfg.Model != "model-b" {
+ t.Fatalf("session model = %q, want model-b", session.Cfg.Model)
+ }
+ if out := stdout.String(); !strings.Contains(out, "Model ready: openai / model-b") {
+ t.Fatalf("switch output = %q", out)
+ }
+}
+
+func TestAgentConsoleResumeLoadsSessionMessages(t *testing.T) {
+ dir := t.TempDir()
+ if err := agent.SaveSession(dir, &agent.SessionData{
+ Model: "test-model",
+ Provider: "capture",
+ Messages: []agent.ChatMessage{
+ agent.NewTextMessage("user", "previous user"),
+ agent.NewTextMessage("assistant", "previous assistant"),
+ },
+ }); err != nil {
+ t.Fatalf("SaveSession: %v", err)
+ }
+ sessions, err := agent.ListSessions(dir)
+ if err != nil {
+ t.Fatalf("ListSessions: %v", err)
+ }
+ if len(sessions) != 1 {
+ t.Fatalf("sessions len = %d, want 1", len(sessions))
+ }
+ path := sessions[0].Path
+
+ var stdout, stderr bytes.Buffer
+ prov := &captureConsoleProvider{}
+ session := agent.NewAgent(agent.Config{Provider: prov, Model: "test-model"})
+ repl := NewAgentConsoleWithWriters(context.Background(), &cfg.Option{}, AppInfo{}, session, &stdout, &stderr)
+
+ if _, err := repl.ExecuteLineAndWait("/resume " + path); err != nil {
+ t.Fatalf("/resume: %v\nstderr=%s", err, stderr.String())
+ }
+ if out := stdout.String(); !strings.Contains(out, "Resumed 2 messages") {
+ t.Fatalf("resume output = %q", out)
+ }
+
+ stdout.Reset()
+ stderr.Reset()
+ if _, err := repl.ExecuteLineAndWait("new prompt"); err != nil {
+ t.Fatalf("prompt after resume: %v\nstderr=%s", err, stderr.String())
+ }
+ if len(prov.requests) == 0 {
+ t.Fatal("provider was not called")
+ }
+ var contents []string
+ for _, msg := range prov.requests[0].Messages {
+ if msg.Content != nil {
+ contents = append(contents, *msg.Content)
+ }
+ }
+ joined := strings.Join(contents, "\n")
+ for _, want := range []string{"previous user", "previous assistant", "new prompt"} {
+ if !strings.Contains(joined, want) {
+ t.Fatalf("request messages missing %q:\n%s", want, joined)
+ }
+ }
+}
+
+func TestAgentConsoleResumeListsAndSelectsSession(t *testing.T) {
+ dir := t.TempDir()
+ oldPath := filepath.Join(dir, "session-old.json")
+ newPath := filepath.Join(dir, "session-new.json")
+ writeConsoleSession(t, oldPath, "old-model", "old message", time.Date(2026, 7, 12, 10, 0, 0, 0, time.UTC))
+ writeConsoleSession(t, newPath, "new-model", "new message", time.Date(2026, 7, 13, 10, 0, 0, 0, time.UTC))
+
+ var stdout, stderr bytes.Buffer
+ session := agent.NewAgent(agent.Config{})
+ repl := NewAgentConsoleWithWriters(context.Background(), &cfg.Option{}, AppInfo{}, session, &stdout, &stderr)
+ repl.sessionDir = dir
+
+ if _, err := repl.ExecuteLineAndWait("/resume list"); err != nil {
+ t.Fatalf("/resume list: %v\nstderr=%s", err, stderr.String())
+ }
+ listOut := stdout.String()
+ if !strings.Contains(listOut, "session-new.json") || !strings.Contains(listOut, "session-old.json") {
+ t.Fatalf("resume list missing sessions:\n%s", listOut)
+ }
+ if strings.Index(listOut, "session-new.json") > strings.Index(listOut, "session-old.json") {
+ t.Fatalf("sessions not sorted newest first:\n%s", listOut)
+ }
+
+ stdout.Reset()
+ stderr.Reset()
+ if _, err := repl.ExecuteLineAndWait("/resume 1"); err != nil {
+ t.Fatalf("/resume 1: %v\nstderr=%s", err, stderr.String())
+ }
+ if out := stdout.String(); !strings.Contains(out, "Resumed 1 messages from "+newPath) {
+ t.Fatalf("resume output = %q", out)
+ }
+}
+
+func writeConsoleSession(t *testing.T, path, model, content string, updatedAt time.Time) {
+ t.Helper()
+ raw, err := json.Marshal(agent.SessionData{
+ Version: 1,
+ UpdatedAt: updatedAt,
+ Model: model,
+ Messages: []agent.ChatMessage{agent.NewTextMessage("user", content)},
+ })
+ if err != nil {
+ t.Fatalf("marshal session: %v", err)
+ }
+ if err := os.WriteFile(path, raw, 0o644); err != nil {
+ t.Fatalf("write session: %v", err)
+ }
+}
diff --git a/pkg/tui/controller.go b/pkg/tui/controller.go
index a09934cc..45ed0c63 100644
--- a/pkg/tui/controller.go
+++ b/pkg/tui/controller.go
@@ -36,6 +36,9 @@ type interactiveRunController struct {
onFinish func()
Eval *EvalSettings
+
+ compactContextTokens int
+ compactContextWindow int
}
func newInteractiveRunController(ctx context.Context, session *agent.Agent, output *AgentOutput) *interactiveRunController {
@@ -152,6 +155,21 @@ func (c *interactiveRunController) run(ctx context.Context, cancel context.Cance
return
}
c.output.Final(result.Output)
+
+ c.checkContextUsage(result)
+}
+
+func (c *interactiveRunController) checkContextUsage(result *agent.Result) {
+ if result == nil || result.ContextTokens <= 0 {
+ return
+ }
+ contextWindow := agent.ModelContextWindow(c.session.Cfg.Model)
+ if result.ContextTokens*100/contextWindow >= 80 {
+ c.mu.Lock()
+ c.compactContextTokens = result.ContextTokens
+ c.compactContextWindow = contextWindow
+ c.mu.Unlock()
+ }
}
func (c *interactiveRunController) finish() {
diff --git a/pkg/tui/ioa.go b/pkg/tui/ioa.go
index 0f5698f5..753a3551 100644
--- a/pkg/tui/ioa.go
+++ b/pkg/tui/ioa.go
@@ -122,6 +122,95 @@ func RunIOANodes(ctx context.Context, client *ioaclient.Client, option *cfg.Opti
return w.Flush()
}
+// ---------------------------------------------------------------------------
+// REPL-boxed listings
+//
+// The CLI (cmd/aiscan) keeps the plain tabwriter tables above for scriptable,
+// pipeable --json output. The interactive REPL renders the same data as boxed
+// panels so /spaces, /nodes and /messages match /status and /provider.
+// ---------------------------------------------------------------------------
+
+func shortID(id string) string { return id[:min(len(id), 8)] }
+
+func (r *AgentConsole) renderIOASpaces(ctx context.Context, client *ioaclient.Client) error {
+ spaces, err := client.ListSpaces(ctx)
+ if err != nil {
+ return err
+ }
+ if len(spaces) == 0 {
+ fmt.Fprintln(r.stderr, "no spaces found")
+ return nil
+ }
+ rows := make([][]string, 0, len(spaces))
+ for _, s := range spaces {
+ rows = append(rows, []string{
+ shortID(s.ID), s.Name,
+ fmt.Sprintf("%d node", len(s.Nodes)), fmt.Sprintf("%d msg", s.MessageCount),
+ })
+ }
+ r.printBoxTable("spaces", rows)
+ return nil
+}
+
+func (r *AgentConsole) renderIOANodes(ctx context.Context, client *ioaclient.Client, space string) error {
+ if space != "" {
+ sp, err := client.ResolveSpace(ctx, space)
+ if err != nil {
+ return err
+ }
+ if len(sp.Nodes) == 0 {
+ fmt.Fprintf(r.stderr, "no nodes in space %q\n", sp.Name)
+ return nil
+ }
+ rows := make([][]string, 0, len(sp.Nodes))
+ for _, n := range sp.Nodes {
+ rows = append(rows, []string{shortID(n.ID), n.Name, n.Description})
+ }
+ r.printBoxTable("nodes", rows)
+ return nil
+ }
+ nodes, err := client.ListNodes(ctx)
+ if err != nil {
+ return err
+ }
+ if len(nodes) == 0 {
+ fmt.Fprintln(r.stderr, "no nodes found")
+ return nil
+ }
+ rows := make([][]string, 0, len(nodes))
+ for _, n := range nodes {
+ rows = append(rows, []string{shortID(n.ID), n.Name})
+ }
+ r.printBoxTable("nodes", rows)
+ return nil
+}
+
+func (r *AgentConsole) renderIOAMessages(ctx context.Context, client *ioaclient.Client, space string) error {
+ sp, err := client.ResolveSpace(ctx, space)
+ if err != nil {
+ return err
+ }
+ messages, err := client.ReadPublic(ctx, sp.ID, protocols.ReadOptions{})
+ if err != nil {
+ return err
+ }
+ if len(messages) == 0 {
+ fmt.Fprintf(r.stderr, "no start messages in space %q\n", sp.Name)
+ return nil
+ }
+ rows := make([][]string, 0, len(messages))
+ for _, m := range messages {
+ rows = append(rows, []string{shortID(m.ID), m.Sender, contentPreview(m.Content, 48)})
+ }
+ r.printBoxTable("messages", rows)
+ return nil
+}
+
+func (r *AgentConsole) printBoxTable(title string, rows [][]string) {
+ colorEnabled := r.output != nil && r.output.color.Enabled
+ fmt.Fprint(r.stdout, r.renderPanel(title, renderBoxTable(rows, colorEnabled), colorEnabled))
+}
+
func contentPreview(content map[string]any, maxLen int) string {
if text, ok := content["text"].(string); ok {
if len(text) > maxLen {
diff --git a/pkg/tui/keybindings.go b/pkg/tui/keybindings.go
index b1452d46..d9a7f3b3 100644
--- a/pkg/tui/keybindings.go
+++ b/pkg/tui/keybindings.go
@@ -1,9 +1,7 @@
package tui
import (
- "errors"
"fmt"
- "os"
"sort"
"strings"
"time"
@@ -29,6 +27,7 @@ func configureAgentReadline(c *console.Console) {
_ = cfg.Set("completion-query-items", 1000)
_ = cfg.Set("bell-style", "none")
_ = cfg.Set("enable-bracketed-paste", true)
+ _ = cfg.Set("autocomplete-select", false)
backspace := inputrc.Unescape(`\C-h`)
deleteBackspace := inputrc.Unescape(`\C-?`)
for _, keymap := range []string{"emacs", "emacs-standard", "vi-insert"} {
@@ -55,6 +54,10 @@ func (r *AgentConsole) configureInterruptKey() {
}
}
+func (r *AgentConsole) configureCompletionKey() {
+ wrapCompleterForFuzzyAt(r.console.Shell())
+}
+
func (r *AgentConsole) configureCtrlCKey() {
if r == nil || r.console == nil || r.console.Shell() == nil {
return
@@ -72,27 +75,42 @@ func (r *AgentConsole) configureCtrlCKey() {
}
func (r *AgentConsole) handleCtrlC() {
- if r.InterruptCurrentRun() {
+ if r.pendingExit.Load() {
+ r.forceExit()
return
}
- if r.pendingExit.Load() {
- os.Exit(0)
+ if r.InterruptCurrentRun() {
+ r.pendingExit.Store(true)
+ go func() {
+ time.Sleep(5 * time.Second)
+ r.pendingExit.Store(false)
+ }()
+ return
}
r.pendingExit.Store(true)
- fmt.Fprintf(r.stderr, " Press Ctrl+C again to exit\n")
+ r.clearReadlineInput()
+ r.printCtrlCExitHint()
go func() {
time.Sleep(3 * time.Second)
r.pendingExit.Store(false)
}()
+}
+
+func (r *AgentConsole) clearReadlineInput() {
shell := r.console.Shell()
- shell.Display.AcceptLine()
- shell.History.Accept(false, false, errors.New(os.Interrupt.String()))
+ shell.Line().Set()
+ shell.Cursor().Set(0)
}
-func (r *AgentConsole) configureVerbosityToggleKey() {
- if r == nil || r.console == nil || r.console.Shell() == nil {
+func (r *AgentConsole) printCtrlCExitHint() {
+ if shell := r.console.Shell(); shell != nil {
+ _, _ = shell.Printf("Press Ctrl+C again to exit")
return
}
+ fmt.Fprintln(r.stderr, "Press Ctrl+C again to exit")
+}
+
+func (r *AgentConsole) configureVerbosityToggleKey() {
shell := r.console.Shell()
shell.Keymap.Register(map[string]func(){
agentConsoleToggleVerbosityCommandName: func() {
@@ -124,10 +142,10 @@ func (r *AgentConsole) handleToggleVerbosity() {
}
func (r *AgentConsole) handleEscapeInterruptKey() {
- if r == nil || r.console == nil || r.console.Shell() == nil {
+ shell := r.console.Shell()
+ if shell == nil {
return
}
- shell := r.console.Shell()
pending := string(shell.Keys.Read())
if pending == "" {
pending = readPendingTerminalBytes(agentConsoleEscapeSequenceWait)
diff --git a/pkg/tui/live.go b/pkg/tui/live.go
index 33e19409..a4de7a2c 100644
--- a/pkg/tui/live.go
+++ b/pkg/tui/live.go
@@ -358,12 +358,8 @@ func (l *LiveStatus) ensureTools() {
}
func (l *LiveStatus) hasTool(id string) bool {
- for _, existing := range l.order {
- if existing == id {
- return true
- }
- }
- return false
+ _, ok := l.tools[id]
+ return ok
}
func (l *LiveStatus) allToolsDone() bool {
diff --git a/pkg/tui/model_picker.go b/pkg/tui/model_picker.go
new file mode 100644
index 00000000..ebbfd72a
--- /dev/null
+++ b/pkg/tui/model_picker.go
@@ -0,0 +1,156 @@
+package tui
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/charmbracelet/bubbles/list"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+)
+
+type choiceItem struct {
+ value string
+ title string
+ desc string
+ current bool
+}
+
+func (i choiceItem) FilterValue() string {
+ return strings.TrimSpace(i.title + " " + i.desc + " " + i.value)
+}
+
+func (i choiceItem) Title() string { return i.title }
+
+func (i choiceItem) Description() string {
+ if i.current {
+ if i.desc == "" {
+ return "active"
+ }
+ return i.desc + " active"
+ }
+ return i.desc
+}
+
+type choicePicker struct {
+ list list.Model
+ selected string
+ canceled bool
+}
+
+type modelPicker = choicePicker
+
+func newChoicePicker(title string, choices []choiceItem, current string, width, height int) choicePicker {
+ items := make([]list.Item, 0, len(choices))
+ selected := 0
+ for i, item := range choices {
+ if item.title == "" {
+ item.title = item.value
+ }
+ item.current = item.current || item.value == current
+ if item.current {
+ selected = i
+ }
+ items = append(items, item)
+ }
+
+ delegate := list.NewDefaultDelegate()
+ delegate.ShowDescription = true
+ styles := list.NewDefaultItemStyles()
+ styles.SelectedTitle = lipgloss.NewStyle().
+ Border(lipgloss.NormalBorder(), false, false, false, true).
+ BorderForeground(lipgloss.Color("6")).
+ Foreground(lipgloss.Color("6")).
+ Padding(0, 0, 0, 1)
+ styles.SelectedDesc = styles.SelectedTitle.Foreground(lipgloss.Color("2"))
+ delegate.Styles = styles
+
+ if width <= 0 {
+ width = 80
+ }
+ if height <= 0 {
+ height = 18
+ }
+ m := list.New(items, delegate, width, height)
+ m.Title = title
+ m.SetShowStatusBar(false)
+ m.SetShowHelp(true)
+ m.SetFilteringEnabled(true)
+ m.Select(selected)
+
+ return choicePicker{list: m}
+}
+
+func newModelPicker(models []string, current string, width, height int) modelPicker {
+ choices := make([]choiceItem, 0, len(models))
+ for _, model := range models {
+ choices = append(choices, choiceItem{value: model, title: model})
+ }
+ return newChoicePicker("models", choices, current, width, height)
+}
+
+func (m choicePicker) Init() tea.Cmd {
+ return nil
+}
+
+func (m choicePicker) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.WindowSizeMsg:
+ m.list.SetSize(msg.Width, msg.Height)
+ case tea.KeyMsg:
+ switch msg.String() {
+ case "enter":
+ if item, ok := m.list.SelectedItem().(choiceItem); ok {
+ m.selected = item.value
+ }
+ return m, tea.Quit
+ case "esc", "ctrl+c", "q":
+ m.canceled = true
+ return m, tea.Quit
+ }
+ }
+
+ var cmd tea.Cmd
+ m.list, cmd = m.list.Update(msg)
+ return m, cmd
+}
+
+func (m choicePicker) View() string {
+ if len(m.list.Items()) == 0 {
+ return "\nNo items.\n"
+ }
+ return strings.TrimRight(m.list.View(), "\n") + "\n"
+}
+
+func (m choicePicker) result() (string, bool) {
+ if m.canceled || strings.TrimSpace(m.selected) == "" {
+ return "", false
+ }
+ return m.selected, true
+}
+
+func runChoicePicker(title string, choices []choiceItem, current string, width, height int) (string, bool, error) {
+ p := tea.NewProgram(newChoicePicker(title, choices, current, width, height), tea.WithAltScreen())
+ finalModel, err := p.Run()
+ if err != nil {
+ return "", false, fmt.Errorf("picker: %w", err)
+ }
+ picker, ok := finalModel.(choicePicker)
+ if !ok {
+ return "", false, fmt.Errorf("picker returned %T", finalModel)
+ }
+ selected, ok := picker.result()
+ return selected, ok, nil
+}
+
+func runModelPicker(models []string, current string, width, height int) (string, bool, error) {
+ choices := make([]choiceItem, 0, len(models))
+ for _, model := range models {
+ choices = append(choices, choiceItem{value: model, title: model})
+ }
+ selected, ok, err := runChoicePicker("models", choices, current, width, height)
+ if err != nil {
+ return "", false, fmt.Errorf("model picker: %w", err)
+ }
+ return selected, ok, nil
+}
diff --git a/pkg/tui/model_picker_test.go b/pkg/tui/model_picker_test.go
new file mode 100644
index 00000000..b29bf703
--- /dev/null
+++ b/pkg/tui/model_picker_test.go
@@ -0,0 +1,37 @@
+package tui
+
+import (
+ "testing"
+
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+func TestModelPickerSelectsCurrentModel(t *testing.T) {
+ model := newModelPicker([]string{"model-a", "model-b"}, "model-b", 80, 20)
+ updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyEnter})
+ picker, ok := updated.(modelPicker)
+ if !ok {
+ t.Fatalf("updated model = %T, want modelPicker", updated)
+ }
+ selected, ok := picker.result()
+ if !ok || selected != "model-b" {
+ t.Fatalf("selected = %q ok=%v, want model-b true", selected, ok)
+ }
+}
+
+func TestChoicePickerSelectsSession(t *testing.T) {
+ model := newChoicePicker("sessions", []choiceItem{
+ {value: "session-new.json", title: "session-new.json"},
+ {value: "session-old.json", title: "session-old.json"},
+ }, "", 80, 20)
+ updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyDown})
+ updated, _ = updated.Update(tea.KeyMsg{Type: tea.KeyEnter})
+ picker, ok := updated.(choicePicker)
+ if !ok {
+ t.Fatalf("updated model = %T, want choicePicker", updated)
+ }
+ selected, ok := picker.result()
+ if !ok || selected != "session-old.json" {
+ t.Fatalf("selected = %q ok=%v, want session-old.json true", selected, ok)
+ }
+}
diff --git a/pkg/tui/output.go b/pkg/tui/output.go
index fcf16cc6..fc4d41fd 100644
--- a/pkg/tui/output.go
+++ b/pkg/tui/output.go
@@ -48,9 +48,11 @@ type AgentOutput struct {
toolErrorCount int
// Transient UI.
- mode RenderMode
- tty bool
- live *LiveStatus
+ mode RenderMode
+ tty bool
+ interactiveInputActive bool
+ live *LiveStatus
+ split *SplitTerminal
}
func NewAgentOutput(option *cfg.Option) *AgentOutput {
@@ -115,8 +117,6 @@ func newAgentOutput(option *cfg.Option, stdout, stderr io.Writer, stdoutTTY, std
return o
}
-func AgentStreamingEnabled(_ *cfg.Option) bool { return true }
-
// Stderr returns the stream writer's stderr for direct output.
func (o *AgentOutput) Stderr() io.Writer { return o.stream.stderr }
@@ -126,6 +126,21 @@ func (o *AgentOutput) Stdout() io.Writer { return o.stream.stdout }
// Markdown returns whether markdown rendering is enabled.
func (o *AgentOutput) Markdown() bool { return o.stream.markdown }
+// SetSplitMode redirects all output through the split terminal's scroll region
+// and wires the live status into the fixed status bar.
+func (o *AgentOutput) SetSplitMode(st *SplitTerminal) {
+ if o == nil || st == nil {
+ return
+ }
+ o.mu.Lock()
+ defer o.mu.Unlock()
+ o.split = st
+ outW := st.OutputWriter()
+ o.stream.stdout = outW
+ o.stream.stderr = outW
+ o.live.view.SetSplitTerminal(st)
+}
+
// ---------------------------------------------------------------------------
// Verbosity
// ---------------------------------------------------------------------------
@@ -247,8 +262,6 @@ func (o *AgentOutput) Queued(text string) {
}
}
-func (o *AgentOutput) QueuedFollowUp(text string) { o.Queued("follow-up: " + text) }
-
func (o *AgentOutput) Stopping() {
if o == nil || o.verbosity < 0 {
return
@@ -304,6 +317,20 @@ func (o *AgentOutput) EnsureStreamNewline() {
o.stream.EnsureNewline()
}
+func (o *AgentOutput) SetInteractiveInputActive(active bool) {
+ if o == nil {
+ return
+ }
+ o.mu.Lock()
+ defer o.mu.Unlock()
+ o.interactiveInputActive = active
+ // In split mode the live status renders to a separate area, so we
+ // never need to stop it when readline becomes active.
+ if active && o.split == nil {
+ o.stopLive()
+ }
+}
+
// ---------------------------------------------------------------------------
// Event handling
// ---------------------------------------------------------------------------
@@ -352,6 +379,11 @@ func (o *AgentOutput) HandleEvent(event agent.Event) {
o.live.MessageUpdate(event, contentDelta)
}
+ case agent.EventMessageEnd:
+ if event.Message.Role == "assistant" && len(event.Message.ToolCalls) > 0 {
+ o.stopLive()
+ }
+
case agent.EventToolExecutionStart:
if o.canAnimate() {
if !o.live.HasTools() {
@@ -418,6 +450,15 @@ func (o *AgentOutput) HandleEvent(event agent.Event) {
case agent.EventEvalError:
o.stopLive()
o.evalError(event)
+ case agent.EventCompactStart:
+ o.stopLive()
+ o.compactStart(event)
+ case agent.EventCompactEnd:
+ o.stopLive()
+ o.compactEnd(event)
+ case agent.EventCompactError:
+ o.stopLive()
+ o.compactError(event)
}
}
@@ -426,7 +467,15 @@ func (o *AgentOutput) HandleEvent(event agent.Event) {
// ---------------------------------------------------------------------------
func (o *AgentOutput) canAnimate() bool {
- return o != nil && o.mode == ModeInteractive && o.tty && o.verbosity >= 0
+ if o == nil || o.mode != ModeInteractive || !o.tty || o.verbosity < 0 {
+ return false
+ }
+ // In split mode the status bar never overlaps the input area, so
+ // animation can continue while readline is active.
+ if o.split != nil {
+ return true
+ }
+ return !o.interactiveInputActive
}
func (o *AgentOutput) renderToolLine(ev agent.Event) string {
@@ -761,6 +810,38 @@ func (o *AgentOutput) evalError(event agent.Event) {
fmt.Fprintf(w, "%s%s\n", toolResultIndent, o.dim(detail+", continuing..."))
}
+func (o *AgentOutput) compactStart(_ agent.Event) {
+ w := o.Stderr()
+ if w == nil {
+ return
+ }
+ fmt.Fprintln(w)
+ fmt.Fprintf(w, "%s%s\n", toolBlockIndent,
+ o.color.Wrap("⋯", output.ANSICyan)+" "+o.bold("compact")+" "+o.dim("compacting context..."))
+}
+
+func (o *AgentOutput) compactEnd(event agent.Event) {
+ w := o.Stderr()
+ if w == nil {
+ return
+ }
+ fmt.Fprintln(w)
+ fmt.Fprintf(w, "%s%s\n", toolBlockIndent,
+ o.color.Wrap("✓", output.ANSIGreen)+" "+o.bold("compact")+" "+
+ o.dim(fmt.Sprintf("~%d → ~%d tokens (%d messages kept)",
+ event.CompactTokensBefore, event.CompactTokensAfter, event.CompactKeptMessages)))
+}
+
+func (o *AgentOutput) compactError(_ agent.Event) {
+ w := o.Stderr()
+ if w == nil {
+ return
+ }
+ fmt.Fprintln(w)
+ fmt.Fprintf(w, "%s%s\n", toolBlockIndent,
+ o.color.Wrap("⚠", output.ANSIYellow)+" "+o.bold("compact")+" "+o.dim("failed"))
+}
+
func (o *AgentOutput) renderUserIntent(body string) {
w := o.Stderr()
if w == nil {
diff --git a/pkg/tui/output_test.go b/pkg/tui/output_test.go
index 566c3e4e..0433c8bc 100644
--- a/pkg/tui/output_test.go
+++ b/pkg/tui/output_test.go
@@ -215,6 +215,32 @@ func TestThinkingLineShowsTokenUsage(t *testing.T) {
}
}
+func TestInteractiveInputSuppressesLiveStatus(t *testing.T) {
+ var stdout bytes.Buffer
+ var stderr syncedBuffer
+ o := NewAgentOutputWithWriters(&cfg.Option{}, &stdout, &stderr, true)
+ defer o.live.Stop()
+
+ o.SetInteractiveInputActive(true)
+ o.HandleEvent(agent.Event{Type: agent.EventTurnStart, Turn: 1})
+ o.HandleEvent(agent.Event{
+ Type: agent.EventMessageUpdate,
+ Turn: 1,
+ Usage: &agent.Usage{PromptTokens: 1000, CompletionTokens: 234, TotalTokens: 1234},
+ Message: agent.ChatMessage{
+ Role: "assistant",
+ },
+ })
+
+ got := stripANSI(stderr.String())
+ if strings.Contains(got, "thinking") || strings.Contains(got, "tokens=1,234") {
+ t.Fatalf("live status leaked while input active: %q", got)
+ }
+ if liveRunning(o.live) {
+ t.Fatal("live spinner started while input was active")
+ }
+}
+
func TestLiveStatusShowsCumulativeContextAndCurrentOutputTokens(t *testing.T) {
var stdout bytes.Buffer
var stderr syncedBuffer
@@ -326,6 +352,33 @@ func TestLiveStatusSwitchesTalkingAndTooling(t *testing.T) {
}
}
+func TestAssistantToolCallMessageEndStopsLiveStatus(t *testing.T) {
+ var stdout bytes.Buffer
+ var stderr syncedBuffer
+ o := NewAgentOutputWithWriters(&cfg.Option{}, &stdout, &stderr, true)
+ defer o.live.Stop()
+
+ o.HandleEvent(agent.Event{Type: agent.EventTurnStart, Turn: 1})
+ o.HandleEvent(agent.Event{
+ Type: agent.EventMessageEnd,
+ Turn: 1,
+ Message: agent.ChatMessage{
+ Role: "assistant",
+ ToolCalls: []agent.ToolCall{{
+ ID: "call-1",
+ Function: agent.FunctionCall{
+ Name: "bash",
+ Arguments: `{"command":"echo hi"}`,
+ },
+ }},
+ },
+ })
+
+ if liveRunning(o.live) {
+ t.Fatal("assistant tool-call message end should stop live status before logger output")
+ }
+}
+
func TestThinkingVerboseStreamsReasoningWithoutTags(t *testing.T) {
var stdout bytes.Buffer
var stderr syncedBuffer
diff --git a/pkg/tui/remote_console.go b/pkg/tui/remote_console.go
index a21630a5..8eeace45 100644
--- a/pkg/tui/remote_console.go
+++ b/pkg/tui/remote_console.go
@@ -13,23 +13,6 @@ import (
rlterm "github.com/chainreactors/tui/readline/terminal"
)
-// RunRemoteAgentConsole runs the agent console over a byte-stream transport.
-// The transport provides raw terminal input and receives terminal output.
-func RunRemoteAgentConsole(ctx context.Context, option *cfg.Option, appInfo AppInfo, session *agent.Agent, input io.Reader, output io.Writer, bus ...*eventbus.Bus[agent.Event]) error {
- if option == nil {
- option = &cfg.Option{}
- }
- if input == nil {
- return fmt.Errorf("remote console input is nil")
- }
- if output == nil {
- output = io.Discard
- }
-
- control := rlterm.NewControl(true, 80, 24)
- return RunRemoteAgentConsoleWithControl(ctx, option, appInfo, session, input, output, control, bus...)
-}
-
func RunRemoteAgentConsoleWithControl(ctx context.Context, option *cfg.Option, appInfo AppInfo, session *agent.Agent, input io.Reader, output io.Writer, control *rlterm.StreamControl, bus ...*eventbus.Bus[agent.Event]) error {
if control == nil {
control = rlterm.NewControl(true, 80, 24)
@@ -73,4 +56,3 @@ func (w *remoteTerminalWriter) Write(p []byte) (int, error) {
_, err := w.w.Write(w.buf.Bytes())
return len(p), err
}
-
diff --git a/pkg/tui/render.go b/pkg/tui/render.go
index b9d037a2..cf612479 100644
--- a/pkg/tui/render.go
+++ b/pkg/tui/render.go
@@ -78,6 +78,9 @@ var defaultFrames = bspinner.Dot
// LiveView manages a transient, animated region on the terminal. Lines
// containing spinnerSentinel get the current animation frame injected on each
// tick. Stop erases the region cleanly.
+//
+// When split is non-nil the view renders into the split terminal's status bar
+// instead of using inline cursor-up/erase tricks.
type LiveView struct {
w io.Writer
accent string // ANSI color for spinner frames
@@ -90,12 +93,28 @@ type LiveView struct {
rendered int
stop chan struct{}
done chan struct{}
+
+ split *SplitTerminal // set once; safe to read without mu
}
func NewLiveView(w io.Writer, accent string) *LiveView {
return &LiveView{w: w, accent: accent}
}
+// SetSplitTerminal puts the view into split mode: status is rendered to the
+// split terminal's fixed status bar instead of inline. Must be called before
+// Start and never changed afterwards.
+func (v *LiveView) SetSplitTerminal(st *SplitTerminal) {
+ if v == nil {
+ return
+ }
+ v.split = st
+ // Redirect the fallback writer into the scroll region so any code
+ // path that writes to v.w directly can never leak into the raw
+ // terminal (which would appear in the input area).
+ v.w = st.OutputWriter()
+}
+
func (v *LiveView) Update(lines []string) {
if v == nil {
return
@@ -151,6 +170,10 @@ func (v *LiveView) render(frame string) {
func (v *LiveView) renderLocked(frame string) {
v.frame = frame
+ if v.split != nil {
+ v.renderSplitLocked(frame)
+ return
+ }
if v.hidden {
return
}
@@ -184,6 +207,20 @@ func (v *LiveView) renderLocked(frame string) {
v.rendered = len(lines)
}
+// renderSplitLocked renders the first status line to the split terminal's
+// status bar. Tool-progress lines are omitted (they appear permanently in
+// the output area when completed).
+func (v *LiveView) renderSplitLocked(frame string) {
+ lines := v.lines
+ if len(lines) == 0 {
+ v.split.UpdateStatus("")
+ return
+ }
+ marker := v.accent + frame + "\x1b[0m"
+ text := strings.Replace(lines[0], spinnerSentinel, marker, 1)
+ v.split.UpdateStatus(" " + text)
+}
+
func (v *LiveView) WithHidden(fn func()) {
if v == nil {
if fn != nil {
@@ -191,6 +228,14 @@ func (v *LiveView) WithHidden(fn func()) {
}
return
}
+ // In split mode output and status areas don't overlap; no need to
+ // hide the status bar while writing content to the scroll region.
+ if v.split != nil {
+ if fn != nil {
+ fn()
+ }
+ return
+ }
v.mu.Lock()
if !v.running {
v.mu.Unlock()
@@ -235,8 +280,13 @@ func (v *LiveView) Stop() {
n := v.rendered
v.rendered = 0
done := v.done
+ isSplit := v.split != nil
v.mu.Unlock()
<-done
+ if isSplit {
+ v.split.ClearStatus()
+ return
+ }
if n > 0 {
writeSynced(v.w, func() {
eraseLines(v.w, n)
diff --git a/pkg/tui/split.go b/pkg/tui/split.go
new file mode 100644
index 00000000..d5caeb2d
--- /dev/null
+++ b/pkg/tui/split.go
@@ -0,0 +1,824 @@
+package tui
+
+import (
+ "fmt"
+ "io"
+ "os"
+ "strings"
+ "sync"
+ "unicode/utf8"
+
+ runewidth "github.com/mattn/go-runewidth"
+ "golang.org/x/term"
+)
+
+const (
+ splitDefaultInputRows = 8 // prompt, multiline edits and completion menu
+ splitMinRows = 10 // don't split if terminal is too small
+)
+
+// SplitTerminal divides the terminal into three areas:
+//
+// rows 1..scrollEnd - scroll region for agent output
+// row statusRow - fixed status bar (thinking/tooling/talking)
+// rows inputRow..rows - fixed input area for readline
+//
+// The status bar always sits immediately above the fixed input viewport.
+type SplitTerminal struct {
+ mu sync.Mutex
+ raw io.Writer
+ fd int
+
+ cols int
+ rows int
+ scrollEnd int
+ statusRow int
+ inputRow int
+ inputRows int // fixed input row budget
+ active bool
+
+ // Tracked cursor position within the scroll region.
+ outRow int
+ outCol int
+ outPendingWrap bool
+
+ // Tracked cursor row inside the input area (0-based offset from inputRow).
+ inputCurRow int
+ inputCurCol int // 1-based column
+ inputPendingWrap bool
+ inputSaveRow int
+ inputSaveCol int
+
+ statusText string
+
+ stopCh chan struct{}
+
+ outputW *splitOutputWriter
+ inputW *splitInputWriter
+}
+
+func NewSplitTerminal(raw io.Writer, fd int) *SplitTerminal {
+ cols, rows, _ := term.GetSize(fd)
+ if cols <= 0 {
+ cols = 80
+ }
+ if rows <= 0 {
+ rows = 24
+ }
+ st := &SplitTerminal{
+ raw: raw,
+ fd: fd,
+ cols: cols,
+ rows: rows,
+ }
+ st.applyInputRows(splitDefaultInputRows)
+ st.outputW = &splitOutputWriter{st: st}
+ st.inputW = &splitInputWriter{st: st, raw: raw}
+ return st
+}
+
+// applyInputRows recalculates the three-area layout for the given input
+// height. inputH is clamped to [1, rows/2].
+func (st *SplitTerminal) applyInputRows(inputH int) {
+ if inputH < 1 {
+ inputH = 1
+ }
+ if max := st.rows / 2; inputH > max {
+ inputH = max
+ }
+ reserve := inputH + 1 // +1 for the status bar row
+ st.scrollEnd = st.rows - reserve
+ if st.scrollEnd < 3 {
+ st.scrollEnd = 3
+ }
+ st.statusRow = st.scrollEnd + 1
+ st.inputRow = st.scrollEnd + 2
+ st.inputRows = inputH
+}
+
+func (st *SplitTerminal) Setup() {
+ st.mu.Lock()
+ defer st.mu.Unlock()
+
+ w := st.raw
+ fmt.Fprint(w, "\x1b[?1049h") // alternate screen
+ fmt.Fprint(w, "\x1b[2J") // clear
+ fmt.Fprintf(w, "\x1b[1;%dr", st.scrollEnd)
+ st.outRow = 1
+ st.outCol = 1
+ st.outPendingWrap = false
+ st.inputCurRow = 0
+ st.inputCurCol = 1
+ st.inputPendingWrap = false
+ st.drawStatusContentLocked()
+ st.moveInputCursorLocked()
+ st.active = true
+
+ st.stopCh = make(chan struct{})
+ st.startResizeWatch()
+}
+
+func (st *SplitTerminal) Teardown() {
+ st.mu.Lock()
+ defer st.mu.Unlock()
+ if !st.active {
+ return
+ }
+ st.active = false
+ st.stopResizeWatch()
+ close(st.stopCh)
+ w := st.raw
+ fmt.Fprintf(w, "\x1b[1;%dr", st.rows)
+ fmt.Fprint(w, "\x1b[?1049l")
+}
+
+// ---------------------------------------------------------------------------
+// Status bar drawing
+// ---------------------------------------------------------------------------
+
+// drawStatusContentLocked positions the cursor at the status row, erases the
+// line and writes the separator. Callers restore the input cursor explicitly.
+func (st *SplitTerminal) drawStatusContentLocked() {
+ w := st.raw
+ text := st.statusText
+ var line string
+ if text == "" {
+ line = "\x1b[2m" + strings.Repeat("─", st.cols) + "\x1b[0m"
+ } else {
+ tw := visibleWidth(text)
+ trail := st.cols - 3 - tw
+ if trail < 0 {
+ trail = 0
+ }
+ line = "\x1b[2m──\x1b[0m" + text + " \x1b[2m" + strings.Repeat("─", trail) + "\x1b[0m"
+ }
+ fmt.Fprintf(w, "\x1b[%d;1H\x1b[2K%s", st.statusRow, line)
+}
+
+// drawStatusLocked is the convenience wrapper that restores the input cursor
+// after drawing the status row.
+func (st *SplitTerminal) drawStatusLocked() {
+ st.drawStatusContentLocked()
+ st.moveInputCursorLocked()
+}
+
+// ---------------------------------------------------------------------------
+// Resize
+// ---------------------------------------------------------------------------
+
+func (st *SplitTerminal) handleResize() {
+ cols, rows, err := term.GetSize(st.fd)
+ if err != nil || cols <= 0 || rows <= 0 {
+ return
+ }
+ st.mu.Lock()
+ defer st.mu.Unlock()
+ if !st.active || (st.cols == cols && st.rows == rows) {
+ return
+ }
+ st.cols = cols
+ st.rows = rows
+ st.applyInputRows(st.inputRows) // keep current input budget
+ w := st.raw
+ fmt.Fprint(w, syncBegin)
+ fmt.Fprintf(w, "\x1b[1;%dr", st.scrollEnd)
+ st.drawStatusContentLocked()
+ st.clearInputAreaLocked()
+ st.moveInputCursorLocked()
+ fmt.Fprint(w, syncEnd)
+}
+
+// ---------------------------------------------------------------------------
+// Public API
+// ---------------------------------------------------------------------------
+
+// OutputWriter returns a writer that routes output into the scroll region.
+func (st *SplitTerminal) OutputWriter() io.Writer {
+ return st.outputW
+}
+
+// InputWriter returns a writer for readline that serialises with output writes
+// and clips terminal control sequences to the input area.
+func (st *SplitTerminal) InputWriter() io.Writer {
+ return st.inputW
+}
+
+// UpdateStatus replaces the status bar content.
+func (st *SplitTerminal) UpdateStatus(text string) {
+ st.mu.Lock()
+ defer st.mu.Unlock()
+ st.statusText = text
+ if !st.active {
+ return
+ }
+ st.drawStatusLocked()
+}
+
+// ClearStatus resets the status bar to the default separator.
+func (st *SplitTerminal) ClearStatus() {
+ st.UpdateStatus("")
+}
+
+// Active reports whether the split layout is set up.
+func (st *SplitTerminal) Active() bool {
+ st.mu.Lock()
+ defer st.mu.Unlock()
+ return st.active
+}
+
+// PrepareInputArea resets the input area to its default height and clears
+// it for a new readline prompt.
+func (st *SplitTerminal) PrepareInputArea() {
+ st.mu.Lock()
+ defer st.mu.Unlock()
+ if !st.active {
+ return
+ }
+ st.inputCurRow = 0
+ st.inputCurCol = 1
+ st.inputPendingWrap = false
+ st.inputSaveRow = 0
+ st.inputSaveCol = 1
+ st.applyInputRows(splitDefaultInputRows)
+ w := st.raw
+ fmt.Fprint(w, syncBegin)
+ fmt.Fprintf(w, "\x1b[1;%dr", st.scrollEnd)
+ st.drawStatusContentLocked()
+ st.clearInputAreaLocked()
+ st.moveInputCursorLocked()
+ fmt.Fprint(w, syncEnd)
+}
+
+// ---------------------------------------------------------------------------
+// splitOutputWriter routes Write calls into the scroll region.
+// ---------------------------------------------------------------------------
+
+type splitOutputWriter struct {
+ st *SplitTerminal
+}
+
+func (w *splitOutputWriter) Write(p []byte) (int, error) {
+ w.st.mu.Lock()
+ defer w.st.mu.Unlock()
+ if !w.st.active {
+ return w.st.raw.Write(p)
+ }
+ raw := w.st.raw
+ fmt.Fprint(raw, syncBegin)
+ fmt.Fprintf(raw, "\x1b[1;%dr", w.st.scrollEnd)
+ w.st.moveOutputCursorLocked()
+ err := w.st.writeOutputPaneLocked(p)
+ w.st.moveInputCursorLocked()
+ fmt.Fprint(raw, syncEnd)
+ if err != nil {
+ return 0, err
+ }
+ return len(p), nil
+}
+
+// ---------------------------------------------------------------------------
+// Output pane rendering
+// ---------------------------------------------------------------------------
+
+func (st *SplitTerminal) writeOutputPaneLocked(p []byte) error {
+ for i := 0; i < len(p); {
+ if p[i] == 0x1b {
+ next := skipEscape(p, i)
+ if next > len(p) {
+ next = len(p)
+ }
+ seq := p[i:next]
+ if len(seq) >= 3 && seq[1] == '[' {
+ if err := st.writeOutputCSILocked(seq); err != nil {
+ return err
+ }
+ } else if len(seq) >= 2 && seq[1] == ']' {
+ if _, err := st.raw.Write(seq); err != nil {
+ return err
+ }
+ }
+ i = next
+ continue
+ }
+
+ switch p[i] {
+ case '\r':
+ if _, err := io.WriteString(st.raw, "\r"); err != nil {
+ return err
+ }
+ st.outCol = 1
+ st.outPendingWrap = false
+ i++
+ case '\n':
+ if err := st.outputLineFeedLocked(); err != nil {
+ return err
+ }
+ i++
+ case '\t':
+ spaces := 4 - ((st.outCol - 1) % 4)
+ for ; spaces > 0; spaces-- {
+ if err := st.writeOutputRuneLocked(' '); err != nil {
+ return err
+ }
+ }
+ i++
+ default:
+ if p[i] < 0x20 || p[i] == 0x7f {
+ i++
+ continue
+ }
+ r, size := utf8.DecodeRune(p[i:])
+ if r == utf8.RuneError && size == 1 {
+ i++
+ continue
+ }
+ if err := st.writeOutputRuneLocked(r); err != nil {
+ return err
+ }
+ i += size
+ }
+ }
+ return nil
+}
+
+func (st *SplitTerminal) writeOutputCSILocked(seq []byte) error {
+ final := seq[len(seq)-1]
+ params := parseCSIParams(seq)
+ param := func(idx, def int) int {
+ if idx >= len(params) || params[idx] <= 0 {
+ return def
+ }
+ return params[idx]
+ }
+
+ switch final {
+ case 'm':
+ _, err := st.raw.Write(seq)
+ return err
+ case 'A':
+ st.outPendingWrap = false
+ st.outRow -= param(0, 1)
+ st.moveOutputCursorLocked()
+ case 'B':
+ st.outPendingWrap = false
+ st.outRow += param(0, 1)
+ st.moveOutputCursorLocked()
+ case 'C':
+ st.outPendingWrap = false
+ st.outCol += param(0, 1)
+ st.moveOutputCursorLocked()
+ case 'D':
+ st.outPendingWrap = false
+ st.outCol -= param(0, 1)
+ st.moveOutputCursorLocked()
+ case 'E':
+ st.outPendingWrap = false
+ st.outRow += param(0, 1)
+ st.outCol = 1
+ st.moveOutputCursorLocked()
+ case 'F':
+ st.outPendingWrap = false
+ st.outRow -= param(0, 1)
+ st.outCol = 1
+ st.moveOutputCursorLocked()
+ case 'G':
+ st.outPendingWrap = false
+ st.outCol = param(0, 1)
+ st.moveOutputCursorLocked()
+ case 'H', 'f':
+ st.outPendingWrap = false
+ st.outRow = param(0, 1)
+ st.outCol = param(1, 1)
+ st.moveOutputCursorLocked()
+ case 'J':
+ st.clearOutputScreenLocked()
+ case 'K':
+ _, err := st.raw.Write(seq)
+ return err
+ case 'r':
+ return nil
+ default:
+ return nil
+ }
+ return nil
+}
+
+func (st *SplitTerminal) writeOutputRuneLocked(r rune) error {
+ width := runewidth.RuneWidth(r)
+ if width < 0 {
+ width = 0
+ }
+ if width > 0 {
+ if st.outPendingWrap {
+ if err := st.outputLineFeedLocked(); err != nil {
+ return err
+ }
+ }
+ if st.outCol+width-1 > st.cols {
+ if err := st.outputLineFeedLocked(); err != nil {
+ return err
+ }
+ }
+ }
+ if _, err := io.WriteString(st.raw, string(r)); err != nil {
+ return err
+ }
+ st.outCol += width
+ if width > 0 && st.outCol > st.cols {
+ st.outCol = st.cols
+ st.outPendingWrap = true
+ }
+ return nil
+}
+
+func (st *SplitTerminal) outputLineFeedLocked() error {
+ if _, err := io.WriteString(st.raw, "\r\n"); err != nil {
+ return err
+ }
+ st.outCol = 1
+ st.outPendingWrap = false
+ if st.outRow < st.scrollEnd {
+ st.outRow++
+ }
+ return nil
+}
+
+func (st *SplitTerminal) moveOutputCursorLocked() {
+ if st.outRow < 1 {
+ st.outRow = 1
+ }
+ if st.outRow > st.scrollEnd {
+ st.outRow = st.scrollEnd
+ }
+ if st.outCol < 1 {
+ st.outCol = 1
+ }
+ if st.outCol > st.cols {
+ st.outCol = st.cols
+ }
+ fmt.Fprintf(st.raw, "\x1b[%d;%dH", st.outRow, st.outCol)
+}
+
+func (st *SplitTerminal) clearOutputScreenLocked() {
+ curRow, curCol, pendingWrap := st.outRow, st.outCol, st.outPendingWrap
+ for row := 1; row <= st.scrollEnd; row++ {
+ fmt.Fprintf(st.raw, "\x1b[%d;1H\x1b[2K", row)
+ }
+ st.outRow, st.outCol = curRow, curCol
+ st.outPendingWrap = pendingWrap
+ st.moveOutputCursorLocked()
+}
+
+// ---------------------------------------------------------------------------
+// splitInputWriter serialises readline writes into the input area.
+// ---------------------------------------------------------------------------
+
+type splitInputWriter struct {
+ st *SplitTerminal
+ raw io.Writer
+}
+
+func (w *splitInputWriter) Write(p []byte) (int, error) {
+ w.st.mu.Lock()
+ defer w.st.mu.Unlock()
+ if !w.st.active {
+ return w.raw.Write(p)
+ }
+ raw := w.st.raw
+ fmt.Fprint(raw, syncBegin)
+ w.st.moveInputCursorLocked()
+ err := w.st.writeInputPaneLocked(p)
+ fmt.Fprint(raw, syncEnd)
+ if err != nil {
+ return 0, err
+ }
+ return len(p), nil
+}
+
+// ---------------------------------------------------------------------------
+// Input pane rendering
+// ---------------------------------------------------------------------------
+
+func (st *SplitTerminal) writeInputPaneLocked(p []byte) error {
+ for i := 0; i < len(p); {
+ if p[i] == 0x1b {
+ next := skipEscape(p, i)
+ if next > len(p) {
+ next = len(p)
+ }
+ seq := p[i:next]
+ if len(seq) >= 3 && seq[1] == '[' {
+ if err := st.writeInputCSILocked(seq); err != nil {
+ return err
+ }
+ } else if err := st.writeInputEscapeLocked(seq); err != nil {
+ return err
+ }
+ i = next
+ continue
+ }
+
+ switch p[i] {
+ case '\r':
+ st.inputCurCol = 1
+ st.inputPendingWrap = false
+ st.moveInputCursorLocked()
+ i++
+ case '\n':
+ st.inputLineFeedLocked()
+ i++
+ case '\b':
+ if st.inputCurCol > 1 {
+ st.inputCurCol--
+ st.inputPendingWrap = false
+ st.moveInputCursorLocked()
+ }
+ i++
+ case '\t':
+ spaces := 4 - ((st.inputCurCol - 1) % 4)
+ for ; spaces > 0; spaces-- {
+ if err := st.writeInputRuneLocked(' '); err != nil {
+ return err
+ }
+ }
+ i++
+ default:
+ if p[i] < 0x20 || p[i] == 0x7f {
+ i++
+ continue
+ }
+ r, size := utf8.DecodeRune(p[i:])
+ if r == utf8.RuneError && size == 1 {
+ i++
+ continue
+ }
+ if err := st.writeInputRuneLocked(r); err != nil {
+ return err
+ }
+ i += size
+ }
+ }
+ return nil
+}
+
+func (st *SplitTerminal) writeInputEscapeLocked(seq []byte) error {
+ if len(seq) < 2 {
+ return nil
+ }
+ switch seq[1] {
+ case '7':
+ st.inputSaveRow = st.inputCurRow
+ st.inputSaveCol = st.inputCurCol
+ return nil
+ case '8':
+ st.inputCurRow = st.inputSaveRow
+ st.inputCurCol = st.inputSaveCol
+ st.inputPendingWrap = false
+ st.moveInputCursorLocked()
+ return nil
+ case ']':
+ _, err := st.raw.Write(seq) // OSC, e.g. terminal title.
+ return err
+ case '(', ')', '*', '+':
+ _, err := st.raw.Write(seq) // Charset selection.
+ return err
+ default:
+ return nil
+ }
+}
+
+func (st *SplitTerminal) writeInputCSILocked(seq []byte) error {
+ final := seq[len(seq)-1]
+ params := parseCSIParams(seq)
+ param := func(idx, def int) int {
+ if idx >= len(params) || params[idx] <= 0 {
+ return def
+ }
+ return params[idx]
+ }
+
+ switch final {
+ case 'A':
+ st.inputPendingWrap = false
+ st.inputCurRow -= param(0, 1)
+ st.moveInputCursorLocked()
+ case 'B':
+ st.inputPendingWrap = false
+ st.inputCurRow += param(0, 1)
+ st.moveInputCursorLocked()
+ case 'C':
+ st.inputPendingWrap = false
+ st.inputCurCol += param(0, 1)
+ st.moveInputCursorLocked()
+ case 'D':
+ st.inputPendingWrap = false
+ st.inputCurCol -= param(0, 1)
+ st.moveInputCursorLocked()
+ case 'E':
+ st.inputPendingWrap = false
+ st.inputCurRow += param(0, 1)
+ st.inputCurCol = 1
+ st.moveInputCursorLocked()
+ case 'F':
+ st.inputPendingWrap = false
+ st.inputCurRow -= param(0, 1)
+ st.inputCurCol = 1
+ st.moveInputCursorLocked()
+ case 'G':
+ st.inputPendingWrap = false
+ st.inputCurCol = param(0, 1)
+ st.moveInputCursorLocked()
+ case 'H', 'f':
+ st.inputPendingWrap = false
+ st.inputCurRow = param(0, 1) - 1
+ st.inputCurCol = param(1, 1)
+ st.moveInputCursorLocked()
+ case 'J':
+ mode := 0
+ if len(params) > 0 {
+ mode = params[0]
+ }
+ st.clearInputScreenLocked(mode)
+ case 'K':
+ _, err := st.raw.Write(seq)
+ return err
+ case 's':
+ st.inputSaveRow = st.inputCurRow
+ st.inputSaveCol = st.inputCurCol
+ case 'u':
+ st.inputCurRow = st.inputSaveRow
+ st.inputCurCol = st.inputSaveCol
+ st.inputPendingWrap = false
+ st.moveInputCursorLocked()
+ case 'r':
+ // Never let readline alter the split terminal scroll margins.
+ return nil
+ default:
+ _, err := st.raw.Write(seq)
+ return err
+ }
+ return nil
+}
+
+func (st *SplitTerminal) writeInputRuneLocked(r rune) error {
+ width := runewidth.RuneWidth(r)
+ if width < 0 {
+ width = 0
+ }
+ if width > 0 {
+ if st.inputPendingWrap {
+ st.inputLineFeedLocked()
+ }
+ if st.inputCurCol+width-1 > st.cols {
+ st.inputLineFeedLocked()
+ }
+ }
+ if st.inputCurRow >= st.inputRows {
+ return nil
+ }
+ if _, err := io.WriteString(st.raw, string(r)); err != nil {
+ return err
+ }
+ st.inputCurCol += width
+ if width > 0 && st.inputCurCol > st.cols {
+ st.inputCurCol = st.cols
+ st.inputPendingWrap = true
+ }
+ return nil
+}
+
+func (st *SplitTerminal) inputLineFeedLocked() {
+ st.inputCurCol = 1
+ st.inputPendingWrap = false
+ if st.inputCurRow < st.inputRows-1 {
+ st.inputCurRow++
+ }
+ st.moveInputCursorLocked()
+}
+
+func (st *SplitTerminal) moveInputCursorLocked() {
+ if st.inputCurRow < 0 {
+ st.inputCurRow = 0
+ }
+ if st.inputCurRow >= st.inputRows {
+ st.inputCurRow = st.inputRows - 1
+ }
+ if st.inputCurCol < 1 {
+ st.inputCurCol = 1
+ }
+ if st.inputCurCol > st.cols {
+ st.inputCurCol = st.cols
+ }
+ fmt.Fprintf(st.raw, "\x1b[%d;%dH", st.inputRow+st.inputCurRow, st.inputCurCol)
+}
+
+func (st *SplitTerminal) clearInputAreaLocked() {
+ curRow, curCol, pendingWrap := st.inputCurRow, st.inputCurCol, st.inputPendingWrap
+ for row := st.inputRow; row <= st.rows; row++ {
+ fmt.Fprintf(st.raw, "\x1b[%d;1H\x1b[2K", row)
+ }
+ st.inputCurRow, st.inputCurCol = curRow, curCol
+ st.inputPendingWrap = pendingWrap
+ st.moveInputCursorLocked()
+}
+
+func (st *SplitTerminal) clearInputScreenLocked(mode int) {
+ st.moveInputCursorLocked()
+ switch mode {
+ case 1:
+ for row := st.inputRow; row < st.inputRow+st.inputCurRow; row++ {
+ fmt.Fprintf(st.raw, "\x1b[%d;1H\x1b[2K", row)
+ }
+ st.moveInputCursorLocked()
+ fmt.Fprint(st.raw, "\x1b[1K")
+ case 2, 3:
+ st.clearInputAreaLocked()
+ default:
+ fmt.Fprint(st.raw, "\x1b[0K")
+ for row := st.inputRow + st.inputCurRow + 1; row <= st.rows; row++ {
+ fmt.Fprintf(st.raw, "\x1b[%d;1H\x1b[2K", row)
+ }
+ st.moveInputCursorLocked()
+ }
+}
+
+func parseCSIParams(seq []byte) []int {
+ if len(seq) < 3 || seq[0] != 0x1b || seq[1] != '[' {
+ return nil
+ }
+ body := seq[2 : len(seq)-1]
+ params := make([]int, 0, 2)
+ value := 0
+ hasValue := false
+ hasParam := false
+ for _, b := range body {
+ switch {
+ case b >= '0' && b <= '9':
+ value = value*10 + int(b-'0')
+ hasValue = true
+ hasParam = true
+ case b == ';':
+ params = append(params, value)
+ value = 0
+ hasValue = false
+ hasParam = true
+ case b == '?' || b == '>' || b == '=' || b == ' ':
+ continue
+ default:
+ continue
+ }
+ }
+ if hasValue || hasParam {
+ params = append(params, value)
+ }
+ return params
+}
+
+func skipEscape(p []byte, i int) int {
+ if i+1 >= len(p) {
+ return i + 1
+ }
+ switch p[i+1] {
+ case '[': // CSI sequence
+ j := i + 2
+ for j < len(p) && (p[j] < 0x40 || p[j] > 0x7e) {
+ j++
+ }
+ if j < len(p) {
+ j++
+ }
+ return j
+ case ']': // OSC sequence
+ j := i + 2
+ for j < len(p) {
+ if p[j] == 0x07 {
+ return j + 1
+ }
+ if p[j] == 0x1b && j+1 < len(p) && p[j+1] == '\\' {
+ return j + 2
+ }
+ j++
+ }
+ return j
+ default:
+ return i + 2
+ }
+}
+
+// splitEnabled reports whether the terminal supports the split layout.
+func splitEnabled(fd int, mode RenderMode) bool {
+ if mode != ModeInteractive {
+ return false
+ }
+ if !term.IsTerminal(fd) {
+ return false
+ }
+ if os.Getenv("AISCAN_SPLIT") == "0" {
+ return false
+ }
+ _, rows, err := term.GetSize(fd)
+ if err != nil || rows < splitMinRows {
+ return false
+ }
+ return true
+}
diff --git a/pkg/tui/split_resize_unix.go b/pkg/tui/split_resize_unix.go
new file mode 100644
index 00000000..47f23288
--- /dev/null
+++ b/pkg/tui/split_resize_unix.go
@@ -0,0 +1,29 @@
+//go:build !windows
+
+package tui
+
+import (
+ "os"
+ "os/signal"
+ "syscall"
+)
+
+func (st *SplitTerminal) startResizeWatch() {
+ sigCh := make(chan os.Signal, 1)
+ signal.Notify(sigCh, syscall.SIGWINCH)
+ go func() {
+ for {
+ select {
+ case <-st.stopCh:
+ signal.Stop(sigCh)
+ return
+ case <-sigCh:
+ st.handleResize()
+ }
+ }
+ }()
+}
+
+func (st *SplitTerminal) stopResizeWatch() {
+ // stopCh close in Teardown triggers the goroutine to exit and call signal.Stop.
+}
diff --git a/pkg/tui/split_resize_windows.go b/pkg/tui/split_resize_windows.go
new file mode 100644
index 00000000..e21caf24
--- /dev/null
+++ b/pkg/tui/split_resize_windows.go
@@ -0,0 +1,25 @@
+//go:build windows
+
+package tui
+
+import "time"
+
+// Windows has no SIGWINCH. Poll terminal size periodically instead.
+func (st *SplitTerminal) startResizeWatch() {
+ go func() {
+ ticker := time.NewTicker(500 * time.Millisecond)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-st.stopCh:
+ return
+ case <-ticker.C:
+ st.handleResize()
+ }
+ }
+ }()
+}
+
+func (st *SplitTerminal) stopResizeWatch() {
+ // stopCh close in Teardown triggers the goroutine to exit.
+}
diff --git a/pkg/tui/split_test.go b/pkg/tui/split_test.go
new file mode 100644
index 00000000..13e05599
--- /dev/null
+++ b/pkg/tui/split_test.go
@@ -0,0 +1,176 @@
+package tui
+
+import (
+ "bytes"
+ "fmt"
+ "strings"
+ "testing"
+)
+
+func newTestSplitTerminal(cols, rows, inputRows int) (*SplitTerminal, *bytes.Buffer) {
+ var buf bytes.Buffer
+ st := &SplitTerminal{
+ raw: &buf,
+ cols: cols,
+ rows: rows,
+ active: true,
+ outRow: 1,
+ outCol: 1,
+ inputCurCol: 1,
+ }
+ st.applyInputRows(inputRows)
+ st.outputW = &splitOutputWriter{st: st}
+ st.inputW = &splitInputWriter{st: st, raw: &buf}
+ return st, &buf
+}
+
+func TestSplitInputWriterLocalizesClearScreenBelow(t *testing.T) {
+ st, buf := newTestSplitTerminal(40, 12, 4)
+
+ if _, err := st.InputWriter().Write([]byte("abc\x1b[J")); err != nil {
+ t.Fatalf("Write: %v", err)
+ }
+ got := buf.String()
+ if strings.Contains(got, "\x1b[J") || strings.Contains(got, "\x1b[0J") {
+ t.Fatalf("input writer leaked clear-screen sequence: %q", got)
+ }
+ if strings.Contains(got, fmt.Sprintf("\x1b[%d;1H\x1b[2K", st.statusRow)) {
+ t.Fatalf("input writer cleared status row: %q", got)
+ }
+ if !strings.Contains(got, fmt.Sprintf("\x1b[%d;1H\x1b[2K", st.inputRow+1)) {
+ t.Fatalf("input writer did not clear below inside input area: %q", got)
+ }
+ if !strings.Contains(got, "abc") {
+ t.Fatalf("input writer dropped printable text: %q", got)
+ }
+}
+
+func TestSplitInputWriterClampsCursorUp(t *testing.T) {
+ st, buf := newTestSplitTerminal(40, 12, 4)
+
+ if _, err := st.InputWriter().Write([]byte("\x1b[5Ahi")); err != nil {
+ t.Fatalf("Write: %v", err)
+ }
+ got := buf.String()
+ if strings.Contains(got, "\x1b[5A") {
+ t.Fatalf("input writer leaked relative cursor-up: %q", got)
+ }
+ if !strings.Contains(got, fmt.Sprintf("\x1b[%d;1H", st.inputRow)) {
+ t.Fatalf("input writer did not clamp to input top: %q", got)
+ }
+}
+
+func TestSplitInputWriterDoesNotEmitLineFeed(t *testing.T) {
+ st, buf := newTestSplitTerminal(40, 12, 4)
+
+ if _, err := st.InputWriter().Write([]byte("a\nb")); err != nil {
+ t.Fatalf("Write: %v", err)
+ }
+ got := buf.String()
+ if strings.Contains(got, "\n") {
+ t.Fatalf("input writer emitted raw newline: %q", got)
+ }
+ if !strings.Contains(got, fmt.Sprintf("\x1b[%d;1H", st.inputRow+1)) {
+ t.Fatalf("input writer did not move newline inside input area: %q", got)
+ }
+}
+
+func TestSplitInputWriterReadlineCompletionRefreshReturnsToPrompt(t *testing.T) {
+ st, _ := newTestSplitTerminal(80, 24, 8)
+
+ refresh := "" +
+ "\x1b[?25l" + // hide cursor
+ "\x1b[80D" +
+ "aiscan ❯ /aiscan\x1b[0K" +
+ "\r\n\x1b[0J" +
+ "commands\x1b[0K\r\r\n" +
+ "/aiscan -- Use this skill when the agent needs to understand aiscan\x1b[0K" +
+ "\x1b[80D\x1b[1A\x1b[1A" +
+ "\x1b[80D\x1b[9C" +
+ "\x1b[80D\x1b[16C" +
+ "\x1b[?25h"
+
+ if _, err := st.InputWriter().Write([]byte(refresh)); err != nil {
+ t.Fatalf("Write: %v", err)
+ }
+ if st.inputCurRow != 0 {
+ t.Fatalf("cursor row after refresh = %d, want prompt row", st.inputCurRow)
+ }
+
+ if _, err := st.InputWriter().Write([]byte(refresh)); err != nil {
+ t.Fatalf("second Write: %v", err)
+ }
+ if st.inputCurRow != 0 {
+ t.Fatalf("cursor row after second refresh = %d, want prompt row", st.inputCurRow)
+ }
+}
+
+func TestSplitInputWriterExactWidthHelperLineDoesNotAdvance(t *testing.T) {
+ st, _ := newTestSplitTerminal(20, 12, 4)
+
+ seq := "\r\n\x1b[0J" +
+ strings.Repeat("x", 20) +
+ "\x1b[0K\x1b[20D\x1b[1A"
+
+ if _, err := st.InputWriter().Write([]byte(seq)); err != nil {
+ t.Fatalf("Write: %v", err)
+ }
+ if st.inputCurRow != 0 {
+ t.Fatalf("cursor row after exact-width helper = %d, want prompt row", st.inputCurRow)
+ }
+}
+
+func TestSplitInputWriterWrapsOnNextPrintableAfterFullColumn(t *testing.T) {
+ st, _ := newTestSplitTerminal(5, 12, 4)
+
+ if _, err := st.InputWriter().Write([]byte("abcdeZ")); err != nil {
+ t.Fatalf("Write: %v", err)
+ }
+ if st.inputCurRow != 1 || st.inputCurCol != 2 {
+ t.Fatalf("cursor = row %d col %d, want row 1 col 2", st.inputCurRow, st.inputCurCol)
+ }
+}
+
+func TestSplitOutputWriterPinsScrollRegion(t *testing.T) {
+ st, buf := newTestSplitTerminal(40, 12, 4)
+
+ if _, err := st.OutputWriter().Write([]byte("hello\n")); err != nil {
+ t.Fatalf("Write: %v", err)
+ }
+ got := buf.String()
+ if !strings.Contains(got, fmt.Sprintf("\x1b[1;%dr", st.scrollEnd)) {
+ t.Fatalf("output writer did not set scroll region: %q", got)
+ }
+ if !strings.Contains(got, fmt.Sprintf("\x1b[%d;1H", st.inputRow)) {
+ t.Fatalf("output writer did not restore input cursor explicitly: %q", got)
+ }
+}
+
+func TestSplitOutputWriterLocalizesControlSequences(t *testing.T) {
+ st, buf := newTestSplitTerminal(40, 12, 4)
+
+ if _, err := st.OutputWriter().Write([]byte("top\x1b[99;1Hbad\x1b[2J")); err != nil {
+ t.Fatalf("Write: %v", err)
+ }
+ got := buf.String()
+ if strings.Contains(got, "\x1b[99;1H") || strings.Contains(got, "\x1b[2J") {
+ t.Fatalf("output writer leaked pane-breaking sequence: %q", got)
+ }
+ if strings.Contains(got, fmt.Sprintf("\x1b[%d;1H\x1b[2K", st.inputRow)) {
+ t.Fatalf("output writer cleared input row: %q", got)
+ }
+ if !strings.Contains(got, "top") || !strings.Contains(got, "bad") {
+ t.Fatalf("output writer dropped printable output: %q", got)
+ }
+}
+
+func TestSplitOutputWriterExactWidthNewlineDoesNotSkipRow(t *testing.T) {
+ st, _ := newTestSplitTerminal(5, 12, 4)
+
+ if _, err := st.OutputWriter().Write([]byte("abcde\nz")); err != nil {
+ t.Fatalf("Write: %v", err)
+ }
+ if st.outRow != 2 || st.outCol != 2 {
+ t.Fatalf("cursor = row %d col %d, want row 2 col 2", st.outRow, st.outCol)
+ }
+}
diff --git a/pkg/web/agents.go b/pkg/web/agents.go
index d1ed85dd..3e738332 100644
--- a/pkg/web/agents.go
+++ b/pkg/web/agents.go
@@ -1,6 +1,7 @@
package web
import (
+ "cmp"
"context"
"encoding/json"
"fmt"
@@ -20,13 +21,14 @@ type WSMessage = webproto.Message
// AgentInfo is the public view of a connected agent.
type AgentInfo struct {
- ID string `json:"id"`
- Name string `json:"name"`
- Commands []string `json:"commands,omitempty"`
- Busy bool `json:"busy"`
- ConnectAt time.Time `json:"connected_at"`
- Identity webproto.AgentIdentity `json:"identity,omitempty"`
- Stats webproto.AgentStats `json:"stats,omitempty"`
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Commands []string `json:"commands,omitempty"`
+ CommandsMenu []webproto.CommandSpec `json:"commands_menu,omitempty"`
+ Busy bool `json:"busy"`
+ ConnectAt time.Time `json:"connected_at"`
+ Identity webproto.AgentIdentity `json:"identity,omitempty"`
+ Stats webproto.AgentStats `json:"stats,omitempty"`
}
type taskResult struct {
@@ -37,14 +39,15 @@ type taskResult struct {
}
type remoteAgent struct {
- id string
- name string
- commands []string
- conn *websocket.Conn
- sendCh chan WSMessage
- connectAt time.Time
- identity webproto.AgentIdentity
- stats webproto.AgentStats
+ id string
+ name string
+ commands []string
+ commandsMenu []webproto.CommandSpec
+ conn *websocket.Conn
+ sendCh chan WSMessage
+ connectAt time.Time
+ identity webproto.AgentIdentity
+ stats webproto.AgentStats
mu sync.Mutex
tasks map[string]chan taskResult
@@ -56,16 +59,27 @@ func (a *remoteAgent) info() AgentInfo {
a.mu.Lock()
defer a.mu.Unlock()
return AgentInfo{
- ID: a.id,
- Name: a.name,
- Commands: a.commands,
- Busy: len(a.tasks) > 0,
- ConnectAt: a.connectAt,
- Identity: a.identity,
- Stats: a.stats,
+ ID: a.id,
+ Name: a.name,
+ Commands: a.commands,
+ CommandsMenu: a.commandsMenu,
+ Busy: len(a.tasks) > 0,
+ ConnectAt: a.connectAt,
+ Identity: a.identity,
+ Stats: a.stats,
}
}
+// commandSpecs returns the agent's reported "/verb" catalog (its agent-scope
+// menu commands plus one per loaded skill). Immutable after register, so it
+// needs no lock. The hub merges it with its hub-scope commands in SessionMenu.
+func (a *remoteAgent) commandSpecs() []webproto.CommandSpec {
+ if a == nil {
+ return nil
+ }
+ return a.commandsMenu
+}
+
// SessionLookup resolves a task ID to its owning chat session.
type SessionLookup interface {
TaskSession(taskID string) (sessionID string, ok bool)
@@ -89,6 +103,7 @@ type AgentPool struct {
ptySubs map[string]chan WSMessage
ptyDrops atomic.Int64
allowedOrigins []string
+ upgrader websocket.Upgrader
}
func NewAgentPool(hub *Hub, allowedOrigins ...string) *AgentPool {
@@ -96,6 +111,7 @@ func NewAgentPool(hub *Hub, allowedOrigins ...string) *AgentPool {
agents: make(map[string]*remoteAgent),
hub: hub,
ptySubs: make(map[string]chan WSMessage),
+ upgrader: buildUpgrader(allowedOrigins),
allowedOrigins: allowedOrigins,
}
}
@@ -108,25 +124,51 @@ func (p *AgentPool) SetRecordStore(rs RecordStore) {
p.records = rs
}
+// agentKey is the pool key for a registering agent: its stable node identity, so
+// a reconnecting agent (WS flap, hub restart, config-driven bounce) re-registers
+// under the SAME key. The hub used to mint a throwaway id per connection, which
+// dangled every chat session bound to it — the session freezes the agent id at
+// creation, so on reconnect the stored id resolved to nothing and the chat
+// rejected every message as "not connected" even with the agent right back.
+// Mirrors the frontend's agentNodeKey (node_name, then name); in practice both
+// equal rt.NodeName. Only a fully anonymous client — no node name and no name —
+// falls back to a per-connection id.
+func agentKey(info webproto.RegisterPayload) string {
+ if k := cmp.Or(info.Identity.NodeName, info.Name); k != "" {
+ return k
+ }
+ return generateID()
+}
+
func (p *AgentPool) register(a *remoteAgent) {
p.mu.Lock()
+ old := p.agents[a.id]
p.agents[a.id] = a
p.mu.Unlock()
+ // The pool is keyed by stable identity (see agentKey), so a reconnecting agent
+ // — or a second agent sharing the same node name — lands on an occupied slot.
+ // Tear the stale connection down: its read loop then exits and its
+ // identity-checked unregister no-ops, leaving `a` alone in the slot.
+ if old != nil && old != a {
+ _ = old.conn.Close()
+ }
}
-func (p *AgentPool) unregister(id string) {
+func (p *AgentPool) unregister(a *remoteAgent) {
p.mu.Lock()
- a, ok := p.agents[id]
- delete(p.agents, id)
+ // Only vacate the slot if it still holds THIS instance. After a reconnect the
+ // slot was already reassigned to the replacement under the same key; the old
+ // instance tearing down must not evict its successor.
+ if p.agents[a.id] == a {
+ delete(p.agents, a.id)
+ }
p.mu.Unlock()
- if ok {
- a.mu.Lock()
- for _, ch := range a.tasks {
- close(ch)
- }
- a.tasks = nil
- a.mu.Unlock()
+ a.mu.Lock()
+ for _, ch := range a.tasks {
+ close(ch)
}
+ a.tasks = nil
+ a.mu.Unlock()
}
func (p *AgentPool) get(id string) *remoteAgent {
@@ -196,50 +238,25 @@ func (p *AgentPool) PickChat() *remoteAgent {
// DispatchCommand sends a command to an agent and returns a channel for the result.
func (p *AgentPool) DispatchCommand(agentID, taskID, command string) (<-chan taskResult, error) {
- return p.dispatch(agentID, taskID, "exec", command)
+ return p.dispatchPayload(agentID, taskID, "exec", command, nil)
}
// DispatchChat sends a natural-language prompt to an LLM-capable agent.
func (p *AgentPool) DispatchChat(agentID, taskID, prompt string) (<-chan taskResult, error) {
- return p.DispatchChatSession(agentID, taskID, "", prompt)
+ return p.DispatchChatSession(agentID, taskID, "", prompt, webproto.ChatPayload{})
}
// DispatchChatSession sends chat input to an agent and scopes the remote
-// agent-side conversation state to the web chat session.
-func (p *AgentPool) DispatchChatSession(agentID, taskID, sessionID, prompt string) (<-chan taskResult, error) {
- var payload json.RawMessage
- if sessionID != "" {
- payload = mustJSON(map[string]string{"session_id": sessionID})
- }
- return p.dispatchPayload(agentID, taskID, "chat", prompt, payload)
-}
-
-func (p *AgentPool) dispatch(agentID, taskID, typ, data string) (<-chan taskResult, error) {
- return p.dispatchPayload(agentID, taskID, typ, data, nil)
+// agent-side conversation state to the web chat session. Goal-mode controls in
+// opts (persist / eval criteria / turn caps) ride along so the agent can run
+// the evaluator loop instead of a plain single-shot turn.
+func (p *AgentPool) DispatchChatSession(agentID, taskID, sessionID, prompt string, opts webproto.ChatPayload) (<-chan taskResult, error) {
+ opts.SessionID = sessionID
+ return p.dispatchPayload(agentID, taskID, "chat", prompt, mustJSON(opts))
}
func (p *AgentPool) dispatchPayload(agentID, taskID, typ, data string, payload json.RawMessage) (<-chan taskResult, error) {
- a := p.get(agentID)
- if a == nil {
- return nil, fmt.Errorf("agent %s not connected", agentID)
- }
- ch := make(chan taskResult, 1)
- a.mu.Lock()
- a.tasks[taskID] = ch
- a.turns[taskID] = 0
- a.mu.Unlock()
-
- select {
- case a.sendCh <- WSMessage{Type: typ, TaskID: taskID, Data: data, Payload: payload}:
- default:
- a.mu.Lock()
- delete(a.tasks, taskID)
- delete(a.turns, taskID)
- a.mu.Unlock()
- close(ch)
- return nil, fmt.Errorf("agent %s send channel full", agentID)
- }
- return ch, nil
+ return p.dispatchMessage(agentID, taskID, WSMessage{Type: typ, TaskID: taskID, Data: data, Payload: payload})
}
func (p *AgentPool) dispatchMessage(agentID, taskID string, msg WSMessage) (<-chan taskResult, error) {
@@ -266,6 +283,24 @@ func (p *AgentPool) dispatchMessage(agentID, taskID string, msg WSMessage) (<-ch
return ch, nil
}
+// BroadcastConfigReload notifies every connected agent that the hub config
+// changed so each re-fetches and hot-swaps its LLM provider without a restart.
+// Best-effort: an agent whose send channel is full picks the change up on its
+// next reconnect. Returns the number of agents notified.
+func (p *AgentPool) BroadcastConfigReload() int {
+ p.mu.RLock()
+ defer p.mu.RUnlock()
+ n := 0
+ for _, a := range p.agents {
+ select { // non-blocking, so safe to send under the read lock
+ case a.sendCh <- WSMessage{Type: "config"}:
+ n++
+ default:
+ }
+ }
+ return n
+}
+
func (p *AgentPool) SendAgentMessage(agentID string, msg WSMessage) error {
a := p.get(agentID)
if a == nil {
@@ -299,7 +334,7 @@ func (p *AgentPool) HandleTerminalWS(agentID string, w http.ResponseWriter, r *h
return
}
- conn, err := p.upgrader().Upgrade(w, r, nil)
+ conn, err := p.upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
@@ -407,12 +442,11 @@ func (p *AgentPool) forwardPTYMessage(msg WSMessage) bool {
// --- WebSocket handler ---
-func (p *AgentPool) upgrader() *websocket.Upgrader {
- if len(p.allowedOrigins) == 0 {
- return &websocket.Upgrader{}
+func buildUpgrader(origins []string) websocket.Upgrader {
+ if len(origins) == 0 {
+ return websocket.Upgrader{}
}
- origins := p.allowedOrigins
- return &websocket.Upgrader{
+ return websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
origin := r.Header.Get("Origin")
for _, o := range origins {
@@ -428,7 +462,7 @@ func (p *AgentPool) upgrader() *websocket.Upgrader {
// HandleWS upgrades to WebSocket and manages the agent lifecycle.
// This single endpoint replaces register + stream + output + complete.
func (p *AgentPool) HandleWS(w http.ResponseWriter, r *http.Request) {
- conn, err := p.upgrader().Upgrade(w, r, nil)
+ conn, err := p.upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
@@ -443,26 +477,31 @@ func (p *AgentPool) HandleWS(w http.ResponseWriter, r *http.Request) {
if reg.Payload != nil {
_ = json.Unmarshal(reg.Payload, &info)
}
+ // Resolve the stable pool key from the raw payload before the display-name
+ // default below, so an anonymous client still gets a unique per-connection id
+ // instead of every nameless agent colliding on the literal "agent".
+ id := agentKey(info)
if info.Name == "" {
info.Name = "agent"
}
agent := &remoteAgent{
- id: generateID(),
- name: info.Name,
- commands: info.Commands,
- conn: conn,
- sendCh: make(chan WSMessage, 32),
- connectAt: time.Now(),
- identity: info.Identity,
- stats: info.Stats,
- tasks: make(map[string]chan taskResult),
- turns: make(map[string]int),
- done: make(chan struct{}),
+ id: id,
+ name: info.Name,
+ commands: info.Commands,
+ commandsMenu: info.CommandsMenu,
+ conn: conn,
+ sendCh: make(chan WSMessage, 32),
+ connectAt: time.Now(),
+ identity: info.Identity,
+ stats: info.Stats,
+ tasks: make(map[string]chan taskResult),
+ turns: make(map[string]int),
+ done: make(chan struct{}),
}
p.register(agent)
defer func() {
- p.unregister(agent.id)
+ p.unregister(agent)
conn.Close()
close(agent.done)
}()
@@ -518,9 +557,26 @@ func (p *AgentPool) handleAgentMessage(a *remoteAgent, msg WSMessage) {
a.mu.Unlock()
}
+ case "agent.identity":
+ // Sent by the agent after a config hot-reload so the pooled identity — and
+ // thus the UI's provider/model badge — tracks the swapped provider instead
+ // of the value captured once at registration. Merge only the fields that a
+ // reload can change; never clobber NodeName/PID/host set at register time.
+ var id webproto.AgentIdentity
+ if len(msg.Payload) > 0 && json.Unmarshal(msg.Payload, &id) == nil {
+ a.mu.Lock()
+ if id.Provider != "" {
+ a.identity.Provider = id.Provider
+ }
+ if id.Model != "" {
+ a.identity.Model = id.Model
+ }
+ a.mu.Unlock()
+ }
+
case "output":
if p.hub != nil && msg.TaskID != "" {
- data := stripANSI(msg.Data)
+ data := output.StripANSI(msg.Data)
if data == "" {
return
}
@@ -622,13 +678,14 @@ func (p *AgentPool) forwardAgentEvent(a *remoteAgent, msg WSMessage) {
return
}
- turn := agentEventTurn(msg.Payload)
+ data := extractEventData(msg.Payload)
+ turn := turnFromEventData(data)
var event ChatEvent
switch msg.Type {
case "agent.turn_start":
event = ChatEvent{Type: ChatEventThinking, Turn: turn, Transient: true}
case "agent.message_start":
- role, content, _, ok := agentMessageFromPayload(msg.Payload)
+ role, content, _, ok := messageFromEventData(data)
if !ok || role != "assistant" {
return
}
@@ -639,7 +696,7 @@ func (p *AgentPool) forwardAgentEvent(a *remoteAgent, msg WSMessage) {
Turn: turn,
}
case "agent.message_update":
- role, content, reasoning, ok := agentMessageFromPayload(msg.Payload)
+ role, content, reasoning, ok := messageFromEventData(data)
if !ok || role != "assistant" {
return
}
@@ -662,7 +719,7 @@ func (p *AgentPool) forwardAgentEvent(a *remoteAgent, msg WSMessage) {
Turn: turn,
}
case "agent.message_end":
- role, content, reasoning, ok := agentMessageFromPayload(msg.Payload)
+ role, content, reasoning, ok := messageFromEventData(data)
if !ok || role != "assistant" {
return
}
@@ -690,7 +747,7 @@ func (p *AgentPool) forwardAgentEvent(a *remoteAgent, msg WSMessage) {
Arguments string `json:"arguments"`
Turn int `json:"turn"`
}
- if data := extractEventData(msg.Payload); len(data) > 0 {
+ if len(data) > 0 {
_ = json.Unmarshal(data, &ev)
}
if ev.Turn != 0 {
@@ -709,7 +766,7 @@ func (p *AgentPool) forwardAgentEvent(a *remoteAgent, msg WSMessage) {
Result string `json:"result"`
Turn int `json:"turn"`
}
- if data := extractEventData(msg.Payload); len(data) > 0 {
+ if len(data) > 0 {
_ = json.Unmarshal(data, &ev)
}
if ev.Turn != 0 {
@@ -721,6 +778,45 @@ func (p *AgentPool) forwardAgentEvent(a *remoteAgent, msg WSMessage) {
Content: ev.Result,
Turn: turn,
}
+ case "agent.eval_end", "agent.eval_error":
+ // Goal-mode per-round verdict from the evaluator loop. eval_start is a
+ // transient "judging…" marker with no verdict, so only the end/error
+ // events carry something worth showing. A judge error surfaces as a
+ // not-passed note with its message as the reason.
+ var ev struct {
+ EvalRound int `json:"eval_round"`
+ EvalPass bool `json:"eval_pass"`
+ EvalReason string `json:"eval_reason"`
+ EvalError string `json:"eval_error"`
+ }
+ if len(data) > 0 {
+ _ = json.Unmarshal(data, &ev)
+ }
+ reason := ev.EvalReason
+ if msg.Type == "agent.eval_error" {
+ reason = ev.EvalError
+ }
+ event = ChatEvent{
+ Type: ChatEventEval,
+ EvalRound: ev.EvalRound,
+ EvalPass: ev.EvalPass,
+ EvalReason: reason,
+ }
+ case "agent.compact_end", "agent.compact_error":
+ var ev struct {
+ CompactTokensBefore int `json:"compact_tokens_before"`
+ CompactTokensAfter int `json:"compact_tokens_after"`
+ CompactKeptMessages int `json:"compact_kept_messages"`
+ }
+ if len(data) > 0 {
+ _ = json.Unmarshal(data, &ev)
+ }
+ event = ChatEvent{
+ Type: ChatEventCompact,
+ CompactTokensBefore: ev.CompactTokensBefore,
+ CompactTokensAfter: ev.CompactTokensAfter,
+ CompactKeptMessages: ev.CompactKeptMessages,
+ }
default:
return
}
@@ -751,8 +847,8 @@ func extractEventData(payload json.RawMessage) json.RawMessage {
return payload
}
-func agentEventTurn(payload json.RawMessage) int {
- data := extractEventData(payload)
+// turnFromEventData extracts the turn number from pre-extracted event data.
+func turnFromEventData(data json.RawMessage) int {
if len(data) == 0 {
return 0
}
@@ -763,8 +859,8 @@ func agentEventTurn(payload json.RawMessage) int {
return event.Turn
}
-func agentMessageFromPayload(payload json.RawMessage) (role, content, reasoning string, ok bool) {
- data := extractEventData(payload)
+// messageFromEventData extracts role, content, and reasoning from pre-extracted event data.
+func messageFromEventData(data json.RawMessage) (role, content, reasoning string, ok bool) {
if len(data) == 0 {
return "", "", "", false
}
diff --git a/pkg/web/agents_test.go b/pkg/web/agents_test.go
index a3f6479f..282be188 100644
--- a/pkg/web/agents_test.go
+++ b/pkg/web/agents_test.go
@@ -89,6 +89,53 @@ func TestWSRegisterAndList(t *testing.T) {
}
}
+// waitAgents polls until the pool holds exactly want agents, so disconnect
+// detection (which fires when the server read loop errors) doesn't race the
+// assertions the way a fixed sleep would.
+func waitAgents(t *testing.T, pool *AgentPool, want int) {
+ t.Helper()
+ deadline := time.Now().Add(2 * time.Second)
+ for time.Now().Before(deadline) {
+ if pool.Count() == want {
+ return
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+ t.Fatalf("agent count did not reach %d (got %d)", want, pool.Count())
+}
+
+// TestReconnectKeepsStableID pins the source fix for the "Agent 未连接" bug: the
+// pool is keyed by the agent's stable node identity, so a reconnect returns the
+// SAME agent id instead of a fresh throwaway. A chat session freezes that id at
+// creation; if it changed on every reconnect the stored id would resolve to
+// nothing and the chat would reject every message as "not connected" even with
+// the agent back. Also guards that the reconnect evicts the stale slot (Count
+// stays 1) rather than leaking a second entry under the same key.
+func TestReconnectKeepsStableID(t *testing.T) {
+ srv, pool := setupTestServer(t)
+
+ conn1 := dialAgent(t, srv, "stable-agent", []string{"scan"})
+ waitAgents(t, pool, 1)
+ id1 := pool.List()[0].ID
+
+ // Drop the connection and let the hub observe the disconnect.
+ conn1.Close()
+ waitAgents(t, pool, 0)
+
+ // Same node reconnects — new socket, new instance, same node name.
+ conn2 := dialAgent(t, srv, "stable-agent", []string{"scan"})
+ defer conn2.Close()
+ waitAgents(t, pool, 1)
+ id2 := pool.List()[0].ID
+
+ if id1 != id2 {
+ t.Fatalf("agent id changed across reconnect: %q -> %q (session binding would dangle)", id1, id2)
+ }
+ if pool.get(id1) == nil {
+ t.Fatalf("agent not resolvable by its pre-reconnect id %q", id1)
+ }
+}
+
func TestWSDispatchAndComplete(t *testing.T) {
srv, pool := setupTestServer(t)
conn := dialAgent(t, srv, "worker", []string{"scan"})
@@ -172,6 +219,61 @@ func TestWSDispatchChatUsesChatMessage(t *testing.T) {
}
}
+// TestDispatchChatSessionCarriesGoalOptions guards the Goal-mode wiring: the
+// eval criteria and round budget must survive into the WS chat payload so the
+// agent can run the evaluator loop. This whole channel was silently dropped
+// once (SendMessageRequest{Content} only), leaving the Goal panel a dead
+// control — this test fails loudly if that regresses.
+func TestDispatchChatSessionCarriesGoalOptions(t *testing.T) {
+ srv, pool := setupTestServer(t)
+ conn := dialAgentWithIdentity(t, srv, "goal-worker", []string{"scan"}, webproto.AgentIdentity{
+ NodeID: "node-goal-worker",
+ NodeName: "goal-worker",
+ Provider: "openai",
+ Model: "test-model",
+ })
+ defer conn.Close()
+
+ time.Sleep(50 * time.Millisecond)
+ agent := pool.PickChat()
+ if agent == nil {
+ t.Fatal("expected chat-capable agent")
+ }
+
+ opts := webproto.ChatPayload{EvalCriteria: "find at least one SQLi", EvalMaxRounds: 5}
+ resultCh, err := pool.DispatchChatSession(agent.id, "task-goal", "sess-1", "audit target", opts)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ var cmd WSMessage
+ if err := conn.ReadJSON(&cmd); err != nil {
+ t.Fatal(err)
+ }
+ if cmd.Type != "chat" || cmd.Data != "audit target" {
+ t.Fatalf("unexpected message: %+v", cmd)
+ }
+ var payload webproto.ChatPayload
+ if err := json.Unmarshal(cmd.Payload, &payload); err != nil {
+ t.Fatalf("decode chat payload: %v (raw=%s)", err, cmd.Payload)
+ }
+ if payload.SessionID != "sess-1" {
+ t.Errorf("session_id = %q, want sess-1", payload.SessionID)
+ }
+ if payload.EvalCriteria != "find at least one SQLi" {
+ t.Errorf("eval_criteria = %q, want it to reach the agent", payload.EvalCriteria)
+ }
+ if payload.EvalMaxRounds != 5 {
+ t.Errorf("eval_max_rounds = %d, want 5", payload.EvalMaxRounds)
+ }
+ conn.WriteJSON(WSMessage{Type: "complete", TaskID: "task-goal", Data: "ok"})
+ select {
+ case <-resultCh:
+ case <-time.After(time.Second):
+ t.Fatal("timeout")
+ }
+}
+
func TestHandleFileUploadPersistsSystemMessage(t *testing.T) {
store, err := NewSQLiteStore(filepath.Join(t.TempDir(), "web.db"))
if err != nil {
@@ -183,7 +285,7 @@ func TestHandleFileUploadPersistsSystemMessage(t *testing.T) {
pool := NewAgentPool(svc.Hub())
svc.SetAgentPool(pool)
- srv := httptest.NewServer(NewHandler(svc, pool, nil, nil))
+ srv := httptest.NewServer(NewHandler(svc, pool, nil, nil, nil, ""))
defer srv.Close()
conn := dialAgentWithIdentity(t, srv, "upload-agent", []string{"scan"}, webproto.AgentIdentity{
@@ -262,6 +364,18 @@ func TestHandleFileUploadPersistsSystemMessage(t *testing.T) {
if msgs[0].Role != "system" || !strings.Contains(msgs[0].Content, "File uploaded: note.txt") || !strings.Contains(msgs[0].Content, result.Path) {
t.Fatalf("unexpected persisted upload message: %+v", msgs[0])
}
+ // The English Content is only a fallback; the localizable contract lives in
+ // Metadata as {code, params} so the message stays translatable after reload.
+ var meta struct {
+ Code string `json:"code"`
+ Params map[string]string `json:"params"`
+ }
+ if err := json.Unmarshal(msgs[0].Metadata, &meta); err != nil {
+ t.Fatalf("decode system message metadata: %v", err)
+ }
+ if meta.Code != SysFileUploaded || meta.Params["filename"] != "note.txt" || meta.Params["path"] != result.Path {
+ t.Fatalf("unexpected system message metadata: %+v", meta)
+ }
}
func TestWSPickChatIgnoresAgentsWithoutProvider(t *testing.T) {
diff --git a/pkg/web/auth.go b/pkg/web/auth.go
new file mode 100644
index 00000000..ec4abaa3
--- /dev/null
+++ b/pkg/web/auth.go
@@ -0,0 +1,33 @@
+package web
+
+import (
+ "net/http"
+ "strings"
+)
+
+// AccessKeyAuth returns middleware that gates requests behind a Bearer token.
+// Requests without a valid token get a 401. An empty key disables auth (dev mode).
+func AccessKeyAuth(key string) func(http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ if key == "" {
+ return next
+ }
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Skip auth for health check, static SPA, and IOA (has its own auth)
+ if r.URL.Path == "/health" || !strings.HasPrefix(r.URL.Path, "/api/") {
+ next.ServeHTTP(w, r)
+ return
+ }
+ // Accept token from: Authorization: Bearer , or ?access_key=
+ token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
+ if token == "" {
+ token = r.URL.Query().Get("access_key")
+ }
+ if strings.TrimSpace(token) != key {
+ writeError(w, http.StatusUnauthorized, "invalid or missing access key")
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
+ }
+}
diff --git a/pkg/web/command_test.go b/pkg/web/command_test.go
new file mode 100644
index 00000000..801abf0e
--- /dev/null
+++ b/pkg/web/command_test.go
@@ -0,0 +1,136 @@
+package web
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/chainreactors/aiscan/pkg/webproto"
+)
+
+func TestParseCommand(t *testing.T) {
+ cases := []struct {
+ name string
+ in string
+ wantCmd string
+ wantArg string
+ wantOK bool
+ }{
+ {"scan with target", "/scan example.com", "scan", "example.com", true},
+ {"scan with flags", "/scan example.com --mode full --deep", "scan", "example.com --mode full --deep", true},
+ {"verb only", "/agents", "agents", "", true},
+ {"lowercased verb", "/SCAN Example.com", "scan", "Example.com", true},
+ {"extra spaces", "/scan a.com b.com", "scan", "a.com b.com", true},
+ {"tab separator", "/help\tx", "help", "x", true},
+ {"plain message", "hello there", "", "", false},
+ {"bare slash", "/", "", "", false},
+ {"slash then spaces", "/ ", "", "", false},
+ {"path-like, not a command", "/etc/passwd", "etc/passwd", "", true},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ cmd, arg, ok := parseCommand(tc.in)
+ if ok != tc.wantOK || cmd != tc.wantCmd || arg != tc.wantArg {
+ t.Fatalf("parseCommand(%q) = (%q, %q, %v), want (%q, %q, %v)",
+ tc.in, cmd, arg, ok, tc.wantCmd, tc.wantArg, tc.wantOK)
+ }
+ })
+ }
+}
+
+func newMenuTestService(t *testing.T) *Service {
+ t.Helper()
+ store, err := NewSQLiteStore(filepath.Join(t.TempDir(), "web.db"))
+ if err != nil {
+ t.Fatalf("NewSQLiteStore() error = %v", err)
+ }
+ t.Cleanup(func() { _ = store.Close() })
+ return NewService(ServiceConfig{Store: store})
+}
+
+// TestSessionMenuMergeAndFallback checks the "/" menu the hub serves: hub-scope
+// commands merged with the agent's (here, the static fallback since no agent is
+// bound), with run-control commands excluded.
+func TestSessionMenuMergeAndFallback(t *testing.T) {
+ svc := newMenuTestService(t)
+ names := map[string]bool{}
+ for _, s := range svc.SessionMenu("no-such-session") {
+ names[s.Name] = true
+ }
+ for _, want := range []string{"/scan", "/agents", "/help", "/status", "/provider", "/model"} {
+ if !names[want] {
+ t.Errorf("SessionMenu missing %q", want)
+ }
+ }
+ for _, absent := range []string{"/stop", "/continue", "/eval", "/followup", "/loop"} {
+ if names[absent] {
+ t.Errorf("SessionMenu leaked run-control command %q", absent)
+ }
+ }
+}
+
+// TestClearCommandWipesTranscript verifies web /clear is a true "clear
+// conversation": the session's persisted messages are deleted (so a reload stays
+// empty), not merely the agent's model context. No agent is bound here, so the
+// path is store-wipe + UI signal only.
+func TestClearCommandWipesTranscript(t *testing.T) {
+ svc := newMenuTestService(t)
+ ctx := context.Background()
+ sid := "sess-clear"
+ for _, role := range []string{"user", "assistant", "user"} {
+ err := svc.store.AddMessage(ctx, &ChatMessage{
+ ID: generateID(), SessionID: sid, Role: role, Content: "x", CreatedAt: time.Now(),
+ })
+ if err != nil {
+ t.Fatalf("AddMessage: %v", err)
+ }
+ }
+ if msgs, _ := svc.GetMessages(ctx, sid); len(msgs) != 3 {
+ t.Fatalf("setup: got %d messages, want 3", len(msgs))
+ }
+
+ svc.handleClearCommand(sid, webproto.ChatPayload{})
+
+ msgs, err := svc.GetMessages(ctx, sid)
+ if err != nil {
+ t.Fatalf("GetMessages: %v", err)
+ }
+ if len(msgs) != 0 {
+ t.Errorf("after /clear: got %d messages, want 0", len(msgs))
+ }
+}
+
+// TestSessionCommandsRoute drives the real HTTP endpoint the frontend "/" menu
+// fetches, proving the route is wired and returns a JSON slash-command catalog.
+func TestSessionCommandsRoute(t *testing.T) {
+ svc := newMenuTestService(t)
+ srv := httptest.NewServer(NewHandler(svc, nil, nil, nil, nil, ""))
+ defer srv.Close()
+
+ resp, err := http.Get(srv.URL + "/api/chat/sessions/anything/commands")
+ if err != nil {
+ t.Fatalf("GET /commands: %v", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("status = %d, want 200", resp.StatusCode)
+ }
+
+ var specs []webproto.CommandSpec
+ if err := json.NewDecoder(resp.Body).Decode(&specs); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ names := map[string]bool{}
+ for _, s := range specs {
+ names[s.Name] = true
+ }
+ for _, want := range []string{"/scan", "/help", "/status", "/model"} {
+ if !names[want] {
+ t.Errorf("/commands response missing %q (got %d specs)", want, len(specs))
+ }
+ }
+}
diff --git a/pkg/web/config_reload_test.go b/pkg/web/config_reload_test.go
new file mode 100644
index 00000000..3fbeb38f
--- /dev/null
+++ b/pkg/web/config_reload_test.go
@@ -0,0 +1,67 @@
+package web
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/chainreactors/aiscan/pkg/webproto"
+)
+
+func newFakeAgent(id string, buf int) *remoteAgent {
+ return &remoteAgent{
+ id: id,
+ name: id,
+ sendCh: make(chan WSMessage, buf),
+ tasks: make(map[string]chan taskResult),
+ turns: make(map[string]int),
+ done: make(chan struct{}),
+ }
+}
+
+// TestBroadcastConfigReload covers both branches: an open agent gets a "config"
+// notification; an agent with a full send buffer is skipped, not blocked on.
+func TestBroadcastConfigReload(t *testing.T) {
+ pool := NewAgentPool(nil)
+ open := newFakeAgent("open", 1)
+ full := newFakeAgent("full", 1)
+ full.sendCh <- WSMessage{Type: "exec"} // saturate the buffer
+ pool.register(open)
+ pool.register(full)
+
+ if n := pool.BroadcastConfigReload(); n != 1 {
+ t.Fatalf("notified = %d, want 1 (full channel skipped)", n)
+ }
+ select {
+ case msg := <-open.sendCh:
+ if msg.Type != "config" {
+ t.Fatalf("open agent got %q, want config", msg.Type)
+ }
+ default:
+ t.Fatal("open agent got no config message")
+ }
+}
+
+// TestHandleAgentIdentityUpdate covers the post-hot-reload identity re-announce:
+// the agent's swapped provider/model reach the pool (so the UI badge tracks the
+// live model), while the register-time identity fields (NodeName/PID/host) are
+// preserved rather than clobbered by the partial update.
+func TestHandleAgentIdentityUpdate(t *testing.T) {
+ pool := NewAgentPool(nil)
+ a := newFakeAgent("n1", 1)
+ a.identity = webproto.AgentIdentity{NodeName: "local-1", PID: 4242, Provider: "anthropic", Model: "old-model"}
+ pool.register(a)
+
+ payload, _ := json.Marshal(webproto.AgentIdentity{Provider: "anthropic", Model: "glm-5.2"})
+ pool.handleAgentMessage(a, WSMessage{Type: "agent.identity", Payload: payload})
+
+ got := a.info().Identity
+ if got.Model != "glm-5.2" {
+ t.Errorf("Model = %q, want glm-5.2 (identity should track the hot-reload)", got.Model)
+ }
+ if got.Provider != "anthropic" {
+ t.Errorf("Provider = %q, want anthropic", got.Provider)
+ }
+ if got.NodeName != "local-1" || got.PID != 4242 {
+ t.Errorf("register-time identity clobbered: NodeName=%q PID=%d", got.NodeName, got.PID)
+ }
+}
diff --git a/pkg/web/conn_probe_test.go b/pkg/web/conn_probe_test.go
new file mode 100644
index 00000000..f7c91d5d
--- /dev/null
+++ b/pkg/web/conn_probe_test.go
@@ -0,0 +1,254 @@
+package web
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/chainreactors/aiscan/pkg/agent/probe"
+ "github.com/chainreactors/aiscan/pkg/webproto"
+)
+
+type cfgT = webproto.DistributeConfig
+
+// configWith builds a DistributeConfig, letting each test set only the fields
+// it cares about. Pass nil for an empty config.
+func configWith(fn func(*cfgT)) cfgT {
+ var c cfgT
+ if fn != nil {
+ fn(&c)
+ }
+ return c
+}
+
+func newService(store ConfigStore) *Service {
+ return NewService(ServiceConfig{ConfigStore: store})
+}
+
+func findCheck(checks []probe.ConnCheck, name string) (probe.ConnCheck, bool) {
+ for _, c := range checks {
+ if c.Name == name {
+ return c, true
+ }
+ }
+ return probe.ConnCheck{}, false
+}
+
+func TestTestConnUnknownSection(t *testing.T) {
+ svc := newService(&fakeConfigStore{})
+ if _, err := svc.TestConn(context.Background(), "agent", configWith(nil)); err == nil {
+ t.Fatal("expected error for untestable section")
+ }
+}
+
+func TestProbeCyberhubSuccess(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if !strings.HasPrefix(r.URL.Path, "/api/v1/fingerprints/export") {
+ http.NotFound(w, r)
+ return
+ }
+ if r.Header.Get("X-API-Key") != "hub-key" {
+ w.WriteHeader(http.StatusUnauthorized)
+ return
+ }
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "code": 0, "message": "ok",
+ "data": map[string]any{"fingerprints": []any{map[string]any{"name": "tomcat"}}, "total": 1},
+ })
+ }))
+ defer srv.Close()
+
+ svc := newService(&fakeConfigStore{})
+ cfg := configWith(func(c *cfgT) { c.Cyberhub.URL = srv.URL; c.Cyberhub.Key = "hub-key" })
+ resp, err := svc.TestConn(context.Background(), "cyberhub", cfg)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if c, ok := findCheck(resp, "cyberhub"); !ok || !c.OK {
+ t.Fatalf("expected cyberhub ok, got %+v", resp)
+ }
+}
+
+func TestProbeCyberhubAuthError(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusUnauthorized)
+ _, _ = w.Write([]byte("bad key"))
+ }))
+ defer srv.Close()
+
+ svc := newService(&fakeConfigStore{})
+ cfg := configWith(func(c *cfgT) { c.Cyberhub.URL = srv.URL; c.Cyberhub.Key = "nope" })
+ resp, err := svc.TestConn(context.Background(), "cyberhub", cfg)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if c, _ := findCheck(resp, "cyberhub"); c.OK {
+ t.Fatal("expected cyberhub failure, got ok")
+ }
+}
+
+func TestProbeFofaSuccessAndStoredKeyFallback(t *testing.T) {
+ var gotKey string
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotKey = r.URL.Query().Get("key")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "error": false, "username": "alice", "email": "a@b.c", "fofa_point": 4200,
+ })
+ }))
+ defer srv.Close()
+ orig := probe.FofaInfoEndpoint
+ probe.FofaInfoEndpoint = srv.URL
+ defer func() { probe.FofaInfoEndpoint = orig }()
+
+ // FOFA key left blank in the request: the stored secret must be used.
+ store := &fakeConfigStore{}
+ store.cfg.Recon.FofaKey = "stored-fofa"
+ svc := newService(store)
+
+ resp, err := svc.TestConn(context.Background(), "recon", configWith(nil))
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ c, ok := findCheck(resp, "fofa")
+ if !ok || !c.OK {
+ t.Fatalf("expected fofa ok, got %+v", resp)
+ }
+ if gotKey != "stored-fofa" {
+ t.Fatalf("expected stored key, server saw %q", gotKey)
+ }
+ if !strings.Contains(c.Detail, "alice") {
+ t.Fatalf("expected username in detail, got %q", c.Detail)
+ }
+}
+
+func TestProbeFofaError(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _ = json.NewEncoder(w).Encode(map[string]any{"error": true, "errmsg": "[-700] account invalid"})
+ }))
+ defer srv.Close()
+ orig := probe.FofaInfoEndpoint
+ probe.FofaInfoEndpoint = srv.URL
+ defer func() { probe.FofaInfoEndpoint = orig }()
+
+ svc := newService(&fakeConfigStore{})
+ resp, _ := svc.TestConn(context.Background(), "recon", configWith(func(c *cfgT) { c.Recon.FofaKey = "bad" }))
+ c, ok := findCheck(resp, "fofa")
+ if !ok || c.OK {
+ t.Fatalf("expected fofa failure, got %+v", resp)
+ }
+ if !strings.Contains(c.Error, "account invalid") {
+ t.Fatalf("expected errmsg surfaced, got %q", c.Error)
+ }
+}
+
+func TestProbeHunterSuccess(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Query().Get("api-key") == "" {
+ w.WriteHeader(http.StatusBadRequest)
+ return
+ }
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "code": 200, "message": "success", "data": map[string]any{"total": 7},
+ })
+ }))
+ defer srv.Close()
+ orig := probe.HunterSearchEndpoint
+ probe.HunterSearchEndpoint = srv.URL
+ defer func() { probe.HunterSearchEndpoint = orig }()
+
+ svc := newService(&fakeConfigStore{})
+ resp, _ := svc.TestConn(context.Background(), "recon", configWith(func(c *cfgT) { c.Recon.HunterAPIKey = "hk" }))
+ if c, ok := findCheck(resp, "hunter"); !ok || !c.OK {
+ t.Fatalf("expected hunter ok, got %+v", resp)
+ }
+}
+
+func TestProbeHunterError(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _ = json.NewEncoder(w).Encode(map[string]any{"code": 401, "message": "invalid api-key"})
+ }))
+ defer srv.Close()
+ orig := probe.HunterSearchEndpoint
+ probe.HunterSearchEndpoint = srv.URL
+ defer func() { probe.HunterSearchEndpoint = orig }()
+
+ svc := newService(&fakeConfigStore{})
+ resp, _ := svc.TestConn(context.Background(), "recon", configWith(func(c *cfgT) { c.Recon.HunterToken = "bad" }))
+ c, ok := findCheck(resp, "hunter")
+ if !ok || c.OK {
+ t.Fatalf("expected hunter failure, got %+v", resp)
+ }
+ if !strings.Contains(c.Error, "invalid api-key") {
+ t.Fatalf("expected hunter message surfaced, got %q", c.Error)
+ }
+}
+
+func TestReconNoCredentials(t *testing.T) {
+ svc := newService(&fakeConfigStore{})
+ resp, _ := svc.TestConn(context.Background(), "recon", configWith(nil))
+ if c, ok := findCheck(resp, "recon"); !ok || c.OK || c.Error == "" {
+ t.Fatalf("expected a single failing recon check, got %+v", resp)
+ }
+}
+
+func TestHandlerTestConnRouting(t *testing.T) {
+ svc := newService(&fakeConfigStore{})
+ srv := httptest.NewServer(NewHandler(svc, nil, nil, nil, nil, ""))
+ defer srv.Close()
+
+ // The {section} wildcard must coexist with the static /llm/test route and
+ // dispatch to testConn. Empty config yields a failing check but a 200
+ // response, proving routing + dispatch worked.
+ resp, err := http.Post(srv.URL+"/api/config/cyberhub/test", "application/json", strings.NewReader("{}"))
+ if err != nil {
+ t.Fatalf("post: %v", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("expected 200, got %d", resp.StatusCode)
+ }
+ var out []probe.ConnCheck
+ if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if len(out) != 1 || out[0].Name != "cyberhub" {
+ t.Fatalf("expected one cyberhub check, got %+v", out)
+ }
+
+ // An untestable section is rejected with 400.
+ resp2, err := http.Post(srv.URL+"/api/config/agent/test", "application/json", strings.NewReader("{}"))
+ if err != nil {
+ t.Fatalf("post: %v", err)
+ }
+ defer resp2.Body.Close()
+ if resp2.StatusCode != http.StatusBadRequest {
+ t.Fatalf("expected 400 for untestable section, got %d", resp2.StatusCode)
+ }
+}
+
+func TestProbeIOASuccess(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/spaces" {
+ http.NotFound(w, r)
+ return
+ }
+ _ = json.NewEncoder(w).Encode([]map[string]any{{"id": "1", "name": "default", "nodes": []any{}}})
+ }))
+ defer srv.Close()
+
+ svc := newService(&fakeConfigStore{})
+ resp, err := svc.TestConn(context.Background(), "ioa", configWith(func(c *cfgT) { c.IOA.URL = srv.URL; c.IOA.Token = "t" }))
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ c, ok := findCheck(resp, "ioa")
+ if !ok || !c.OK {
+ t.Fatalf("expected ioa ok, got %+v", resp)
+ }
+ if !strings.Contains(c.Detail, "1 space") {
+ t.Fatalf("expected space count in detail, got %q", c.Detail)
+ }
+}
diff --git a/pkg/web/eval_forward_test.go b/pkg/web/eval_forward_test.go
new file mode 100644
index 00000000..c6600f55
--- /dev/null
+++ b/pkg/web/eval_forward_test.go
@@ -0,0 +1,108 @@
+package web
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/chainreactors/aiscan/core/output"
+ "github.com/chainreactors/aiscan/pkg/agent"
+)
+
+// evalSink is a minimal SessionLookup that maps every task to one session and
+// records the ChatEvents forwarded to it.
+type evalSink struct {
+ sid string
+ events []ChatEvent
+}
+
+func (s *evalSink) TaskSession(taskID string) (string, bool) { return s.sid, true }
+func (s *evalSink) BroadcastChatEvent(sessionID string, event ChatEvent) {
+ s.events = append(s.events, event)
+}
+
+func agentEventPayload(t *testing.T, ev agent.Event) json.RawMessage {
+ t.Helper()
+ // Build the WS payload exactly as the agent does (webagent/agent.go): a
+ // Record wrapping the Event, marshaled through Event.MarshalJSON. This makes
+ // the test fail if the marshaler ever drops the verdict fields again.
+ payload, err := json.Marshal(output.NewRecord(output.TypeAgent, ev))
+ if err != nil {
+ t.Fatalf("marshal record: %v", err)
+ }
+ return payload
+}
+
+// TestForwardAgentEventSurfacesEvalVerdict guards the Goal-mode eval badge
+// end-to-end through the hub: an agent.eval_end must reach the session SSE as a
+// ChatEventEval carrying the round/pass/reason. The whole evaluator→hub→SSE path
+// was silently dropped — Event.MarshalJSON omitted the verdict fields AND
+// forwardAgentEvent had no eval case — so the per-round verdict never rendered.
+func TestForwardAgentEventSurfacesEvalVerdict(t *testing.T) {
+ sink := &evalSink{sid: "sess-eval"}
+ pool := NewAgentPool(NewHub())
+ pool.SetSessionLookup(sink)
+ a := &remoteAgent{id: "agent-1", name: "worker", tasks: map[string]chan taskResult{}, turns: map[string]int{}}
+
+ pool.forwardAgentEvent(a, WSMessage{
+ Type: "agent.eval_end",
+ TaskID: "task-1",
+ Payload: agentEventPayload(t, agent.Event{Type: agent.EventEvalEnd, EvalRound: 1, EvalPass: true, EvalReason: "found SQLi"}),
+ })
+
+ if len(sink.events) != 1 {
+ t.Fatalf("want 1 forwarded event, got %d", len(sink.events))
+ }
+ got := sink.events[0]
+ if got.Type != ChatEventEval {
+ t.Fatalf("type = %q, want %q", got.Type, ChatEventEval)
+ }
+ if got.EvalRound != 1 || !got.EvalPass || got.EvalReason != "found SQLi" {
+ t.Fatalf("verdict not carried: round=%d pass=%v reason=%q", got.EvalRound, got.EvalPass, got.EvalReason)
+ }
+ if got.AgentID != "agent-1" {
+ t.Fatalf("agent id not stamped: %q", got.AgentID)
+ }
+}
+
+// A judge error is still a round marker: it surfaces as a not-passed verdict
+// with the error text as the reason, so the round boundary (and its badge) is
+// not silently lost.
+func TestForwardAgentEventEvalErrorBecomesReason(t *testing.T) {
+ sink := &evalSink{sid: "sess-eval"}
+ pool := NewAgentPool(NewHub())
+ pool.SetSessionLookup(sink)
+ a := &remoteAgent{id: "a", name: "w", tasks: map[string]chan taskResult{}, turns: map[string]int{}}
+
+ pool.forwardAgentEvent(a, WSMessage{
+ Type: "agent.eval_error",
+ TaskID: "task-1",
+ Payload: agentEventPayload(t, agent.Event{Type: agent.EventEvalError, EvalRound: 0, EvalError: "judge timed out"}),
+ })
+
+ if len(sink.events) != 1 {
+ t.Fatalf("want 1 forwarded event, got %d", len(sink.events))
+ }
+ got := sink.events[0]
+ if got.Type != ChatEventEval || got.EvalPass || got.EvalReason != "judge timed out" {
+ t.Fatalf("unexpected eval error event: %+v", got)
+ }
+}
+
+// eval_start is a transient "judging…" marker with no verdict; it must not
+// produce a badge (which would render as a bogus "round 1 · not passed").
+func TestForwardAgentEventEvalStartDropped(t *testing.T) {
+ sink := &evalSink{sid: "sess-eval"}
+ pool := NewAgentPool(NewHub())
+ pool.SetSessionLookup(sink)
+ a := &remoteAgent{id: "a", name: "w", tasks: map[string]chan taskResult{}, turns: map[string]int{}}
+
+ pool.forwardAgentEvent(a, WSMessage{
+ Type: "agent.eval_start",
+ TaskID: "task-1",
+ Payload: agentEventPayload(t, agent.Event{Type: agent.EventEvalStart, EvalRound: 0}),
+ })
+
+ if len(sink.events) != 0 {
+ t.Fatalf("eval_start should not forward, got %d events", len(sink.events))
+ }
+}
diff --git a/pkg/web/handler.go b/pkg/web/handler.go
index 797ea682..c918802a 100644
--- a/pkg/web/handler.go
+++ b/pkg/web/handler.go
@@ -4,19 +4,21 @@ import (
"encoding/json"
"io"
"net/http"
+ "strconv"
"strings"
+ "github.com/chainreactors/aiscan/pkg/agent/probe"
"github.com/chainreactors/aiscan/pkg/webproto"
)
type Handler struct {
- mux *http.ServeMux
+ handler http.Handler
}
-func NewHandler(service *Service, agents *AgentPool, ioaHandler http.Handler, static http.Handler) *Handler {
+func NewHandler(service *Service, agents *AgentPool, local *LocalAgents, ioaHandler http.Handler, static http.Handler, accessKey string) *Handler {
mux := http.NewServeMux()
- h := &handlerImpl{service: service, agents: agents}
+ h := &handlerImpl{service: service, agents: agents, accessKey: accessKey}
mux.HandleFunc("POST /api/scans", h.createScan)
mux.HandleFunc("GET /api/scans", h.listScans)
@@ -28,8 +30,18 @@ func NewHandler(service *Service, agents *AgentPool, ioaHandler http.Handler, st
mux.HandleFunc("GET /api/config", h.getConfig)
mux.HandleFunc("PUT /api/config", h.saveConfig)
mux.HandleFunc("GET /api/config/distribute", h.getDistributeConfig)
+ mux.HandleFunc("POST /api/config/llm/test", h.testLLM)
+ mux.HandleFunc("POST /api/config/llm/models", h.listLLMModels)
+ mux.HandleFunc("POST /api/config/{section}/test", h.testConn)
mux.HandleFunc("GET /api/agents", h.listAgents)
+ mux.HandleFunc("GET /api/sco/nodes", h.listSCONodes)
+ mux.HandleFunc("GET /api/sco/nodes/{id}", h.getSCONode)
+ mux.HandleFunc("GET /api/sco/stats", h.scoNodeStats)
+ mux.HandleFunc("DELETE /api/sco/nodes", h.deleteSCONodes)
+ mux.HandleFunc("POST /api/sco/import", h.importSCONodes)
+ mux.HandleFunc("GET /api/sco/artifacts", h.listSupportedArtifacts)
+
// Chat session routes
mux.HandleFunc("POST /api/chat/sessions", h.createSession)
mux.HandleFunc("GET /api/chat/sessions", h.listSessions)
@@ -39,6 +51,7 @@ func NewHandler(service *Service, agents *AgentPool, ioaHandler http.Handler, st
mux.HandleFunc("POST /api/chat/sessions/{id}/cancel", h.cancelSession)
mux.HandleFunc("POST /api/chat/sessions/{id}/upload", h.uploadFile)
mux.HandleFunc("GET /api/chat/sessions/{id}/messages", h.listMessages)
+ mux.HandleFunc("GET /api/chat/sessions/{id}/commands", h.sessionCommands)
mux.HandleFunc("GET /api/chat/sessions/{id}/events", h.sessionEvents)
if agents != nil {
@@ -56,27 +69,30 @@ func NewHandler(service *Service, agents *AgentPool, ioaHandler http.Handler, st
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
})
+ registerLocalAgentRoutes(mux, local)
+
if static != nil {
mux.Handle("/", static)
}
- return &Handler{mux: mux}
+ return &Handler{handler: AccessKeyAuth(accessKey)(mux)}
}
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
- w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
+ w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
- h.mux.ServeHTTP(w, r)
+ h.handler.ServeHTTP(w, r)
}
type handlerImpl struct {
- service *Service
- agents *AgentPool
+ service *Service
+ agents *AgentPool
+ accessKey string
}
func (h *handlerImpl) serviceStatus(w http.ResponseWriter, r *http.Request) {
@@ -84,6 +100,17 @@ func (h *handlerImpl) serviceStatus(w http.ResponseWriter, r *http.Request) {
if h.agents != nil {
status.Agents = h.agents.Count()
}
+ if h.accessKey != "" {
+ host := r.Host
+ scheme := "http"
+ if r.TLS != nil {
+ scheme = "https"
+ }
+ if fwd := r.Header.Get("X-Forwarded-Proto"); fwd != "" {
+ scheme = fwd
+ }
+ status.IOAURL = scheme + "://" + h.accessKey + "@" + host + "/ioa"
+ }
writeJSON(w, http.StatusOK, status)
}
@@ -127,14 +154,52 @@ func (h *handlerImpl) getDistributeConfig(w http.ResponseWriter, r *http.Request
writeJSON(w, http.StatusOK, cfg)
}
+func (h *handlerImpl) testLLM(w http.ResponseWriter, r *http.Request) {
+ var req probe.LLMProbeRequest
+ if !decodeBody(w, r, &req) {
+ return
+ }
+ result, err := h.service.TestLLM(r.Context(), req)
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, result)
+}
+
+func (h *handlerImpl) listLLMModels(w http.ResponseWriter, r *http.Request) {
+ var req probe.LLMProbeRequest
+ if !decodeBody(w, r, &req) {
+ return
+ }
+ result, err := h.service.ListLLMModels(r.Context(), req)
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, result)
+}
+
+func (h *handlerImpl) testConn(w http.ResponseWriter, r *http.Request) {
+ var cfg webproto.DistributeConfig
+ if !decodeOptionalBody(w, r, &cfg) {
+ return
+ }
+ result, err := h.service.TestConn(r.Context(), r.PathValue("section"), cfg)
+ if err != nil {
+ writeError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, result)
+}
+
func (h *handlerImpl) createScan(w http.ResponseWriter, r *http.Request) {
var req ScanRequest
if err := decodeJSON(r.Body, &req); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
- verify, sniper, deep := req.AnalysisOptions()
- job, err := h.service.SubmitScan(r.Context(), req.Target, req.Mode, verify, sniper, deep)
+ job, err := h.service.SubmitScan(r.Context(), req.Target, req.Mode, req.Verify, req.Sniper, req.Deep)
if err != nil {
writeError(w, http.StatusUnprocessableEntity, err.Error())
return
@@ -181,7 +246,7 @@ func (h *handlerImpl) scanEvents(w http.ResponseWriter, r *http.Request) {
}
func (h *handlerImpl) scanReport(w http.ResponseWriter, r *http.Request) {
- report, err := h.service.GetReport(r.Context(), r.PathValue("id"))
+ report, err := h.service.GetReport(r.Context(), r.PathValue("id"), r.URL.Query().Get("lang"))
if err != nil {
writeError(w, http.StatusNotFound, "scan not found")
return
@@ -253,7 +318,9 @@ func (h *handlerImpl) sendMessage(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "content is required")
return
}
- msg, err := h.service.HandleUserMessage(r.Context(), r.PathValue("id"), req.Content)
+ opts := req.ChatPayload
+ opts.EvalCriteria = strings.TrimSpace(opts.EvalCriteria)
+ msg, err := h.service.HandleUserMessage(r.Context(), r.PathValue("id"), req.Content, opts)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
@@ -261,6 +328,14 @@ func (h *handlerImpl) sendMessage(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, msg)
}
+// sessionCommands returns the web "/" command menu for a session: hub-scope
+// commands merged with the bound agent's reported agent-scope commands (skills
+// included). The frontend renders its slash-command popup from this, so the menu
+// always reflects what actually works instead of a hand-maintained list.
+func (h *handlerImpl) sessionCommands(w http.ResponseWriter, r *http.Request) {
+ writeJSON(w, http.StatusOK, h.service.SessionMenu(r.PathValue("id")))
+}
+
func (h *handlerImpl) cancelSession(w http.ResponseWriter, r *http.Request) {
if err := h.service.CancelSession(r.Context(), r.PathValue("id")); err != nil {
writeError(w, http.StatusNotFound, "session not found")
@@ -272,7 +347,11 @@ func (h *handlerImpl) cancelSession(w http.ResponseWriter, r *http.Request) {
const maxUploadSize = 50 << 20 // 50 MB
func (h *handlerImpl) uploadFile(w http.ResponseWriter, r *http.Request) {
- if err := r.ParseMultipartForm(maxUploadSize); err != nil {
+ // Bound the total request body before parsing so an oversized multipart
+ // upload can't exhaust memory/disk (gosec G120). Allow modest headroom over
+ // maxUploadSize for the multipart envelope (boundaries and part headers).
+ r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize+(1<<20))
+ if err := r.ParseMultipartForm(maxUploadSize); err != nil { //nolint:gosec // G120: body bounded by http.MaxBytesReader above
writeError(w, http.StatusBadRequest, "file too large or invalid multipart form")
return
}
@@ -318,6 +397,62 @@ func (h *handlerImpl) sessionEvents(w http.ResponseWriter, r *http.Request) {
ServeSSE(w, r, h.service.Hub(), sessionTopic(id), "_never")
}
+// ── SCO Nodes ──
+
+func (h *handlerImpl) listSCONodes(w http.ResponseWriter, r *http.Request) {
+ nodeType := r.URL.Query().Get("type")
+ scanID := r.URL.Query().Get("scan_id")
+ limit := 500
+ if v := r.URL.Query().Get("limit"); v != "" {
+ if n, err := strconv.Atoi(v); err == nil && n > 0 {
+ limit = n
+ }
+ }
+ nodes, err := h.service.store.ListSCONodesByScanID(r.Context(), scanID, nodeType, limit)
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ if nodes == nil {
+ nodes = []json.RawMessage{}
+ }
+ writeJSON(w, http.StatusOK, nodes)
+}
+
+func (h *handlerImpl) getSCONode(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ node, err := h.service.store.GetSCONode(r.Context(), id)
+ if err != nil {
+ writeError(w, http.StatusNotFound, "node not found")
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ w.Write(node)
+}
+
+func (h *handlerImpl) scoNodeStats(w http.ResponseWriter, r *http.Request) {
+ stats, err := h.service.store.SCONodeStats(r.Context())
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, stats)
+}
+
+func (h *handlerImpl) deleteSCONodes(w http.ResponseWriter, r *http.Request) {
+ scanID := r.URL.Query().Get("scan_id")
+ if scanID == "" {
+ writeError(w, http.StatusBadRequest, "scan_id required")
+ return
+ }
+ if err := h.service.store.DeleteSCONodesByScan(r.Context(), scanID); err != nil {
+ writeError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
+}
+
func pathSegments(path string) []string {
path = strings.Trim(path, "/")
if path == "" {
@@ -340,3 +475,23 @@ func decodeJSON(body io.ReadCloser, v interface{}) error {
defer body.Close()
return json.NewDecoder(body).Decode(v)
}
+
+// decodeBody decodes the JSON request body into v, writing a 400 on failure.
+// Returns false when the caller should return early.
+func decodeBody(w http.ResponseWriter, r *http.Request, v any) bool {
+ if err := decodeJSON(r.Body, v); err != nil {
+ writeError(w, http.StatusBadRequest, err.Error())
+ return false
+ }
+ return true
+}
+
+// decodeOptionalBody decodes the request body only when one is present. An absent
+// body is fine (returns true); a present-but-invalid body writes a 400 and
+// returns false so the caller returns early.
+func decodeOptionalBody(w http.ResponseWriter, r *http.Request, v any) bool {
+ if r.ContentLength == 0 {
+ return true
+ }
+ return decodeBody(w, r, v)
+}
diff --git a/pkg/web/handler_import.go b/pkg/web/handler_import.go
new file mode 100644
index 00000000..0a775b08
--- /dev/null
+++ b/pkg/web/handler_import.go
@@ -0,0 +1,80 @@
+package web
+
+import (
+ "encoding/json"
+ "io"
+ "net/http"
+ "strings"
+
+ "github.com/chainreactors/libcstx/go"
+)
+
+func (h *handlerImpl) importSCONodes(w http.ResponseWriter, r *http.Request) {
+ if err := r.ParseMultipartForm(50 << 20); err != nil {
+ writeError(w, http.StatusBadRequest, "parse form: "+err.Error())
+ return
+ }
+
+ artifact := strings.TrimSpace(r.FormValue("artifact"))
+ if artifact == "" {
+ writeError(w, http.StatusBadRequest, "artifact is required")
+ return
+ }
+
+ file, _, err := r.FormFile("file")
+ if err != nil {
+ writeError(w, http.StatusBadRequest, "file is required: "+err.Error())
+ return
+ }
+ defer file.Close()
+
+ data, err := io.ReadAll(io.LimitReader(file, 50<<20))
+ if err != nil {
+ writeError(w, http.StatusBadRequest, "read file: "+err.Error())
+ return
+ }
+
+ nodes, err := cstx.Parse(artifact, data)
+ if err != nil {
+ writeError(w, http.StatusUnprocessableEntity, "transform failed: "+err.Error())
+ return
+ }
+
+ rawNodes := make([]json.RawMessage, 0, len(nodes))
+ seen := make(map[string]struct{}, len(nodes))
+ for _, n := range nodes {
+ id := n.CstxID()
+ if _, ok := seen[id]; ok {
+ continue
+ }
+ seen[id] = struct{}{}
+ if raw, err := json.Marshal(n); err == nil {
+ rawNodes = append(rawNodes, raw)
+ }
+ }
+
+ scanID := r.FormValue("scan_id")
+ if scanID == "" {
+ scanID = "import"
+ }
+
+ if err := h.service.store.UpsertSCONodes(r.Context(), scanID, rawNodes); err != nil {
+ writeError(w, http.StatusInternalServerError, "store: "+err.Error())
+ return
+ }
+
+ writeJSON(w, http.StatusOK, map[string]any{
+ "status": "ok",
+ "nodes": len(rawNodes),
+ "artifact": artifact,
+ "duplicates": len(nodes) - len(rawNodes),
+ })
+}
+
+func (h *handlerImpl) listSupportedArtifacts(w http.ResponseWriter, _ *http.Request) {
+ arts := cstx.SupportedArtifacts()
+ if arts == nil {
+ arts = []string{}
+ }
+ writeJSON(w, http.StatusOK, arts)
+}
diff --git a/pkg/web/llm_probe_test.go b/pkg/web/llm_probe_test.go
new file mode 100644
index 00000000..8ac31349
--- /dev/null
+++ b/pkg/web/llm_probe_test.go
@@ -0,0 +1,264 @@
+package web
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/chainreactors/aiscan/pkg/agent/probe"
+ "github.com/chainreactors/aiscan/pkg/webproto"
+)
+
+// fakeConfigStore is a minimal in-memory ConfigStore for probe tests.
+type fakeConfigStore struct {
+ cfg webproto.DistributeConfig
+}
+
+func (f *fakeConfigStore) GetDistributeConfig(ctx context.Context) (string, bool, webproto.DistributeConfig, error) {
+ return "config.yaml", true, f.cfg, nil
+}
+
+func (f *fakeConfigStore) SaveDistributeConfig(ctx context.Context, cfg webproto.DistributeConfig) error {
+ f.cfg = cfg
+ return nil
+}
+
+// stubLLMServer emulates an OpenAI-compatible /chat/completions endpoint and
+// records the Authorization header it received.
+func stubLLMServer(t *testing.T, reply string, gotAuth *string) *httptest.Server {
+ t.Helper()
+ mux := http.NewServeMux()
+ mux.HandleFunc("/v1/chat/completions", func(w http.ResponseWriter, r *http.Request) {
+ if gotAuth != nil {
+ *gotAuth = r.Header.Get("Authorization")
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "id": "cmpl-1",
+ "choices": []map[string]any{
+ {"message": map[string]any{"role": "assistant", "content": reply}, "finish_reason": "stop"},
+ },
+ })
+ })
+ return httptest.NewServer(mux)
+}
+
+func TestTestLLMSuccess(t *testing.T) {
+ srv := stubLLMServer(t, "pong", nil)
+ defer srv.Close()
+
+ svc := NewService(ServiceConfig{ConfigStore: &fakeConfigStore{}})
+ res, err := svc.TestLLM(context.Background(), probe.LLMProbeRequest{
+ Provider: "openai",
+ BaseURL: srv.URL + "/v1",
+ APIKey: "sk-test",
+ Model: "gpt-test",
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !res.OK {
+ t.Fatalf("expected ok, got error: %q", res.Error)
+ }
+ if res.Reply != "pong" {
+ t.Fatalf("expected reply pong, got %q", res.Reply)
+ }
+ if res.LatencyMs < 0 {
+ t.Fatalf("expected non-negative latency, got %d", res.LatencyMs)
+ }
+}
+
+func TestTestLLMMissingModel(t *testing.T) {
+ svc := NewService(ServiceConfig{ConfigStore: &fakeConfigStore{}})
+ res, err := svc.TestLLM(context.Background(), probe.LLMProbeRequest{Provider: "openai", APIKey: "sk-test"})
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if res.OK {
+ t.Fatal("expected failure when model is empty")
+ }
+ if !strings.Contains(res.Error, "model") {
+ t.Fatalf("expected model error, got %q", res.Error)
+ }
+}
+
+func TestTestLLMFallsBackToStoredKey(t *testing.T) {
+ var gotAuth string
+ srv := stubLLMServer(t, "ok", &gotAuth)
+ defer srv.Close()
+
+ store := &fakeConfigStore{}
+ store.cfg.LLM.APIKey = "sk-stored"
+ svc := NewService(ServiceConfig{ConfigStore: store})
+
+ // APIKey left blank: the stored secret must be used.
+ res, err := svc.TestLLM(context.Background(), probe.LLMProbeRequest{
+ Provider: "openai",
+ BaseURL: srv.URL + "/v1",
+ Model: "gpt-test",
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !res.OK {
+ t.Fatalf("expected ok, got error: %q", res.Error)
+ }
+ if gotAuth != "Bearer sk-stored" {
+ t.Fatalf("expected stored key in Authorization header, got %q", gotAuth)
+ }
+}
+
+func TestTestLLMReportsTransportError(t *testing.T) {
+ svc := NewService(ServiceConfig{ConfigStore: &fakeConfigStore{}})
+ // Unroutable port → connection refused, surfaced inside the result.
+ res, err := svc.TestLLM(context.Background(), probe.LLMProbeRequest{
+ Provider: "openai",
+ BaseURL: "http://127.0.0.1:1/v1",
+ APIKey: "sk-test",
+ Model: "gpt-test",
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if res.OK {
+ t.Fatal("expected failure against unreachable endpoint")
+ }
+ if res.Error == "" {
+ t.Fatal("expected an error message")
+ }
+}
+
+// stubModelsServer emulates an OpenAI-compatible GET /models endpoint returning
+// the given IDs, recording the Authorization header it received.
+func stubModelsServer(t *testing.T, ids []string, gotAuth *string) *httptest.Server {
+ t.Helper()
+ mux := http.NewServeMux()
+ mux.HandleFunc("/v1/models", func(w http.ResponseWriter, r *http.Request) {
+ if gotAuth != nil {
+ *gotAuth = r.Header.Get("Authorization")
+ }
+ data := make([]map[string]any, 0, len(ids))
+ for _, id := range ids {
+ data = append(data, map[string]any{"id": id, "object": "model"})
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{"object": "list", "data": data})
+ })
+ return httptest.NewServer(mux)
+}
+
+func TestListLLMModelsSuccess(t *testing.T) {
+ var gotAuth string
+ srv := stubModelsServer(t, []string{"gpt-4.1", "deepseek-v4-pro"}, &gotAuth)
+ defer srv.Close()
+
+ svc := NewService(ServiceConfig{ConfigStore: &fakeConfigStore{}})
+ res, err := svc.ListLLMModels(context.Background(), probe.LLMProbeRequest{
+ Provider: "openai",
+ BaseURL: srv.URL + "/v1",
+ APIKey: "sk-test",
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !res.OK {
+ t.Fatalf("expected ok, got error: %q", res.Error)
+ }
+ if len(res.Models) != 2 || res.Models[0] != "gpt-4.1" {
+ t.Fatalf("unexpected models: %v", res.Models)
+ }
+ if gotAuth != "Bearer sk-test" {
+ t.Fatalf("expected bearer key in Authorization header, got %q", gotAuth)
+ }
+}
+
+func TestListLLMModelsFallsBackToStoredKey(t *testing.T) {
+ var gotAuth string
+ srv := stubModelsServer(t, []string{"m1"}, &gotAuth)
+ defer srv.Close()
+
+ store := &fakeConfigStore{}
+ store.cfg.LLM.APIKey = "sk-stored"
+ svc := NewService(ServiceConfig{ConfigStore: store})
+
+ // APIKey left blank: the stored secret must be used.
+ res, err := svc.ListLLMModels(context.Background(), probe.LLMProbeRequest{
+ Provider: "openai",
+ BaseURL: srv.URL + "/v1",
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !res.OK {
+ t.Fatalf("expected ok, got error: %q", res.Error)
+ }
+ if gotAuth != "Bearer sk-stored" {
+ t.Fatalf("expected stored key in Authorization header, got %q", gotAuth)
+ }
+}
+
+func TestListLLMModelsReportsTransportError(t *testing.T) {
+ svc := NewService(ServiceConfig{ConfigStore: &fakeConfigStore{}})
+ res, err := svc.ListLLMModels(context.Background(), probe.LLMProbeRequest{
+ Provider: "openai",
+ BaseURL: "http://127.0.0.1:1/v1",
+ APIKey: "sk-test",
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if res.OK {
+ t.Fatal("expected failure against unreachable endpoint")
+ }
+ if res.Error == "" {
+ t.Fatal("expected an error message")
+ }
+}
+
+// TestListLLMModelsAnthropic guards the fix for the Anthropic provider: it must
+// enumerate models via GET {base}/models (with x-api-key + anthropic-version)
+// rather than short-circuiting on the modelLister assertion with "provider does
+// not support listing models".
+func TestListLLMModelsAnthropic(t *testing.T) {
+ var gotKey, gotVersion string
+ mux := http.NewServeMux()
+ mux.HandleFunc("/v1/models", func(w http.ResponseWriter, r *http.Request) {
+ gotKey = r.Header.Get("x-api-key")
+ gotVersion = r.Header.Get("anthropic-version")
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "object": "list",
+ "data": []map[string]any{
+ {"id": "claude-opus-4-8", "object": "model"},
+ {"id": "glm-5.2", "object": "model"},
+ },
+ })
+ })
+ srv := httptest.NewServer(mux)
+ defer srv.Close()
+
+ svc := NewService(ServiceConfig{ConfigStore: &fakeConfigStore{}})
+ res, err := svc.ListLLMModels(context.Background(), probe.LLMProbeRequest{
+ Provider: "anthropic",
+ BaseURL: srv.URL + "/v1",
+ APIKey: "sk-test",
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !res.OK {
+ t.Fatalf("expected ok, got error: %q", res.Error)
+ }
+ if len(res.Models) != 2 || res.Models[0] != "claude-opus-4-8" {
+ t.Fatalf("unexpected models: %v", res.Models)
+ }
+ if gotKey != "sk-test" {
+ t.Fatalf("expected x-api-key header, got %q", gotKey)
+ }
+ if gotVersion == "" {
+ t.Fatal("expected anthropic-version header to be set")
+ }
+}
diff --git a/pkg/web/localagent.go b/pkg/web/localagent.go
new file mode 100644
index 00000000..3038885c
--- /dev/null
+++ b/pkg/web/localagent.go
@@ -0,0 +1,256 @@
+package web
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "net/url"
+ "os"
+ "os/exec"
+ "strings"
+ "sync"
+)
+
+// LocalAgentView is the API-facing view of a hub-hosted agent, cross-referenced
+// with the live pool for connection state.
+type LocalAgentView struct {
+ Name string `json:"name"`
+ PID int `json:"pid"`
+ Registered bool `json:"registered"` // has connected back to the hub pool
+ Busy bool `json:"busy,omitempty"`
+}
+
+// localProc is the process handle for one launched `aiscan agent` child.
+type localProc struct {
+ name string // --ioa-node-name, also the stable handle used to stop it
+ pid int
+ cmd *exec.Cmd
+}
+
+// LocalAgents launches and tracks `aiscan agent` subprocesses on the hub host so
+// they register in the pool and can be listed/stopped from the UI. Each child
+// dials the hub's own loopback web + IOA endpoints, so it shows up in the pool
+// like any node. The hub holds the only handle to these processes, so StopAll
+// kills them on shutdown rather than leaving orphans.
+type LocalAgents struct {
+ webURL string // hub loopback address children dial (derived from web --addr)
+ webAuthURL string // same base with the access token as userinfo, for /api/agent/ws auth
+ ioaURL string // hub IOA endpoint carrying the embedded access token
+ pool *AgentPool // live pool, for registration/busy cross-reference
+
+ mu sync.Mutex
+ procs []*localProc
+ seq int
+}
+
+// NewLocalAgents builds a launcher. hubURL is the loopback base the children
+// dial (e.g. http://127.0.0.1:8080); ioaToken is embedded into the child's IOA
+// URL. Children are launched from the current aiscan executable.
+func NewLocalAgents(hubURL, ioaToken string, pool *AgentPool) *LocalAgents {
+ return &LocalAgents{
+ webURL: hubURL,
+ webAuthURL: webURLWithToken(hubURL, ioaToken),
+ ioaURL: nodeIOAURL(hubURL, ioaToken),
+ pool: pool,
+ }
+}
+
+// webURLWithToken embeds the access token as userinfo on the hub's loopback web
+// URL (http://@host), so a launched agent can authenticate its
+// /api/agent/ws pool connection — the hub gates /api/* behind that key. An empty
+// token or unparseable hubURL yields hubURL unchanged.
+func webURLWithToken(hubURL, token string) string {
+ if hubURL == "" || token == "" {
+ return hubURL
+ }
+ u, err := url.Parse(strings.TrimRight(hubURL, "/"))
+ if err != nil {
+ return hubURL
+ }
+ u.User = url.User(token)
+ return u.String()
+}
+
+// nodeIOAURL embeds the access token as userinfo and points at the /ioa path,
+// yielding http://@host:port/ioa. An empty or unparseable hubURL yields "".
+func nodeIOAURL(hubURL, token string) string {
+ if hubURL == "" {
+ return ""
+ }
+ u, err := url.Parse(strings.TrimRight(hubURL, "/"))
+ if err != nil {
+ return ""
+ }
+ if token != "" {
+ u.User = url.User(token)
+ }
+ u.Path = "/ioa"
+ return u.String()
+}
+
+// Launch spawns an `aiscan agent` on the hub host wired to the hub's loopback
+// web + IOA endpoints, and tracks it. The LLM provider/model/key arrive via the
+// hub's config push on registration, so nothing about the model is passed here.
+func (l *LocalAgents) Launch(ctx context.Context) (*LocalAgentView, error) {
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+ if l.webURL == "" {
+ return nil, fmt.Errorf("hub local address unknown; cannot launch a local agent (check the web --addr)")
+ }
+ bin, err := os.Executable()
+ if err != nil {
+ return nil, fmt.Errorf("resolve agent binary: %w", err)
+ }
+
+ l.mu.Lock()
+ l.seq++
+ name := fmt.Sprintf("local-%d", l.seq)
+ l.mu.Unlock()
+
+ cmd := exec.Command(bin, "agent",
+ "--web-url", l.webAuthURL,
+ "--server-url", l.ioaURL,
+ "--space", "default",
+ "--node-name", name,
+ )
+ if err := cmd.Start(); err != nil {
+ return nil, fmt.Errorf("start local agent: %w", err)
+ }
+ p := &localProc{name: name, pid: cmd.Process.Pid, cmd: cmd}
+
+ l.mu.Lock()
+ l.procs = append(l.procs, p)
+ l.mu.Unlock()
+
+ // Drop the entry once the child exits (on its own or via Stop) so the list
+ // never shows a dead node.
+ go func() {
+ _ = cmd.Wait()
+ l.remove(p)
+ }()
+
+ v := l.view(p)
+ return &v, nil
+}
+
+// List returns the tracked local agents (launch order), cross-referenced with
+// the pool for connection state.
+func (l *LocalAgents) List() []LocalAgentView {
+ l.mu.Lock()
+ all := make([]*localProc, len(l.procs))
+ copy(all, l.procs)
+ l.mu.Unlock()
+
+ views := make([]LocalAgentView, 0, len(all))
+ for _, p := range all {
+ views = append(views, l.view(p))
+ }
+ return views
+}
+
+// Stop kills a tracked local agent by name and drops it from the roster.
+func (l *LocalAgents) Stop(name string) error {
+ l.mu.Lock()
+ var found *localProc
+ for _, p := range l.procs {
+ if p.name == name {
+ found = p
+ break
+ }
+ }
+ l.mu.Unlock()
+ if found == nil {
+ return fmt.Errorf("local agent %s not found", name)
+ }
+ l.remove(found)
+ killLocalProc(found.cmd)
+ return nil
+}
+
+// StopAll kills every tracked local agent (hub shutdown), so none are left
+// orphaned once the hub — which holds the only handle to them — exits.
+func (l *LocalAgents) StopAll() {
+ l.mu.Lock()
+ all := l.procs
+ l.procs = nil
+ l.mu.Unlock()
+ for _, p := range all {
+ killLocalProc(p.cmd)
+ }
+}
+
+// remove drops a specific tracked agent (called when its process exits).
+func (l *LocalAgents) remove(p *localProc) {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ for i, x := range l.procs {
+ if x == p {
+ l.procs = append(l.procs[:i], l.procs[i+1:]...)
+ return
+ }
+ }
+}
+
+// view cross-references a child against the live pool (matched by IOA node name,
+// falling back to the agent name) to report whether it has connected yet.
+func (l *LocalAgents) view(p *localProc) LocalAgentView {
+ v := LocalAgentView{Name: p.name, PID: p.pid}
+ if l.pool == nil {
+ return v
+ }
+ for _, a := range l.pool.List() {
+ name := a.Identity.NodeName
+ if name == "" {
+ name = a.Name
+ }
+ if name == p.name {
+ v.Registered, v.Busy = true, a.Busy
+ break
+ }
+ }
+ return v
+}
+
+// killLocalProc terminates a child process (best-effort; no-op once it exited).
+func killLocalProc(cmd *exec.Cmd) {
+ if cmd != nil && cmd.Process != nil {
+ _ = cmd.Process.Kill()
+ }
+}
+
+// ---------------------------------------------------------------------------
+// HTTP surface
+// ---------------------------------------------------------------------------
+
+func (l *LocalAgents) handleLaunch(w http.ResponseWriter, r *http.Request) {
+ view, err := l.Launch(r.Context())
+ if err != nil {
+ writeError(w, http.StatusUnprocessableEntity, err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, view)
+}
+
+func (l *LocalAgents) handleList(w http.ResponseWriter, r *http.Request) {
+ writeJSON(w, http.StatusOK, l.List())
+}
+
+func (l *LocalAgents) handleStop(w http.ResponseWriter, r *http.Request) {
+ if err := l.Stop(r.PathValue("id")); err != nil {
+ writeError(w, http.StatusUnprocessableEntity, err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]string{"status": "stopped"})
+}
+
+// registerLocalAgentRoutes wires the hub-hosted local-agent endpoints. The
+// literal "local" segment never collides with a real id, so plain paths suffice.
+func registerLocalAgentRoutes(mux *http.ServeMux, l *LocalAgents) {
+ if l == nil {
+ return
+ }
+ mux.HandleFunc("POST /api/deploy/local", l.handleLaunch)
+ mux.HandleFunc("GET /api/deploy/local", l.handleList)
+ mux.HandleFunc("DELETE /api/deploy/local/{id}", l.handleStop)
+}
diff --git a/pkg/web/probe.go b/pkg/web/probe.go
new file mode 100644
index 00000000..925c95b8
--- /dev/null
+++ b/pkg/web/probe.go
@@ -0,0 +1,55 @@
+package web
+
+import (
+ "context"
+ "strings"
+
+ "github.com/chainreactors/aiscan/pkg/agent/probe"
+ "github.com/chainreactors/aiscan/pkg/webproto"
+)
+
+// TestConn probes one settings section's external dependencies, resolving blank
+// secrets against the stored config, then delegates to pkg/probe. Probe failures
+// live inside the response; a returned error only signals an untestable section.
+func (s *Service) TestConn(ctx context.Context, section string, in webproto.DistributeConfig) ([]probe.ConnCheck, error) {
+ stored, _ := s.storedConfig(ctx)
+ return probe.TestConn(ctx, section, in, stored)
+}
+
+// TestLLM probes the supplied LLM settings, falling back to the stored API key
+// when the request leaves it blank, then delegates to pkg/probe.
+func (s *Service) TestLLM(ctx context.Context, req probe.LLMProbeRequest) (probe.LLMTestResult, error) {
+ var storedKey string
+ if s.config != nil {
+ if dc, err := s.GetDistributeConfig(ctx); err == nil {
+ storedKey = strings.TrimSpace(dc.LLM.APIKey)
+ }
+ }
+ return probe.TestLLM(ctx, req, storedKey)
+}
+
+// ListLLMModels enumerates the models the supplied LLM endpoint advertises,
+// falling back to the stored API key when the request leaves it blank, then
+// delegates to pkg/probe.
+func (s *Service) ListLLMModels(ctx context.Context, req probe.LLMProbeRequest) (probe.LLMModelsResult, error) {
+ var storedKey string
+ if s.config != nil {
+ if dc, err := s.GetDistributeConfig(ctx); err == nil {
+ storedKey = strings.TrimSpace(dc.LLM.APIKey)
+ }
+ }
+ return probe.ListLLMModels(ctx, req, storedKey)
+}
+
+// storedConfig returns the config persisted on the server, or ok=false when no
+// config store is wired or it cannot be read.
+func (s *Service) storedConfig(ctx context.Context) (webproto.DistributeConfig, bool) {
+ if s.config == nil {
+ return webproto.DistributeConfig{}, false
+ }
+ dc, err := s.GetDistributeConfig(ctx)
+ if err != nil {
+ return webproto.DistributeConfig{}, false
+ }
+ return dc, true
+}
diff --git a/pkg/web/service.go b/pkg/web/service.go
index 2bdd473b..d88960c7 100644
--- a/pkg/web/service.go
+++ b/pkg/web/service.go
@@ -3,7 +3,9 @@ package web
import (
"bytes"
"context"
+ "crypto/rand"
"encoding/base64"
+ "encoding/hex"
"encoding/json"
"fmt"
"io"
@@ -13,18 +15,24 @@ import (
"sync"
"time"
+ "github.com/chainreactors/aiscan/core/config"
"github.com/chainreactors/aiscan/core/output"
"github.com/chainreactors/aiscan/core/runner"
+ scantool "github.com/chainreactors/aiscan/pkg/tools/scan"
+ "github.com/chainreactors/aiscan/pkg/tui"
"github.com/chainreactors/aiscan/pkg/webproto"
)
+// hubCommands are the 3 commands that run on the web hub, not the agent.
+var hubCommands = map[string]bool{"scan": true, "agents": true, "help": true}
+
type ConfigStore interface {
GetDistributeConfig(ctx context.Context) (path string, loaded bool, cfg webproto.DistributeConfig, err error)
SaveDistributeConfig(ctx context.Context, cfg webproto.DistributeConfig) error
}
type ServiceConfig struct {
- Store Store
+ Store *SQLiteStore
App *runner.App
ConfigStore ConfigStore
AppFactory func(ctx context.Context) (*runner.App, error)
@@ -34,7 +42,7 @@ type ServiceConfig struct {
}
type Service struct {
- store Store
+ store *SQLiteStore
appMu sync.RWMutex
app *runner.App
config ConfigStore
@@ -103,6 +111,7 @@ func (s *Service) Close() {
func (s *Service) Status() ServiceStatus {
app := s.appSnapshot()
status := ServiceStatus{
+ Version: config.Version,
LLMAvailable: app != nil && app.Provider != nil,
}
if app != nil {
@@ -152,6 +161,11 @@ func (s *Service) SaveConfig(ctx context.Context, cfg webproto.DistributeConfig)
}
s.swapApp(app)
}
+ // Tell connected agents to hot-swap their own provider too — the hub reload
+ // above only refreshes the hub's in-process runtime, not the agent subprocesses.
+ if s.agents != nil {
+ s.agents.BroadcastConfigReload()
+ }
return s.GetConfigStatus(ctx)
}
@@ -183,7 +197,6 @@ func (s *Service) SubmitScan(ctx context.Context, target, mode string, verify, s
Mode: mode,
Verify: verify,
Sniper: sniper,
- AI: verify || sniper,
Deep: deep,
Status: StatusQueued,
CreatedAt: now,
@@ -200,11 +213,29 @@ func (s *Service) SubmitScan(ctx context.Context, target, mode string, verify, s
}
func (s *Service) GetScan(ctx context.Context, id string) (*ScanJob, error) {
- return s.store.Get(ctx, id)
+ job, err := s.store.Get(ctx, id)
+ if err != nil {
+ return nil, err
+ }
+ refreshStructuredAssets(job)
+ return job, nil
}
func (s *Service) ListScans(ctx context.Context) ([]*ScanJob, error) {
- return s.store.List(ctx, 100)
+ jobs, err := s.store.List(ctx, 100)
+ if err != nil {
+ return nil, err
+ }
+ for _, job := range jobs {
+ refreshStructuredAssets(job)
+ }
+ return jobs, nil
+}
+
+func refreshStructuredAssets(job *ScanJob) {
+ if job != nil && job.Result != nil && (len(job.Result.Services) > 0 || len(job.Result.WebProbes) > 0) {
+ job.Result.Assets = scantool.AggregateStructuredResult(job.Result)
+ }
}
func (s *Service) CancelScan(id string) error {
@@ -227,11 +258,18 @@ func (s *Service) CancelScan(id string) error {
return nil
}
-func (s *Service) GetReport(ctx context.Context, id string) (string, error) {
- job, err := s.store.Get(ctx, id)
+// GetReport re-renders the report in the requested language from the stored
+// structured result, so a zh user gets a zh report even though the scan ran
+// once. It falls back to the report frozen at scan time when the structured
+// result is no longer around.
+func (s *Service) GetReport(ctx context.Context, id, lang string) (string, error) {
+ job, err := s.GetScan(ctx, id)
if err != nil {
return "", err
}
+ if job.Result != nil {
+ return buildMarkdownReport(job.Target, job.Mode, job.Result, lang), nil
+ }
return job.Report, nil
}
@@ -311,20 +349,7 @@ func (s *Service) runScanViaAgent(ctx context.Context, job *ScanJob) {
_ = json.Unmarshal(res.Result, result)
}
- report := buildMarkdownReport(job.Target, job.Mode, result)
- job.Status = StatusCompleted
- job.Report = report
- job.Result = result
- job.UpdatedAt = time.Now()
- _ = s.store.Update(ctx, job)
-
- s.persistResultRecords(job.ID, agent.id, result)
-
- s.hub.Broadcast(job.ID, HubEvent{
- Type: "complete",
- Data: mustJSON(map[string]any{"scan_id": job.ID, "status": "completed", "result": result}),
- })
- s.broadcastScanComplete(job.ID, result)
+ s.completeJob(ctx, job, agent.id, result)
}
func (s *Service) runScanLocally(ctx context.Context, job *ScanJob) {
@@ -346,20 +371,7 @@ func (s *Service) runScanLocally(ctx context.Context, job *ScanJob) {
job = streamWriter.job
}
- report := buildMarkdownReport(job.Target, job.Mode, result)
- job.Status = StatusCompleted
- job.Report = report
- job.Result = result
- job.UpdatedAt = time.Now()
- _ = s.store.Update(ctx, job)
-
- s.persistResultRecords(job.ID, "", result)
-
- s.hub.Broadcast(job.ID, HubEvent{
- Type: "complete",
- Data: mustJSON(map[string]any{"scan_id": job.ID, "status": "completed", "result": result}),
- })
- s.broadcastScanComplete(job.ID, result)
+ s.completeJob(ctx, job, "", result)
}
func (s *Service) persistResultRecords(scanID, agentID string, result *output.Result) {
@@ -369,14 +381,33 @@ func (s *Service) persistResultRecords(scanID, agentID string, result *output.Re
}
}
+func (s *Service) completeJob(ctx context.Context, job *ScanJob, agentID string, result *output.Result) {
+ job.Status = StatusCompleted
+ job.Report = buildMarkdownReport(job.Target, job.Mode, result, defaultReportLang)
+ job.Result = result
+ job.UpdatedAt = time.Now()
+ _ = s.store.Update(ctx, job)
+ s.persistResultRecords(job.ID, agentID, result)
+ if len(result.Nodes) > 0 {
+ _ = s.store.UpsertSCONodes(ctx, job.ID, result.Nodes)
+ }
+ s.hub.Broadcast(job.ID, HubEvent{
+ Type: "complete",
+ Data: mustJSON(map[string]any{"scan_id": job.ID, "status": "completed", "result": result}),
+ Reliable: true,
+ })
+ s.broadcastScanComplete(job.ID, result)
+}
+
func (s *Service) failJob(job *ScanJob, errMsg string) {
job.Status = StatusFailed
job.Error = errMsg
job.UpdatedAt = time.Now()
_ = s.store.Update(context.Background(), job)
s.hub.Broadcast(job.ID, HubEvent{
- Type: "error",
- Data: mustJSON(map[string]string{"scan_id": job.ID, "error": errMsg}),
+ Type: "error",
+ Data: mustJSON(map[string]string{"scan_id": job.ID, "error": errMsg}),
+ Reliable: true,
})
}
@@ -444,7 +475,7 @@ func (s *Service) executeScan(ctx context.Context, args []string, stream io.Writ
type sseStreamWriter struct {
hub *Hub
scanID string
- store Store
+ store *SQLiteStore
job *ScanJob
ctx context.Context
buf []byte
@@ -467,7 +498,7 @@ func (w *sseStreamWriter) Write(p []byte) (int, error) {
line := string(w.buf[:idx])
w.buf = w.buf[idx+1:]
- line = stripANSI(line)
+ line = output.StripANSI(line)
if line == "" {
continue
}
@@ -496,62 +527,239 @@ func (w *sseStreamWriter) Write(p []byte) (int, error) {
return len(p), nil
}
-func buildMarkdownReport(target, mode string, result *output.Result) string {
- var sb strings.Builder
- sb.WriteString("# Penetration Test Report\n\n")
- sb.WriteString(fmt.Sprintf("**Target:** `%s` \n", target))
- sb.WriteString(fmt.Sprintf("**Mode:** %s \n", mode))
- sb.WriteString(fmt.Sprintf("**Date:** %s\n\n", time.Now().Format("2006-01-02 15:04:05")))
- sb.WriteString("---\n\n")
+// defaultReportLang is the language the report is frozen in at scan time; the
+// stored copy is only a fallback — GetReport re-renders per request language.
+const defaultReportLang = "zh"
+
+// reportWriter holds the target language so every helper can call w.tr()
+// without threading `lang` through every signature.
+type reportWriter struct {
+ strings.Builder
+ lang string
+}
+
+func newReportWriter(lang string) *reportWriter {
+ if strings.HasPrefix(strings.ToLower(lang), "zh") {
+ return &reportWriter{lang: "zh"}
+ }
+ return &reportWriter{lang: "en"}
+}
+
+func (w *reportWriter) tr(zh, en string) string {
+ if w.lang == "zh" {
+ return zh
+ }
+ return en
+}
+
+func (w *reportWriter) modeName(mode string) string {
+ if strings.EqualFold(mode, "full") {
+ return w.tr("全面侦察", "Full recon")
+ }
+ return w.tr("快速侦察", "Quick recon")
+}
+
+func (w *reportWriter) sep() string { return w.tr(":", ": ") }
+
+// buildMarkdownReport renders a scan result as an operator-facing recon report.
+// It reads like something a human wrote — a prose overview instead of a raw
+// metric dump, no internal scanner names (gogo_portscan / check) leaking into
+// the prose, and bare live hosts (an icmp echo, say) folded into a trailing
+// list rather than each claiming a full section.
+func buildMarkdownReport(target, mode string, result *output.Result, lang string) string {
+ w := newReportWriter(lang)
+
+ heading := output.FirstNonEmpty(target, w.tr("目标", "target"))
+ fmt.Fprintf(w, "# %s%s\n\n", w.tr("侦察报告 · ", "Recon report · "), heading)
+ fmt.Fprintf(w, "%s `%s` · %s · %s\n\n",
+ w.tr("目标", "Target"), target,
+ w.modeName(mode),
+ time.Now().Format("2006-01-02 15:04:05"))
+ w.WriteString("---\n\n")
if result == nil {
- sb.WriteString("No structured result was returned.\n")
- return sb.String()
+ w.WriteString(w.tr("本次扫描未返回结构化结果。\n", "No structured result was returned.\n"))
+ return w.String()
}
- sb.WriteString("## Summary\n\n")
- sb.WriteString("| Metric | Value |\n|---|---:|\n")
- sb.WriteString(fmt.Sprintf("| Targets | %d |\n", result.Summary.Targets))
- sb.WriteString(fmt.Sprintf("| Services | %d |\n", result.Summary.Services))
- sb.WriteString(fmt.Sprintf("| Web | %d |\n", result.Summary.Webs))
- sb.WriteString(fmt.Sprintf("| Probes | %d |\n", result.Summary.Probes))
- sb.WriteString(fmt.Sprintf("| Fingerprints | %d |\n", resultFingerprintCount(result)))
- sb.WriteString(fmt.Sprintf("| Loots | %d |\n", result.Summary.Loots))
- sb.WriteString(fmt.Sprintf("| Errors | %d |\n", result.Summary.Errors))
- if result.Summary.Duration != "" {
- sb.WriteString(fmt.Sprintf("| Duration | %s |\n", result.Summary.Duration))
+ w.WriteString("## " + w.tr("概述", "Overview") + "\n\n")
+ w.writeOverview(result)
+ w.WriteString("\n\n")
+
+ rich, bare := splitReportAssets(result.Assets)
+ if len(rich) > 0 {
+ w.WriteString("## " + w.tr("资产明细", "Assets") + "\n\n")
+ for _, asset := range rich {
+ w.writeAsset(asset)
+ }
}
- sb.WriteString("\n")
+ if len(bare) > 0 {
+ w.WriteString("## " + w.tr("其他存活主机", "Other live hosts") + "\n\n")
+ for _, asset := range bare {
+ w.writeBareAsset(asset)
+ }
+ w.WriteString("\n")
+ }
+
+ return w.String()
+}
- if len(result.Assets) == 0 {
- return sb.String()
+// writeOverview appends the executive summary — one flowing paragraph that
+// names only the numbers that are actually present, so a clean scan reads like
+// a sentence rather than a table full of zeros.
+func (w *reportWriter) writeOverview(result *output.Result) {
+ s := result.Summary
+ hosts := reportHostCount(result.Assets)
+ fingers := resultFingerprintCount(result)
+
+ if w.lang == "zh" {
+ fmt.Fprintf(w, "本次侦察共识别 %d 台主机、%d 个开放服务", hosts, s.Services)
+ if s.Webs > 0 {
+ fmt.Fprintf(w, "(含 %d 个 Web 站点)", s.Webs)
+ }
+ w.WriteString("。")
+ if s.Probes > 0 {
+ fmt.Fprintf(w, "累计探测 %d 条路径", s.Probes)
+ if fingers > 0 {
+ fmt.Fprintf(w, "、命中 %d 项 Web 指纹", fingers)
+ }
+ w.WriteString("。")
+ } else if fingers > 0 {
+ fmt.Fprintf(w, "命中 %d 项 Web 指纹。", fingers)
+ }
+ if s.Loots > 0 {
+ fmt.Fprintf(w, "**发现 %d 项需优先复核的安全发现(凭证 / 弱口令 / 漏洞)。**", s.Loots)
+ }
+ if s.Errors > 0 {
+ fmt.Fprintf(w, "另有 %d 处探测报错。", s.Errors)
+ }
+ if s.Duration != "" {
+ fmt.Fprintf(w, "全程耗时 %s。", s.Duration)
+ }
+ return
}
- sb.WriteString("## Assets\n\n")
- for _, asset := range result.Assets {
- title := output.FirstNonEmpty(asset.Title, asset.Target, asset.Key, "Asset")
- sb.WriteString(fmt.Sprintf("### %s\n\n", title))
- if asset.Target != "" && asset.Target != title {
- sb.WriteString(fmt.Sprintf("- **Target:** %s\n", markdownCode(asset.Target)))
+ fmt.Fprintf(w, "The scan identified %s across %s", plural(hosts, "host", "hosts"), plural(s.Services, "open service", "open services"))
+ if s.Webs > 0 {
+ fmt.Fprintf(w, " (%s)", plural(s.Webs, "web site", "web sites"))
+ }
+ w.WriteString(". ")
+ if s.Probes > 0 {
+ fmt.Fprintf(w, "It probed %s", plural(s.Probes, "path", "paths"))
+ if fingers > 0 {
+ fmt.Fprintf(w, " and matched %s", plural(fingers, "fingerprint", "fingerprints"))
}
- if asset.Status != "" {
- sb.WriteString(fmt.Sprintf("- **State:** %s\n", markdownCode(asset.Status)))
+ w.WriteString(". ")
+ } else if fingers > 0 {
+ fmt.Fprintf(w, "It matched %s. ", plural(fingers, "fingerprint", "fingerprints"))
+ }
+ if s.Loots > 0 {
+ fmt.Fprintf(w, "**%s surfaced (credentials / weak passwords / vulnerabilities) — review these first.** ", plural(s.Loots, "security finding", "security findings"))
+ }
+ if s.Errors > 0 {
+ fmt.Fprintf(w, "%s occurred during probing. ", plural(s.Errors, "error", "errors"))
+ }
+ if s.Duration != "" {
+ fmt.Fprintf(w, "The scan took %s.", s.Duration)
+ }
+}
+
+func plural(n int, one, many string) string {
+ if n == 1 {
+ return fmt.Sprintf("%d %s", n, one)
+ }
+ return fmt.Sprintf("%d %s", n, many)
+}
+
+// reportHostCount collapses assets down to distinct hosts, so an IP that has
+// both an icmp echo and a web service counts once, not twice.
+func reportHostCount(assets []output.Asset) int {
+ seen := make(map[string]struct{})
+ for _, a := range assets {
+ if h := assetHost(a); h != "" {
+ seen[h] = struct{}{}
}
- writeMarkdownList(&sb, "Services", assetServiceFacts(asset.Items))
- writeMarkdownList(&sb, "HTTP", assetHTTPStatuses(asset.Items))
- writeMarkdownList(&sb, "Fingers", assetFingers(asset.Items))
- writeMarkdownList(&sb, "Sources", assetSources(asset.Items))
- if paths := assetPathCount(asset.Items); paths > 0 {
- sb.WriteString(fmt.Sprintf("- **Paths:** %d\n", paths))
+ }
+ if len(seen) == 0 {
+ return len(assets)
+ }
+ return len(seen)
+}
+
+func assetHost(a output.Asset) string {
+ v := output.FirstNonEmpty(a.Target, a.Key, a.Title)
+ if i := strings.Index(v, "://"); i >= 0 {
+ v = v[i+3:]
+ }
+ if i := strings.IndexAny(v, "/?#"); i >= 0 {
+ v = v[:i]
+ }
+ if strings.Count(v, ":") == 1 { // host:port — drop the port, leave IPv6 alone
+ v = v[:strings.LastIndex(v, ":")]
+ }
+ return v
+}
+
+func splitReportAssets(assets []output.Asset) (rich, bare []output.Asset) {
+ for _, a := range assets {
+ if assetIsBare(a) {
+ bare = append(bare, a)
+ } else {
+ rich = append(rich, a)
}
- writeAssetLootMarkdown(&sb, asset.Items)
- sb.WriteString("\n")
}
+ return rich, bare
+}
+
+// assetIsBare is true for a live host that only answered with non-web services
+// (an icmp echo, a bare tcp port) — nothing worth its own section.
+func assetIsBare(a output.Asset) bool {
+ hasService := false
+ for _, item := range a.Items {
+ if item.Kind != output.AssetItemService {
+ return false
+ }
+ hasService = true
+ svc := strings.ToLower(output.AssetDataString(item.Data, "service") + " " + output.AssetDataString(item.Data, "protocol"))
+ if strings.Contains(svc, "http") {
+ return false
+ }
+ }
+ return hasService
+}
+
+func (w *reportWriter) writeAsset(asset output.Asset) {
+ title := output.FirstNonEmpty(asset.Title, asset.Target, asset.Key, w.tr("资产", "Asset"))
+ if asset.Target != "" && asset.Target != title {
+ fmt.Fprintf(w, "### %s — `%s`\n\n", title, asset.Target)
+ } else {
+ fmt.Fprintf(w, "### %s\n\n", title)
+ }
+
+ w.writeFact(w.tr("开放服务", "Services"), assetServiceFacts(asset.Items))
+ w.writeFact(w.tr("HTTP 响应", "HTTP"), assetHTTPStatuses(asset.Items))
+ w.writeFact(w.tr("Web 指纹", "Fingerprints"), assetFingers(asset.Items))
+ if paths := assetPathCount(asset.Items); paths > 0 {
+ fmt.Fprintf(w, "- %s%s%s\n", w.tr("已探测路径", "Paths"), w.sep(), w.tr(fmt.Sprintf("%d 条", paths), fmt.Sprintf("%d", paths)))
+ }
+ if asset.Status != "" {
+ fmt.Fprintf(w, "- %s%s%s\n", w.tr("状态", "State"), w.sep(), markdownCode(asset.Status))
+ }
+ w.WriteString("\n")
- return sb.String()
+ w.writeLootMarkdown(asset.Items)
}
-func writeMarkdownList(sb *strings.Builder, label string, values []string) {
+func (w *reportWriter) writeBareAsset(asset output.Asset) {
+ host := output.FirstNonEmpty(asset.Target, asset.Title, asset.Key)
+ if services := assetServiceFacts(asset.Items); len(services) > 0 {
+ fmt.Fprintf(w, "- `%s` · %s\n", host, strings.Join(services, ", "))
+ } else {
+ fmt.Fprintf(w, "- `%s`\n", host)
+ }
+}
+
+func (w *reportWriter) writeFact(label string, values []string) {
if len(values) == 0 {
return
}
@@ -559,10 +767,10 @@ func writeMarkdownList(sb *strings.Builder, label string, values []string) {
for _, value := range values {
coded = append(coded, markdownCode(value))
}
- sb.WriteString(fmt.Sprintf("- **%s:** %s\n", label, strings.Join(coded, ", ")))
+ fmt.Fprintf(w, "- %s%s%s\n", label, w.sep(), strings.Join(coded, w.tr("、", ", ")))
}
-func writeAssetLootMarkdown(sb *strings.Builder, items []output.AssetItem) {
+func (w *reportWriter) writeLootMarkdown(items []output.AssetItem) {
wrote := false
for _, item := range items {
switch item.Kind {
@@ -572,24 +780,19 @@ func writeAssetLootMarkdown(sb *strings.Builder, items []output.AssetItem) {
if summary == "" && detail == "" {
continue
}
- prefix := output.FirstNonEmpty(item.Source, item.Kind)
- if item.Status != "" {
- prefix += ":" + item.Status
- }
if !wrote {
- sb.WriteString("\n#### Analysis\n\n")
+ w.WriteString("#### " + w.tr("分析研判", "Analysis") + "\n\n")
wrote = true
}
if summary == "" {
summary = firstMarkdownLine(detail)
}
- sb.WriteString(fmt.Sprintf("##### %s\n\n", markdownHeading(summary)))
- sb.WriteString(fmt.Sprintf("**Source:** %s\n\n", markdownCode(prefix)))
+ fmt.Fprintf(w, "##### %s\n\n", markdownHeading(summary))
if detail != "" && !sameMarkdownText(summary, detail) {
- writeMarkdownBlock(sb, detail)
+ writeMarkdownBlock(&w.Builder, detail)
} else if detail == "" && summary != "" {
- sb.WriteString(summary)
- sb.WriteString("\n\n")
+ w.WriteString(summary)
+ w.WriteString("\n\n")
}
}
}
@@ -657,14 +860,6 @@ func assetFingers(items []output.AssetItem) []string {
return output.CompactStrings(values...)
}
-func assetSources(items []output.AssetItem) []string {
- var values []string
- for _, item := range items {
- values = append(values, item.Source)
- }
- return output.CompactStrings(values...)
-}
-
func assetPathCount(items []output.AssetItem) int {
count := 0
for _, item := range items {
@@ -703,17 +898,15 @@ func markdownHeading(value string) string {
}
func generateID() string {
- return fmt.Sprintf("%d", time.Now().UnixNano())
+ b := make([]byte, 16)
+ _, _ = rand.Read(b)
+ return hex.EncodeToString(b)
}
-func stripANSI(s string) string {
- return output.StripANSI(s)
-}
-
-func lastOutputLine(output string) string {
- lines := strings.Split(output, "\n")
+func lastOutputLine(s string) string {
+ lines := strings.Split(s, "\n")
for i := len(lines) - 1; i >= 0; i-- {
- line := strings.TrimSpace(stripANSI(lines[i]))
+ line := strings.TrimSpace(output.StripANSI(lines[i]))
if line != "" {
return line
}
@@ -775,7 +968,7 @@ func (s *Service) CancelSession(ctx context.Context, sessionID string) error {
s.mu.Unlock()
if len(tasks) == 0 {
- s.broadcastSystemMessage(sessionID, "No running task.")
+ s.broadcastSystemMessage(sessionID, SysNoRunningTask, "No running task.", nil)
return nil
}
if s.agents != nil {
@@ -785,7 +978,7 @@ func (s *Service) CancelSession(ctx context.Context, sessionID string) error {
}
}
}
- s.broadcastSystemMessage(sessionID, "Paused.")
+ s.broadcastSystemMessage(sessionID, SysPaused, "Paused.", nil)
return nil
}
@@ -829,23 +1022,22 @@ func (s *Service) HandleFileUpload(ctx context.Context, sessionID, filename stri
return nil, fmt.Errorf("agent disconnected during upload")
}
var result webproto.FileUploadResult
- if len(res.Result) > 0 {
- if err := json.Unmarshal(res.Result, &result); err != nil {
- return &webproto.FileUploadResult{
- Filename: filename,
- Path: res.Output,
- Size: int64(len(data)),
- }, nil
+ // The agent normally returns a JSON-encoded FileUploadResult. If it sent
+ // nothing structured (or non-JSON output), synthesize one from the raw
+ // output path — the upload still succeeded, just without an envelope.
+ if len(res.Result) == 0 || json.Unmarshal(res.Result, &result) != nil {
+ result = webproto.FileUploadResult{
+ Filename: filename,
+ Path: res.Output,
+ Size: int64(len(data)),
}
- } else {
- result.Filename = filename
- result.Path = res.Output
- result.Size = int64(len(data))
}
if result.Error != "" {
return nil, fmt.Errorf("agent upload error: %s", result.Error)
}
- s.broadcastSystemMessage(sessionID, fmt.Sprintf("File uploaded: %s → %s", filename, result.Path))
+ s.broadcastSystemMessage(sessionID, SysFileUploaded,
+ fmt.Sprintf("File uploaded: %s → %s", filename, result.Path),
+ map[string]any{"filename": filename, "path": result.Path})
return &result, nil
case <-ctx.Done():
return nil, ctx.Err()
@@ -899,9 +1091,28 @@ func (s *Service) BroadcastChatEvent(sessionID string, event ChatEvent) {
s.hub.Broadcast(sessionTopic(sessionID), HubEvent{
Type: event.Type,
Data: mustJSON(event),
+ // Terminal events must never be dropped (see isTerminalChatEvent). Eval
+ // verdicts are rare, non-terminal, but each one is a discrete round marker
+ // the client can't reconstruct if lost under backpressure — send reliably.
+ Reliable: isTerminalChatEvent(event.Type) || event.Type == ChatEventEval || event.Type == ChatEventCompact,
})
}
+// isTerminalChatEvent reports whether an event ends a run (or its scan) on the
+// client — the signals that release the composer and stop the streaming
+// indicators. These are broadcast reliably so the SSE hub never drops them under
+// backpressure: a lost token delta is invisible (a later delta and the final
+// message resend the full text), but a lost terminal event leaves the UI stuck
+// "streaming" forever — a busy composer and a blinking cursor that never clears.
+func isTerminalChatEvent(t string) bool {
+ switch t {
+ case ChatEventMessage, ChatEventMessageEnd, ChatEventError,
+ ChatEventScanComplete, ChatEventScanError:
+ return true
+ }
+ return false
+}
+
func (s *Service) persistRuntimeChatEvent(sessionID string, event ChatEvent) {
if s == nil || s.store == nil || sessionID == "" {
return
@@ -923,6 +1134,24 @@ func (s *Service) persistRuntimeChatEvent(sessionID string, event ChatEvent) {
}
switch event.Type {
+ case ChatEventMessageEnd:
+ // The finalized assistant text for one turn — the commentary the model
+ // emits before (or between) its tool calls. Only the run's LAST turn used
+ // to survive a reload, persisted as the aggregate reply by
+ // completeAssistantRun; every earlier turn's text streamed live but was
+ // dropped from the store, so it vanished from any timeline rebuilt from it:
+ // a page reload, an SSE reconnect, or a session switch that revalidates
+ // against the store. Persist each turn's text as an assistant message keyed
+ // by its turn so buildTimelineFromMessages reconstructs it. The final turn
+ // shares a turn key with the aggregate reply, so on rebuild the two merge
+ // into one bubble instead of doubling. (message_start / message_delta stay
+ // unpersisted — they are streaming partials of this same finalized text.)
+ msg.Role = "assistant"
+ msg.Content = strings.TrimSpace(event.Content)
+ if msg.Content == "" {
+ return
+ }
+
case ChatEventThinking:
msg.Role = "system"
msg.Content = strings.TrimSpace(event.Content)
@@ -946,6 +1175,38 @@ func (s *Service) persistRuntimeChatEvent(sessionID string, event ChatEvent) {
msg.Content = event.Content
metadata["tool_call_id"] = event.ToolCallID
+ case ChatEventEval:
+ // Persist the round verdict so the eval badge survives a reload / session
+ // switch — buildTimelineFromMessages reconstructs it from content (reason)
+ // plus this metadata (round/pass).
+ msg.Role = "system"
+ msg.Content = event.EvalReason
+ metadata["eval_round"] = event.EvalRound
+ metadata["eval_pass"] = event.EvalPass
+ metadata["eval_reason"] = event.EvalReason
+
+ case ChatEventCompact:
+ msg.Role = "system"
+ msg.Content = fmt.Sprintf("context compacted: ~%d → ~%d tokens", event.CompactTokensBefore, event.CompactTokensAfter)
+ metadata["compact_tokens_before"] = event.CompactTokensBefore
+ metadata["compact_tokens_after"] = event.CompactTokensAfter
+ metadata["compact_kept_messages"] = event.CompactKeptMessages
+
+ case ChatEventScanComplete:
+ // Persist a lightweight marker so the inline scan card survives a reload /
+ // session switch. The heavy Result payload is NOT stored here — it stays
+ // reloadable via the session_scans link (getScan), and the client fills the
+ // card from its scanResults map keyed by this scan_id. Without this marker
+ // the scan is invisible to any timeline rebuilt from messages (a page
+ // reload, an SSE reconnect, or a session switch that revalidates against
+ // the store), even though the result itself is still fetchable.
+ if event.ScanID == "" {
+ return
+ }
+ msg.Role = "system"
+ msg.Content = "scan complete"
+ metadata["scan_id"] = event.ScanID
+
default:
return
}
@@ -956,7 +1217,7 @@ func (s *Service) persistRuntimeChatEvent(sessionID string, event ChatEvent) {
_ = s.store.AddMessage(context.Background(), msg)
}
-func (s *Service) HandleUserMessage(ctx context.Context, sessionID, content string) (*ChatMessage, error) {
+func (s *Service) HandleUserMessage(ctx context.Context, sessionID, content string, opts webproto.ChatPayload) (*ChatMessage, error) {
now := time.Now()
msg := &ChatMessage{
ID: generateID(),
@@ -983,14 +1244,124 @@ func (s *Service) HandleUserMessage(ctx context.Context, sessionID, content stri
_ = s.store.UpdateSession(ctx, session)
}
- go s.dispatchUserMessage(sessionID, msg)
+ go s.dispatchUserMessage(sessionID, msg, opts)
return msg, nil
}
-func (s *Service) dispatchUserMessage(sessionID string, msg *ChatMessage) {
+func (s *Service) dispatchUserMessage(sessionID string, msg *ChatMessage, opts webproto.ChatPayload) {
content := strings.TrimSpace(msg.Content)
- s.handleChatMessage(sessionID, content)
+
+ // A typed "/verb" is routed by scope. Hub-scope commands (scan pipeline,
+ // agent roster, merged help) run here. Agent-scope commands (/status,
+ // /provider, /, ...) and unknown verbs fall through to the agent,
+ // where the AgentConsole bridge runs the real REPL — so the full REPL
+ // command set and `!bash` work from the browser without a parallel switch.
+ if verb, args, ok := parseCommand(content); ok {
+ // /clear is a true "clear conversation" on the web: it must wipe the
+ // visible+persisted transcript, not just reset the agent's model context.
+ // Owned end-to-end by the hub so it does both (see handleClearCommand).
+ if verb == "clear" {
+ s.handleClearCommand(sessionID, opts)
+ return
+ }
+ if hubCommands[verb] {
+ s.runHubCommand(sessionID, verb, args)
+ return
+ }
+ }
+
+ s.handleChatMessage(sessionID, content, opts)
+}
+
+// handleClearCommand implements web /clear as "clear conversation": it deletes the
+// session's persisted messages (incl. the "/clear" message itself) and signals the
+// open UI to empty its timeline, then forwards /clear to the bound agent so its
+// in-memory model context resets too. The agent's "Context cleared." reply lands in
+// the now-empty transcript as the sole confirmation line; with no agent bound, the
+// emptied view is itself the confirmation.
+func (s *Service) handleClearCommand(sessionID string, opts webproto.ChatPayload) {
+ _ = s.store.ClearMessages(context.Background(), sessionID)
+ // Transient: a live-only signal to connected clients — the cleared state is
+ // already durable in the store, so a reconnecting client re-derives it on load.
+ s.BroadcastChatEvent(sessionID, ChatEvent{Type: ChatEventSessionCleared, Transient: true})
+ if s.sessionAgent(sessionID) != nil {
+ s.handleChatMessage(sessionID, "/clear", opts)
+ }
+}
+
+// runHubCommand executes a hub-scope slash command — one that needs hub state
+// (the scan pipeline, the connected-agent roster, or the merged help catalog).
+// name is the canonical catalog name without its leading slash. Agent-scope
+// commands never reach here; they fall through to the agent bridge.
+func (s *Service) runHubCommand(sessionID, name, args string) {
+ switch name {
+ case "scan":
+ s.handleScanCommand(sessionID, args)
+ case "agents":
+ s.handleAgentsCommand(sessionID)
+ case "help":
+ s.handleHelpCommand(sessionID)
+ }
+}
+
+// parseCommand splits a leading "/verb args..." into its lowercased verb
+// and the trimmed remainder. ok is false when content does not begin with a
+// non-empty "/verb".
+func parseCommand(content string) (cmd, args string, ok bool) {
+ if !strings.HasPrefix(content, "/") {
+ return "", "", false
+ }
+ rest := strings.TrimSpace(content[1:])
+ if rest == "" {
+ return "", "", false
+ }
+ if i := strings.IndexAny(rest, " \t\r\n"); i >= 0 {
+ return strings.ToLower(rest[:i]), strings.TrimSpace(rest[i:]), true
+ }
+ return strings.ToLower(rest), "", true
+}
+
+// handleHelpCommand renders the merged "/" command catalog (hub-scope plus the
+// bound agent's reported agent-scope commands) as a system message. Broadcast
+// with an empty code so the frontend shows this dynamic, already-localized text
+// verbatim instead of translating it.
+func (s *Service) handleHelpCommand(sessionID string) {
+ var b strings.Builder
+ b.WriteString("**Commands**\n")
+ for _, c := range s.SessionMenu(sessionID) {
+ syntax := c.Usage
+ if syntax == "" {
+ syntax = c.Name
+ }
+ if c.Description != "" {
+ fmt.Fprintf(&b, "- `%s` — %s\n", syntax, c.Description)
+ } else {
+ fmt.Fprintf(&b, "- `%s`\n", syntax)
+ }
+ }
+ b.WriteString("\n`!` 直接在 agent 上执行 shell/伪命令;其他文本作为对话发送给 agent。")
+ s.broadcastSystemMessage(sessionID, "", b.String(), nil)
+}
+
+// SessionMenu is the web "/" command catalog for a session: the hub-scope
+// commands plus the bound agent's reported agent-scope commands (its skills
+// included). It falls back to the static agent-scope menu when no agent is
+// bound, so the menu is populated even before an agent connects. This is the
+// single source both the "/" menu (GET .../commands) and /help render from.
+func (s *Service) SessionMenu(sessionID string) []webproto.CommandSpec {
+ hubSpecs := []webproto.CommandSpec{
+ {Name: "/help", Description: "查看命令面板"},
+ {Name: "/scan", Description: "在本会话运行扫描", Usage: "/scan [--mode full] [--verify] [--sniper] [--deep]"},
+ {Name: "/agents", Description: "列出已连接的 agent"},
+ }
+ agentSpecs := s.sessionAgent(sessionID).commandSpecs()
+ if len(agentSpecs) == 0 {
+ // Fall back to the static agent-scope menu when no agent is bound.
+ r := &tui.AgentConsole{}
+ agentSpecs = tui.WebMenuSpecs(r.StaticCommands())
+ }
+ return append(hubSpecs, agentSpecs...)
}
func (s *Service) handleScanCommand(sessionID, args string) {
@@ -1049,10 +1420,11 @@ func (s *Service) handleScanCommand(sessionID, args string) {
func (s *Service) handleAgentsCommand(sessionID string) {
if s.agents == nil || s.agents.Count() == 0 {
- s.broadcastSystemMessage(sessionID, "No agents connected.")
+ s.broadcastSystemMessage(sessionID, SysNoAgentsConnected, "No agents connected.", nil)
return
}
agents := s.agents.List()
+ list := make([]map[string]any, 0, len(agents))
var sb strings.Builder
sb.WriteString(fmt.Sprintf("%d agent(s) connected:\n", len(agents)))
for _, a := range agents {
@@ -1061,12 +1433,17 @@ func (s *Service) handleAgentsCommand(sessionID string) {
status = "busy"
}
sb.WriteString(fmt.Sprintf("- **%s** (%s) — %s", a.Name, a.ID[:8], status))
+ entry := map[string]any{"name": a.Name, "id": a.ID[:8], "busy": a.Busy}
if a.Identity.Model != "" {
sb.WriteString(fmt.Sprintf(" — %s/%s", a.Identity.Provider, a.Identity.Model))
+ entry["provider"] = a.Identity.Provider
+ entry["model"] = a.Identity.Model
}
sb.WriteString("\n")
+ list = append(list, entry)
}
- s.broadcastSystemMessage(sessionID, sb.String())
+ s.broadcastSystemMessage(sessionID, SysAgentsList, sb.String(),
+ map[string]any{"count": len(agents), "agents": list})
}
func (s *Service) sessionAgent(sessionID string) *remoteAgent {
@@ -1080,18 +1457,11 @@ func (s *Service) sessionAgent(sessionID string) *remoteAgent {
return s.agents.get(session.AgentID)
}
-func (s *Service) handleShellCommand(sessionID, command string) {
- command = strings.TrimSpace(command)
- if command == "" {
- return
- }
-
+func (s *Service) handleChatMessage(sessionID, content string, opts webproto.ChatPayload) {
agent := s.sessionAgent(sessionID)
if agent == nil {
- s.BroadcastChatEvent(sessionID, ChatEvent{
- Type: ChatEventError,
- Error: "agent is not connected",
- })
+ s.broadcastSystemMessage(sessionID, SysAgentNotConnected,
+ "Agent is not connected. Reconnect the agent to continue chatting.", nil)
return
}
@@ -1104,7 +1474,7 @@ func (s *Service) handleShellCommand(sessionID, command string) {
AgentName: agent.name,
})
- resultCh, err := s.agents.DispatchCommand(agent.id, taskID, command)
+ resultCh, err := s.agents.DispatchChatSession(agent.id, taskID, sessionID, content, opts)
if err != nil {
s.finishSessionTask(taskID)
s.BroadcastChatEvent(sessionID, ChatEvent{
@@ -1118,6 +1488,9 @@ func (s *Service) handleShellCommand(sessionID, command string) {
res, ok := <-resultCh
canceled := s.finishSessionTask(taskID)
if !ok {
+ // Agent dropped mid-run: signal completion so the composer releases
+ // instead of hanging on the streaming indicator (mirrors the command
+ // path above).
s.BroadcastChatEvent(sessionID, ChatEvent{
Type: ChatEventError,
Error: "agent disconnected",
@@ -1127,68 +1500,31 @@ func (s *Service) handleShellCommand(sessionID, command string) {
if canceled {
return
}
- content := res.Output
- if res.Err != "" {
- content = "Error: " + res.Err
- }
- if strings.TrimSpace(content) != "" {
- s.persistAssistantMessage(sessionID, agent.id, agent.name, content, res.Turn)
- }
- }()
-}
-
-func (s *Service) handleChatMessage(sessionID, content string) {
- agent := s.sessionAgent(sessionID)
- if agent == nil {
- s.broadcastSystemMessage(sessionID, "Agent is not connected. Reconnect the agent to continue chatting.")
- return
- }
-
- taskID := generateID()
- s.registerSessionTask(taskID, sessionID, agent.id)
-
- s.BroadcastChatEvent(sessionID, ChatEvent{
- Type: ChatEventAgentJoined,
- AgentID: agent.id,
- AgentName: agent.name,
- })
-
- resultCh, err := s.agents.DispatchChatSession(agent.id, taskID, sessionID, content)
- if err != nil {
- s.finishSessionTask(taskID)
- s.BroadcastChatEvent(sessionID, ChatEvent{
- Type: ChatEventError,
- Error: err.Error(),
- })
- return
- }
-
- go func() {
- res, ok := <-resultCh
- canceled := s.finishSessionTask(taskID)
- if !ok {
- return
- }
- if canceled {
- return
- }
reply := res.Output
if res.Err != "" {
reply = "Error: " + res.Err
}
- if strings.TrimSpace(reply) != "" {
- s.persistAssistantMessage(sessionID, agent.id, agent.name, reply, res.Turn)
- }
+ s.completeAssistantRun(sessionID, agent.id, agent.name, reply, res.Turn)
}()
}
-func (s *Service) broadcastSystemMessage(sessionID, content string) {
+// broadcastSystemMessage persists + broadcasts a system message. code names a
+// translatable template rendered client-side via i18n (see the Sys* codes);
+// fallback is the English text kept in Content for non-i18n consumers, logs and
+// tests. params feeds i18n interpolation and is stored next to code so the
+// message stays localizable after a reload.
+func (s *Service) broadcastSystemMessage(sessionID, code, fallback string, params map[string]any) {
now := time.Now()
+ var meta json.RawMessage
+ if code != "" {
+ meta, _ = json.Marshal(map[string]any{"code": code, "params": params})
+ }
msg := &ChatMessage{
ID: generateID(),
SessionID: sessionID,
Role: "system",
- Content: content,
+ Content: fallback,
+ Metadata: meta,
CreatedAt: now,
}
_ = s.store.AddMessage(context.Background(), msg)
@@ -1196,7 +1532,9 @@ func (s *Service) broadcastSystemMessage(sessionID, content string) {
Type: ChatEventMessage,
MessageID: msg.ID,
Role: "system",
- Content: content,
+ Content: fallback,
+ Code: code,
+ Params: params,
})
}
@@ -1217,31 +1555,40 @@ func (s *Service) broadcastScanComplete(scanID string, result *output.Result) {
})
}
-func (s *Service) persistAssistantMessage(sessionID, agentID, agentName, content string, turn int) {
+// completeAssistantRun is a run's terminal signal to the client. It always
+// broadcasts the aggregate assistant message so the UI finalizes the turn and
+// releases the composer — even when the run produced no final text (a tool-only
+// turn, or an eval run that hit its round cap). Skipping the broadcast on empty
+// content was what stranded the streaming indicator — the blinking cursor or the
+// "working" dots — forever. The reply is persisted only when it carries text, so
+// an empty completion never leaves a blank row in the transcript.
+func (s *Service) completeAssistantRun(sessionID, agentID, agentName, content string, turn int) {
content = strings.TrimRight(content, " \t\r\n")
- now := time.Now()
- msg := &ChatMessage{
- ID: generateID(),
- SessionID: sessionID,
+ event := ChatEvent{
+ Type: ChatEventMessage,
Role: "assistant",
AgentID: agentID,
AgentName: agentName,
+ Turn: turn,
Content: content,
- CreatedAt: now,
}
- if turn > 0 {
- if data, err := json.Marshal(map[string]any{"turn": turn}); err == nil {
- msg.Metadata = data
+ if content != "" {
+ msg := &ChatMessage{
+ ID: generateID(),
+ SessionID: sessionID,
+ Role: "assistant",
+ AgentID: agentID,
+ AgentName: agentName,
+ Content: content,
+ CreatedAt: time.Now(),
+ }
+ if turn > 0 {
+ if data, err := json.Marshal(map[string]any{"turn": turn}); err == nil {
+ msg.Metadata = data
+ }
}
+ _ = s.store.AddMessage(context.Background(), msg)
+ event.MessageID = msg.ID
}
- _ = s.store.AddMessage(context.Background(), msg)
- s.BroadcastChatEvent(sessionID, ChatEvent{
- Type: ChatEventMessage,
- MessageID: msg.ID,
- Role: "assistant",
- AgentID: agentID,
- AgentName: agentName,
- Turn: turn,
- Content: content,
- })
+ s.BroadcastChatEvent(sessionID, event)
}
diff --git a/pkg/web/service_test.go b/pkg/web/service_test.go
index 393092ed..cd2f3552 100644
--- a/pkg/web/service_test.go
+++ b/pkg/web/service_test.go
@@ -1,22 +1,21 @@
package web
import (
+ "context"
+ "path/filepath"
"reflect"
"strings"
"testing"
+ "time"
"github.com/chainreactors/aiscan/core/output"
+ "github.com/chainreactors/utils/parsers"
)
-func TestScanRequestAnalysisOptions(t *testing.T) {
- verify, sniper, deep := ScanRequest{Verify: true, Deep: true}.AnalysisOptions()
- if !verify || sniper || !deep {
- t.Fatalf("new analysis options = verify:%v sniper:%v deep:%v", verify, sniper, deep)
- }
-
- verify, sniper, deep = ScanRequest{AI: true}.AnalysisOptions()
- if !verify || !sniper || deep {
- t.Fatalf("legacy AI options = verify:%v sniper:%v deep:%v", verify, sniper, deep)
+func TestScanRequestFields(t *testing.T) {
+ req := ScanRequest{Verify: true, Deep: true}
+ if !req.Verify || req.Sniper || !req.Deep {
+ t.Fatalf("scan request = verify:%v sniper:%v deep:%v", req.Verify, req.Sniper, req.Deep)
}
}
@@ -43,6 +42,97 @@ func TestServiceStatusReportsLLMAvailability(t *testing.T) {
}
}
+func TestGetScanRebuildsLegacyMergedAssets(t *testing.T) {
+ store, err := NewSQLiteStore(filepath.Join(t.TempDir(), "scans.db"))
+ if err != nil {
+ t.Fatalf("NewSQLiteStore() error = %v", err)
+ }
+ defer store.Close()
+
+ now := time.Now()
+ job := &ScanJob{
+ ID: "legacy-merged-services",
+ Target: "111.63.65.103",
+ Mode: "quick",
+ Status: StatusCompleted,
+ CreatedAt: now,
+ UpdatedAt: now,
+ Result: &output.Result{
+ Services: []*parsers.GOGOResult{
+ {Ip: "111.63.65.103", Port: "80", Protocol: "http"},
+ {Ip: "111.63.65.103", Port: "443", Protocol: "https"},
+ {Ip: "111.63.65.103", Port: "icmp", Protocol: "icmp"},
+ },
+ WebProbes: []*parsers.SprayResult{
+ {UrlString: "http://111.63.65.103/", Status: 200, Source: parsers.CheckSource},
+ {UrlString: "https://111.63.65.103/", Status: 301, Source: parsers.CheckSource},
+ },
+ Assets: []output.Asset{{
+ Target: "https://111.63.65.103",
+ Items: []output.AssetItem{{
+ Kind: output.AssetItemResponse, Target: "https://111.63.65.103", Summary: "saved analysis",
+ }},
+ }},
+ },
+ }
+ if err := store.Create(context.Background(), job); err != nil {
+ t.Fatalf("Create() error = %v", err)
+ }
+
+ got, err := NewService(ServiceConfig{Store: store}).GetScan(context.Background(), job.ID)
+ if err != nil {
+ t.Fatalf("GetScan() error = %v", err)
+ }
+ if len(got.Result.Assets) != 3 {
+ t.Fatalf("assets = %d, want 3 separated services: %#v", len(got.Result.Assets), got.Result.Assets)
+ }
+ foundAnalysis := false
+ for _, asset := range got.Result.Assets {
+ for _, item := range asset.Items {
+ foundAnalysis = foundAnalysis || item.Kind == output.AssetItemResponse && item.Summary == "saved analysis"
+ }
+ }
+ if !foundAnalysis {
+ t.Fatal("supplemental analysis item was dropped during legacy asset rebuild")
+ }
+}
+
+func TestGetScanRebuildsLegacyWebOnlyAssets(t *testing.T) {
+ store, err := NewSQLiteStore(filepath.Join(t.TempDir(), "scans.db"))
+ if err != nil {
+ t.Fatalf("NewSQLiteStore() error = %v", err)
+ }
+ defer store.Close()
+
+ now := time.Now()
+ job := &ScanJob{
+ ID: "legacy-web-only",
+ Target: "111.63.65.103",
+ Mode: "quick",
+ Status: StatusCompleted,
+ CreatedAt: now,
+ UpdatedAt: now,
+ Result: &output.Result{
+ WebProbes: []*parsers.SprayResult{
+ {UrlString: "http://111.63.65.103/", Status: 200, Source: parsers.CheckSource},
+ {UrlString: "https://111.63.65.103/", Status: 301, Source: parsers.CheckSource},
+ },
+ Assets: []output.Asset{{Target: "http://111.63.65.103"}},
+ },
+ }
+ if err := store.Create(context.Background(), job); err != nil {
+ t.Fatalf("Create() error = %v", err)
+ }
+
+ got, err := NewService(ServiceConfig{Store: store}).GetScan(context.Background(), job.ID)
+ if err != nil {
+ t.Fatalf("GetScan() error = %v", err)
+ }
+ if len(got.Result.Assets) != 2 {
+ t.Fatalf("assets = %d, want separate http and https web origins: %#v", len(got.Result.Assets), got.Result.Assets)
+ }
+}
+
func TestBuildMarkdownReportKeepsAssetDetailAsMarkdown(t *testing.T) {
report := buildMarkdownReport("http://127.0.0.1:8092", "quick", &output.Result{
Summary: output.Summary{Targets: 1},
@@ -60,7 +150,7 @@ func TestBuildMarkdownReportKeepsAssetDetailAsMarkdown(t *testing.T) {
},
},
},
- })
+ }, "en")
for _, want := range []string{"## Evidence Analysis", "| Asset | Details |"} {
if !strings.Contains(report, want) {
@@ -68,3 +158,63 @@ func TestBuildMarkdownReportKeepsAssetDetailAsMarkdown(t *testing.T) {
}
}
}
+
+func TestBuildMarkdownReportLocalizedAndDeNoised(t *testing.T) {
+ result := &output.Result{
+ Summary: output.Summary{Targets: 1, Services: 3, Webs: 2, Probes: 2, Duration: "22.266s"},
+ Assets: []output.Asset{
+ {
+ Target: "http://111.63.65.103:80",
+ Title: "BWS/1.1",
+ Items: []output.AssetItem{
+ {Kind: output.AssetItemService, Source: "gogo_portscan", Data: map[string]any{"service": "http", "port": "80"}},
+ {Kind: output.AssetItemPath, Status: "301"},
+ {Kind: output.AssetItemPath, Status: "200"},
+ },
+ },
+ {
+ // Bare live host — only an icmp echo. Must fold into the trailing
+ // list, not claim its own ### section, and must not inflate the host count.
+ Target: "111.63.65.103:icmp",
+ Key: "111.63.65.103:icmp",
+ Items: []output.AssetItem{
+ {Kind: output.AssetItemService, Source: "gogo_portscan", Data: map[string]any{"service": "icmp"}},
+ },
+ },
+ },
+ }
+
+ zh := buildMarkdownReport("baidu.com", "quick", result, "zh")
+ for _, want := range []string{"# 侦察报告", "## 概述", "快速侦察", "1 台主机", "其他存活主机"} {
+ if !strings.Contains(zh, want) {
+ t.Fatalf("zh report missing %q:\n%s", want, zh)
+ }
+ }
+ // The "去 AI 味" contract: no internal scanner names leak, no generic English boilerplate title.
+ if strings.Contains(zh, "gogo_portscan") {
+ t.Errorf("zh report leaks scanner source name:\n%s", zh)
+ }
+ if strings.Contains(zh, "战利品") {
+ t.Errorf("zh report leaks internal loot terminology:\n%s", zh)
+ }
+ if strings.Contains(zh, "Penetration Test Report") || strings.Contains(zh, "| Metric | Value |") {
+ t.Errorf("zh report still uses the old boilerplate:\n%s", zh)
+ }
+ // icmp is folded, so it must not appear as its own heading.
+ if strings.Contains(zh, "### 111.63.65.103:icmp") {
+ t.Errorf("bare icmp host got its own section:\n%s", zh)
+ }
+
+ en := buildMarkdownReport("baidu.com", "quick", result, "en")
+ for _, want := range []string{"## Overview", "Quick recon", "1 host", "Other live hosts"} {
+ if !strings.Contains(en, want) {
+ t.Fatalf("en report missing %q:\n%s", want, en)
+ }
+ }
+ if strings.Contains(en, "gogo_portscan") {
+ t.Errorf("en report leaks scanner source name:\n%s", en)
+ }
+ if strings.Contains(strings.ToLower(en), "loot") {
+ t.Errorf("en report leaks internal loot terminology:\n%s", en)
+ }
+}
diff --git a/pkg/web/sse.go b/pkg/web/sse.go
index cba002c1..0e82da8e 100644
--- a/pkg/web/sse.go
+++ b/pkg/web/sse.go
@@ -4,8 +4,11 @@ import (
"encoding/json"
"fmt"
"net/http"
+ "slices"
"sync"
"time"
+
+ "github.com/chainreactors/aiscan/pkg/webproto"
)
// HubEvent is the unit broadcast through the SSE hub. Type is the SSE
@@ -13,14 +16,16 @@ import (
type HubEvent struct {
Type string
Data json.RawMessage
+ // Reliable marks a terminal event that Broadcast must not drop under
+ // backpressure: on a full buffer it evicts the oldest queued event to seat
+ // one, rather than shedding it like a token delta. See isTerminalChatEvent
+ // for which events qualify and why a lost one strands the UI.
+ Reliable bool
}
-type BroadcastCallback func(id string, event HubEvent)
-
type Hub struct {
mu sync.Mutex
subscribers map[string]map[chan HubEvent]struct{}
- callback BroadcastCallback
}
func NewHub() *Hub {
@@ -29,12 +34,6 @@ func NewHub() *Hub {
}
}
-func (h *Hub) OnBroadcast(cb BroadcastCallback) {
- h.mu.Lock()
- h.callback = cb
- h.mu.Unlock()
-}
-
func (h *Hub) Subscribe(id string) (<-chan HubEvent, func()) {
ch := make(chan HubEvent, 64)
h.mu.Lock()
@@ -58,17 +57,30 @@ func (h *Hub) Subscribe(id string) (<-chan HubEvent, func()) {
func (h *Hub) Broadcast(id string, event HubEvent) {
h.mu.Lock()
- cb := h.callback
for ch := range h.subscribers[id] {
select {
case ch <- event:
default:
+ // Buffer full. A non-reliable event (a token delta) is simply
+ // dropped — a later cumulative delta and the final message resend the
+ // same text. A reliable (terminal) event must not be the one dropped,
+ // so evict the oldest queued event to make room. Safe under h.mu: no
+ // other Broadcast fills this channel concurrently (so the resend is
+ // guaranteed room), and unsubscribe takes h.mu before close(ch), so
+ // ch is still open here.
+ if event.Reliable {
+ select {
+ case <-ch:
+ default:
+ }
+ select {
+ case ch <- event:
+ default:
+ }
+ }
}
}
h.mu.Unlock()
- if cb != nil {
- cb(id, event)
- }
}
func ServeSSE(w http.ResponseWriter, r *http.Request, hub *Hub, id string, terminalEvents ...string) {
@@ -115,20 +127,8 @@ func isTerminalEvent(eventType string, terminalEvents []string) bool {
if len(terminalEvents) == 0 {
return eventType == "complete" || eventType == "error"
}
- for _, t := range terminalEvents {
- if eventType == t {
- return true
- }
- }
- return false
+ return slices.Contains(terminalEvents, eventType)
}
-// mustJSON marshals v to json.RawMessage. Panics on error (should never
-// happen with map/struct inputs).
-func mustJSON(v any) json.RawMessage {
- data, err := json.Marshal(v)
- if err != nil {
- panic(err)
- }
- return data
-}
+// mustJSON is a package-local alias for webproto.MustJSON.
+var mustJSON = webproto.MustJSON
diff --git a/pkg/web/sse_test.go b/pkg/web/sse_test.go
new file mode 100644
index 00000000..66149d97
--- /dev/null
+++ b/pkg/web/sse_test.go
@@ -0,0 +1,235 @@
+package web
+
+import (
+ "context"
+ "encoding/json"
+ "path/filepath"
+ "testing"
+)
+
+// A saturated subscriber buffer must never swallow a terminal event: the hub
+// evicts the oldest queued (droppable) delta to make room. This is the fix that
+// keeps a finished run from stranding the composer as "busy" with a blinking
+// cursor when the closing message_end / message is lost to backpressure.
+func TestHubBroadcastReliableSurvivesBackpressure(t *testing.T) {
+ h := NewHub()
+ ch, unsub := h.Subscribe("s1")
+ defer unsub()
+
+ // Saturate the 64-slot buffer with droppable deltas while nobody reads.
+ const bufCap = 64
+ for i := 0; i < bufCap; i++ {
+ h.Broadcast("s1", HubEvent{Type: ChatEventMessageDelta, Data: mustJSON(i)})
+ }
+
+ // One more droppable event has nowhere to go: it is silently dropped, never
+ // blocking and never displacing a queued event.
+ h.Broadcast("s1", HubEvent{Type: ChatEventMessageDelta, Data: mustJSON("overflow")})
+
+ // A terminal event onto the same full buffer must land, evicting the oldest.
+ h.Broadcast("s1", HubEvent{Type: ChatEventMessageEnd, Data: mustJSON("done"), Reliable: true})
+
+ drained := make([]HubEvent, 0, bufCap)
+ for len(ch) > 0 {
+ drained = append(drained, <-ch)
+ }
+
+ if len(drained) != bufCap {
+ t.Fatalf("buffer size = %d, want %d", len(drained), bufCap)
+ }
+
+ var sawTerminal, sawOverflow bool
+ for _, e := range drained {
+ if e.Type == ChatEventMessageEnd {
+ sawTerminal = true
+ }
+ if string(e.Data) == string(mustJSON("overflow")) {
+ sawOverflow = true
+ }
+ }
+ if !sawTerminal {
+ t.Error("terminal (reliable) event was dropped under backpressure")
+ }
+ if sawOverflow {
+ t.Error("non-reliable overflow event should have been dropped, not queued")
+ }
+}
+
+// isTerminalChatEvent is the only test of the reliability classification: the
+// run-ending signals must all qualify, or the stuck-cursor bug returns. Whether
+// mid-stream types stay droppable is low-stakes (a mis-marked delta only adds
+// eviction churn), so it isn't asserted.
+func TestIsTerminalChatEvent(t *testing.T) {
+ for _, ty := range []string{
+ ChatEventMessage, ChatEventMessageEnd, ChatEventError,
+ ChatEventScanComplete, ChatEventScanError,
+ } {
+ if !isTerminalChatEvent(ty) {
+ t.Errorf("%q should be terminal (reliable)", ty)
+ }
+ }
+}
+
+// A run that ends with no final text (a tool-only turn, or an eval run that hit
+// its round cap) must still broadcast the terminal message so the client
+// finalizes the turn and releases the composer — but it must not leave a blank
+// assistant row in the transcript. A run with real text does both.
+func TestCompleteAssistantRunAlwaysSignalsButPersistsOnlyText(t *testing.T) {
+ store, err := NewSQLiteStore(filepath.Join(t.TempDir(), "web.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer store.Close()
+ svc := NewService(ServiceConfig{Store: store})
+
+ const sid = "sess-terminal"
+ ch, unsub := svc.Hub().Subscribe(sessionTopic(sid))
+ defer unsub()
+
+ // Empty completion: broadcasts the terminal signal, persists nothing.
+ svc.completeAssistantRun(sid, "agent-1", "Agent One", " ", 1)
+ if got := drainEventTypes(ch); len(got) != 1 || got[0] != ChatEventMessage {
+ t.Fatalf("empty completion broadcast = %v, want one %q", got, ChatEventMessage)
+ }
+ if msgs, _ := store.ListMessages(context.Background(), sid, 100); len(msgs) != 0 {
+ t.Fatalf("empty completion persisted %d messages, want 0", len(msgs))
+ }
+
+ // Text completion: same terminal signal, plus the reply is persisted.
+ svc.completeAssistantRun(sid, "agent-1", "Agent One", "done", 2)
+ if got := drainEventTypes(ch); len(got) != 1 || got[0] != ChatEventMessage {
+ t.Fatalf("text completion broadcast = %v, want one %q", got, ChatEventMessage)
+ }
+ msgs, _ := store.ListMessages(context.Background(), sid, 100)
+ if len(msgs) != 1 || msgs[0].Content != "done" {
+ t.Fatalf("text completion persisted %+v, want one message %q", msgs, "done")
+ }
+}
+
+// A run's intermediate assistant text — the commentary the model streams before
+// its tool calls — must be persisted, not just streamed. Before this, only the
+// final aggregate reply (completeAssistantRun) survived, so every earlier turn's
+// text vanished from any timeline rebuilt from the store: a page reload, an SSE
+// reconnect, or a session switch that revalidates against it. It is persisted as
+// an assistant message carrying its turn, so buildTimelineFromMessages keys it to
+// the right bubble. Streaming partials (message_start / message_delta) stay out.
+func TestMessageEndPersistsIntermediateAssistantText(t *testing.T) {
+ store, err := NewSQLiteStore(filepath.Join(t.TempDir(), "web.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer store.Close()
+ svc := NewService(ServiceConfig{Store: store})
+
+ const sid = "sess-msgend"
+
+ // Streaming partials of the same text: live-only, never persisted.
+ svc.BroadcastChatEvent(sid, ChatEvent{Type: ChatEventMessageStart, Role: "assistant", Content: "有意", Turn: 1})
+ svc.BroadcastChatEvent(sid, ChatEvent{Type: ChatEventMessageDelta, Role: "assistant", Content: "有意思", Turn: 1})
+ // Finalized turn-1 commentary: persisted so a rebuild can show it.
+ svc.BroadcastChatEvent(sid, ChatEvent{Type: ChatEventMessageEnd, Role: "assistant", Content: "有意思!charge.js 暴露了内部 API", Turn: 1})
+ // Whitespace-only end (a tool-only turn): nothing to persist.
+ svc.BroadcastChatEvent(sid, ChatEvent{Type: ChatEventMessageEnd, Role: "assistant", Content: " \n ", Turn: 2})
+
+ msgs, err := store.ListMessages(context.Background(), sid, 100)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(msgs) != 1 {
+ t.Fatalf("persisted messages = %d, want 1 (only the non-empty message_end)", len(msgs))
+ }
+ got := msgs[0]
+ if got.Role != "assistant" || got.Content != "有意思!charge.js 暴露了内部 API" {
+ t.Fatalf("persisted message = {role:%q content:%q}, want assistant commentary", got.Role, got.Content)
+ }
+ var metadata map[string]any
+ if err := json.Unmarshal(got.Metadata, &metadata); err != nil {
+ t.Fatalf("metadata json: %v", err)
+ }
+ // The turn is what keys this text to its bubble on rebuild; without it a
+ // multi-turn run collapses its intermediate texts into one slot.
+ if metadata["turn"] != float64(1) {
+ t.Fatalf("turn metadata = %#v, want 1", metadata["turn"])
+ }
+}
+
+func TestEvalEventPersistsVerdictMetadata(t *testing.T) {
+ store, err := NewSQLiteStore(filepath.Join(t.TempDir(), "web.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer store.Close()
+ svc := NewService(ServiceConfig{Store: store})
+
+ svc.BroadcastChatEvent("sess-eval", ChatEvent{
+ Type: ChatEventEval,
+ EvalRound: 2,
+ EvalPass: false,
+ EvalReason: "needs one more verified finding",
+ })
+
+ msgs, err := store.ListMessages(context.Background(), "sess-eval", 100)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(msgs) != 1 {
+ t.Fatalf("persisted messages = %d, want 1", len(msgs))
+ }
+ var metadata map[string]any
+ if err := json.Unmarshal(msgs[0].Metadata, &metadata); err != nil {
+ t.Fatalf("metadata json: %v", err)
+ }
+ if metadata["event_type"] != ChatEventEval || metadata["eval_reason"] != "needs one more verified finding" {
+ t.Fatalf("eval metadata = %#v", metadata)
+ }
+ if metadata["eval_round"] != float64(2) || metadata["eval_pass"] != false {
+ t.Fatalf("eval verdict metadata = %#v", metadata)
+ }
+}
+
+func TestScanCompletePersistsMarkerMetadata(t *testing.T) {
+ store, err := NewSQLiteStore(filepath.Join(t.TempDir(), "web.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer store.Close()
+ svc := NewService(ServiceConfig{Store: store})
+
+ // A completed scan must leave a durable marker so its inline card survives a
+ // timeline rebuild (reload / session switch). The heavy Result is intentionally
+ // not stored — only the scan_id, which the client re-hydrates via scan_ids.
+ svc.BroadcastChatEvent("sess-scan", ChatEvent{
+ Type: ChatEventScanComplete,
+ ScanID: "scan-123",
+ })
+
+ msgs, err := store.ListMessages(context.Background(), "sess-scan", 100)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(msgs) != 1 {
+ t.Fatalf("persisted messages = %d, want 1", len(msgs))
+ }
+ var metadata map[string]any
+ if err := json.Unmarshal(msgs[0].Metadata, &metadata); err != nil {
+ t.Fatalf("metadata json: %v", err)
+ }
+ if metadata["event_type"] != ChatEventScanComplete || metadata["scan_id"] != "scan-123" {
+ t.Fatalf("scan marker metadata = %#v", metadata)
+ }
+
+ // A marker with no scan id is meaningless — it must not create a phantom row.
+ svc.BroadcastChatEvent("sess-scan-empty", ChatEvent{Type: ChatEventScanComplete})
+ empty, _ := store.ListMessages(context.Background(), "sess-scan-empty", 100)
+ if len(empty) != 0 {
+ t.Fatalf("empty-scanID persisted messages = %d, want 0", len(empty))
+ }
+}
+
+func drainEventTypes(ch <-chan HubEvent) []string {
+ var out []string
+ for len(ch) > 0 {
+ out = append(out, (<-ch).Type)
+ }
+ return out
+}
diff --git a/pkg/web/store.go b/pkg/web/store.go
deleted file mode 100644
index 03fa5342..00000000
--- a/pkg/web/store.go
+++ /dev/null
@@ -1,35 +0,0 @@
-package web
-
-import (
- "context"
-
- "github.com/chainreactors/aiscan/core/output"
-)
-
-type Store interface {
- // Scan CRUD
- Create(ctx context.Context, job *ScanJob) error
- Get(ctx context.Context, id string) (*ScanJob, error)
- List(ctx context.Context, limit int) ([]*ScanJob, error)
- Update(ctx context.Context, job *ScanJob) error
- Delete(ctx context.Context, id string) error
-
- // Chat sessions
- CreateSession(ctx context.Context, session *ChatSession) error
- GetSession(ctx context.Context, id string) (*ChatSession, error)
- ListSessions(ctx context.Context, limit int) ([]*ChatSession, error)
- UpdateSession(ctx context.Context, session *ChatSession) error
- DeleteSession(ctx context.Context, id string) error
-
- // Chat messages
- AddMessage(ctx context.Context, msg *ChatMessage) error
- ListMessages(ctx context.Context, sessionID string, limit int) ([]*ChatMessage, error)
-
- // Session-scan association
- LinkScanToSession(ctx context.Context, sessionID, scanID string) error
- SessionScanIDs(ctx context.Context, sessionID string) ([]string, error)
-
- // Records
- InsertRecord(ctx context.Context, rec *output.Record) error
- InsertRecords(ctx context.Context, recs []*output.Record) error
-}
diff --git a/pkg/web/store_sqlite.go b/pkg/web/store_sqlite.go
index 86ed5ca6..d0e0419d 100644
--- a/pkg/web/store_sqlite.go
+++ b/pkg/web/store_sqlite.go
@@ -124,15 +124,26 @@ func migrate(db *sql.DB) error {
return err
}
+ if _, err := db.Exec(`
+ CREATE TABLE IF NOT EXISTS sco_nodes (
+ cstx_id TEXT PRIMARY KEY,
+ cstx_type TEXT NOT NULL,
+ data TEXT NOT NULL,
+ scan_id TEXT NOT NULL DEFAULT '',
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+ );
+ `); err != nil {
+ return err
+ }
+
_, err := db.Exec(`
CREATE INDEX IF NOT EXISTS idx_scans_created ON scans(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_sessions_updated ON chat_sessions(updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_sessions_agent ON chat_sessions(agent_id);
CREATE INDEX IF NOT EXISTS idx_messages_session ON chat_messages(session_id, created_at);
- CREATE INDEX IF NOT EXISTS idx_records_scan ON records(scan_id, type, created_at);
- CREATE INDEX IF NOT EXISTS idx_records_session ON records(session_id, type);
- CREATE INDEX IF NOT EXISTS idx_records_type ON records(type, created_at DESC);
- CREATE INDEX IF NOT EXISTS idx_records_priority ON records(priority, type);
+ CREATE INDEX IF NOT EXISTS idx_sco_nodes_type ON sco_nodes(cstx_type);
+ CREATE INDEX IF NOT EXISTS idx_sco_nodes_scan ON sco_nodes(scan_id);
`)
return err
}
@@ -195,12 +206,11 @@ func (s *SQLiteStore) Close() error {
}
func (s *SQLiteStore) Create(ctx context.Context, job *ScanJob) error {
- normalizeJobAnalysis(job)
resultJSON := marshalResult(job)
_, err := s.db.ExecContext(ctx,
`INSERT INTO scans (id, target, mode, ai, verify, sniper, deep, status, progress, report, result, error, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
- job.ID, job.Target, job.Mode, boolToInt(job.AI), boolToInt(job.Verify), boolToInt(job.Sniper), boolToInt(job.Deep),
+ job.ID, job.Target, job.Mode, boolToInt(job.Verify || job.Sniper), boolToInt(job.Verify), boolToInt(job.Sniper), boolToInt(job.Deep),
string(job.Status), job.Progress, job.Report, resultJSON, job.Error,
job.CreatedAt.Format(time.RFC3339Nano), job.UpdatedAt.Format(time.RFC3339Nano),
)
@@ -238,11 +248,10 @@ func (s *SQLiteStore) List(ctx context.Context, limit int) ([]*ScanJob, error) {
}
func (s *SQLiteStore) Update(ctx context.Context, job *ScanJob) error {
- normalizeJobAnalysis(job)
resultJSON := marshalResult(job)
_, err := s.db.ExecContext(ctx,
`UPDATE scans SET ai=?, verify=?, sniper=?, deep=?, status=?, progress=?, report=?, result=?, error=?, updated_at=? WHERE id=?`,
- boolToInt(job.AI), boolToInt(job.Verify), boolToInt(job.Sniper), boolToInt(job.Deep),
+ boolToInt(job.Verify || job.Sniper), boolToInt(job.Verify), boolToInt(job.Sniper), boolToInt(job.Deep),
string(job.Status), job.Progress, job.Report, resultJSON, job.Error,
job.UpdatedAt.Format(time.RFC3339Nano), job.ID,
)
@@ -267,11 +276,10 @@ func scanFromScanner(sc scanner) (*ScanJob, error) {
if err != nil {
return nil, err
}
- job.AI = ai != 0
+ _ = ai
job.Verify = verify != 0
job.Sniper = sniper != 0
job.Deep = deep != 0
- normalizeJobAnalysis(&job)
job.Status = ScanStatus(status)
if resultJSON != "" {
_ = json.Unmarshal([]byte(resultJSON), &job.Result)
@@ -288,17 +296,6 @@ func boolToInt(value bool) int {
return 0
}
-func normalizeJobAnalysis(job *ScanJob) {
- if job == nil {
- return
- }
- if job.AI && !job.Verify && !job.Sniper {
- job.Verify = true
- job.Sniper = true
- }
- job.AI = job.Verify || job.Sniper
-}
-
func marshalResult(job *ScanJob) string {
if job == nil || job.Result == nil {
return ""
@@ -396,6 +393,14 @@ func (s *SQLiteStore) AddMessage(ctx context.Context, msg *ChatMessage) error {
return err
}
+// ClearMessages deletes every message in a session without removing the session
+// itself — the store half of web /clear ("clear conversation"). Messages are leaf
+// rows (nothing references them), so a single delete suffices.
+func (s *SQLiteStore) ClearMessages(ctx context.Context, sessionID string) error {
+ _, err := s.db.ExecContext(ctx, `DELETE FROM chat_messages WHERE session_id = ?`, sessionID)
+ return err
+}
+
func (s *SQLiteStore) ListMessages(ctx context.Context, sessionID string, limit int) ([]*ChatMessage, error) {
if limit <= 0 {
limit = 500
@@ -454,16 +459,7 @@ func (s *SQLiteStore) SessionScanIDs(ctx context.Context, sessionID string) ([]s
// --- Records ---
func (s *SQLiteStore) InsertRecord(ctx context.Context, rec *output.Record) error {
- tagsJSON, _ := json.Marshal(rec.Tags)
- _, err := s.db.ExecContext(ctx,
- `INSERT OR IGNORE INTO records (id, type, scan_id, session_id, agent_id, source, target, turn, priority, summary, loot, tags, data, created_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
- rec.ID, string(rec.Type), rec.ScanID, rec.SessionID, rec.AgentID,
- rec.Source, rec.Target, rec.Turn, rec.Priority, rec.Summary,
- boolToInt(rec.Loot), string(tagsJSON), string(rec.Data),
- rec.Timestamp.Format(time.RFC3339Nano),
- )
- return err
+ return s.InsertRecords(ctx, []*output.Record{rec})
}
func (s *SQLiteStore) InsertRecords(ctx context.Context, recs []*output.Record) error {
@@ -478,7 +474,7 @@ func (s *SQLiteStore) InsertRecords(ctx context.Context, recs []*output.Record)
`INSERT OR IGNORE INTO records (id, type, scan_id, session_id, agent_id, source, target, turn, priority, summary, loot, tags, data, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
if err != nil {
- tx.Rollback()
+ _ = tx.Rollback()
return err
}
defer stmt.Close()
@@ -490,10 +486,112 @@ func (s *SQLiteStore) InsertRecords(ctx context.Context, recs []*output.Record)
boolToInt(rec.Loot), string(tagsJSON), string(rec.Data),
rec.Timestamp.Format(time.RFC3339Nano),
); err != nil {
- tx.Rollback()
+ _ = tx.Rollback()
return err
}
}
return tx.Commit()
}
+// ── SCO Nodes ──
+
+func (s *SQLiteStore) UpsertSCONodes(ctx context.Context, scanID string, nodes []json.RawMessage) error {
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ stmt, err := tx.PrepareContext(ctx,
+ `INSERT OR REPLACE INTO sco_nodes (cstx_id, cstx_type, data, scan_id, created_at, updated_at)
+ VALUES (?, ?, ?, ?, COALESCE((SELECT created_at FROM sco_nodes WHERE cstx_id = ?), ?), ?)`)
+ if err != nil {
+ _ = tx.Rollback()
+ return err
+ }
+ defer stmt.Close()
+ now := time.Now().Format(time.RFC3339Nano)
+ for _, raw := range nodes {
+ var header struct {
+ Type string `json:"cstx_type"`
+ ID string `json:"cstx_id"`
+ }
+ if json.Unmarshal(raw, &header) != nil || header.ID == "" {
+ continue
+ }
+ if _, err := stmt.ExecContext(ctx,
+ header.ID, header.Type, string(raw), scanID, header.ID, now, now,
+ ); err != nil {
+ _ = tx.Rollback()
+ return err
+ }
+ }
+ return tx.Commit()
+}
+
+func (s *SQLiteStore) ListSCONodes(ctx context.Context, nodeType string, limit int) ([]json.RawMessage, error) {
+ return s.ListSCONodesByScanID(ctx, "", nodeType, limit)
+}
+
+func (s *SQLiteStore) ListSCONodesByScanID(ctx context.Context, scanID, nodeType string, limit int) ([]json.RawMessage, error) {
+ var where []string
+ var args []any
+ if scanID != "" {
+ where = append(where, "scan_id = ?")
+ args = append(args, scanID)
+ }
+ if nodeType != "" {
+ where = append(where, "cstx_type = ?")
+ args = append(args, nodeType)
+ }
+ query := "SELECT data FROM sco_nodes"
+ if len(where) > 0 {
+ query += " WHERE " + strings.Join(where, " AND ")
+ }
+ query += " ORDER BY updated_at DESC LIMIT ?"
+ args = append(args, limit)
+ rows, err := s.db.QueryContext(ctx, query, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var nodes []json.RawMessage
+ for rows.Next() {
+ var data string
+ if err := rows.Scan(&data); err != nil {
+ return nil, err
+ }
+ nodes = append(nodes, json.RawMessage(data))
+ }
+ return nodes, rows.Err()
+}
+
+func (s *SQLiteStore) GetSCONode(ctx context.Context, cstxID string) (json.RawMessage, error) {
+ var data string
+ err := s.db.QueryRowContext(ctx, `SELECT data FROM sco_nodes WHERE cstx_id = ?`, cstxID).Scan(&data)
+ if err != nil {
+ return nil, err
+ }
+ return json.RawMessage(data), nil
+}
+
+func (s *SQLiteStore) DeleteSCONodesByScan(ctx context.Context, scanID string) error {
+ _, err := s.db.ExecContext(ctx, `DELETE FROM sco_nodes WHERE scan_id = ?`, scanID)
+ return err
+}
+
+func (s *SQLiteStore) SCONodeStats(ctx context.Context) (map[string]int, error) {
+ rows, err := s.db.QueryContext(ctx, `SELECT cstx_type, COUNT(*) FROM sco_nodes GROUP BY cstx_type`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ stats := make(map[string]int)
+ for rows.Next() {
+ var t string
+ var c int
+ if err := rows.Scan(&t, &c); err != nil {
+ return nil, err
+ }
+ stats[t] = c
+ }
+ return stats, rows.Err()
+}
diff --git a/pkg/web/store_sqlite_test.go b/pkg/web/store_sqlite_test.go
index 03f4e08e..d1eb3c45 100644
--- a/pkg/web/store_sqlite_test.go
+++ b/pkg/web/store_sqlite_test.go
@@ -33,37 +33,7 @@ func TestSQLiteStorePersistsAnalysisOptions(t *testing.T) {
if err != nil {
t.Fatalf("Get() error = %v", err)
}
- if !got.Verify || got.Sniper || !got.AI || !got.Deep {
- t.Fatalf("stored options = verify:%v sniper:%v ai:%v deep:%v", got.Verify, got.Sniper, got.AI, got.Deep)
- }
-}
-
-func TestSQLiteStoreMapsLegacyAIToVerifyAndSniper(t *testing.T) {
- store, err := NewSQLiteStore(filepath.Join(t.TempDir(), "scans.db"))
- if err != nil {
- t.Fatalf("NewSQLiteStore() error = %v", err)
- }
- defer store.Close()
-
- now := time.Now()
- job := &ScanJob{
- ID: "scan-legacy",
- Target: "127.0.0.1",
- Mode: "quick",
- AI: true,
- Status: StatusQueued,
- CreatedAt: now,
- UpdatedAt: now,
- }
- if err := store.Create(context.Background(), job); err != nil {
- t.Fatalf("Create() error = %v", err)
- }
-
- got, err := store.Get(context.Background(), job.ID)
- if err != nil {
- t.Fatalf("Get() error = %v", err)
- }
- if !got.Verify || !got.Sniper || !got.AI {
- t.Fatalf("legacy options = verify:%v sniper:%v ai:%v", got.Verify, got.Sniper, got.AI)
+ if !got.Verify || got.Sniper || !got.Deep {
+ t.Fatalf("stored options = verify:%v sniper:%v deep:%v", got.Verify, got.Sniper, got.Deep)
}
}
diff --git a/pkg/web/types.go b/pkg/web/types.go
index 3927f2dd..aa634245 100644
--- a/pkg/web/types.go
+++ b/pkg/web/types.go
@@ -24,7 +24,6 @@ type ScanJob struct {
Mode string `json:"mode"`
Verify bool `json:"verify,omitempty"`
Sniper bool `json:"sniper,omitempty"`
- AI bool `json:"ai,omitempty"`
Deep bool `json:"deep,omitempty"`
Status ScanStatus `json:"status"`
Progress string `json:"progress,omitempty"`
@@ -40,20 +39,11 @@ type ScanRequest struct {
Mode string `json:"mode"`
Verify bool `json:"verify,omitempty"`
Sniper bool `json:"sniper,omitempty"`
- AI bool `json:"ai,omitempty"`
Deep bool `json:"deep,omitempty"`
}
-func (r ScanRequest) AnalysisOptions() (verify, sniper, deep bool) {
- verify, sniper, deep = r.Verify, r.Sniper, r.Deep
- if r.AI && !verify && !sniper {
- verify = true
- sniper = true
- }
- return verify, sniper, deep
-}
-
type ServiceStatus struct {
+ Version string `json:"version"`
LLMAvailable bool `json:"llm_available"`
LLMProvider string `json:"llm_provider,omitempty"`
LLMModel string `json:"llm_model,omitempty"`
@@ -61,6 +51,7 @@ type ServiceStatus struct {
ConfigPath string `json:"config_path,omitempty"`
ConfigLoaded bool `json:"config_loaded"`
Agents int `json:"agents"`
+ IOAURL string `json:"ioa_url,omitempty"`
}
// ConfigStatus is the response for GET /api/config — secrets masked,
@@ -90,8 +81,7 @@ type ConfigStatus struct {
Limit *int `json:"limit,omitempty"`
} `json:"recon"`
Scan struct {
- Verify string `json:"verify"`
- VerifyTimeout int `json:"verify_timeout"`
+ Verify string `json:"verify"`
} `json:"scan"`
Search struct {
TavilyKeysConfigured bool `json:"tavily_keys_configured"`
@@ -130,7 +120,6 @@ func ConfigStatusFromDistribute(d *webproto.DistributeConfig, path string, loade
cs.Recon.Proxy = d.Recon.Proxy
cs.Recon.Limit = d.Recon.Limit
cs.Scan.Verify = d.Scan.Verify
- cs.Scan.VerifyTimeout = d.Scan.VerifyTimeout
cs.Search.TavilyKeysConfigured = d.Search.TavilyKeys != ""
cs.IOA.URL = d.IOA.URL
cs.IOA.TokenConfigured = d.IOA.Token != ""
@@ -173,19 +162,35 @@ type ChatMessage struct {
}
const (
- ChatEventMessage = "message"
- ChatEventMessageStart = "message_start"
- ChatEventMessageDelta = "message_delta"
- ChatEventMessageEnd = "message_end"
- ChatEventToolCall = "tool_call"
- ChatEventToolResult = "tool_result"
- ChatEventThinking = "thinking"
- ChatEventScanStarted = "scan_started"
- ChatEventScanProgress = "scan_progress"
- ChatEventScanComplete = "scan_complete"
- ChatEventScanError = "scan_error"
- ChatEventAgentJoined = "agent_joined"
- ChatEventError = "error"
+ ChatEventMessage = "message"
+ ChatEventMessageStart = "message_start"
+ ChatEventMessageDelta = "message_delta"
+ ChatEventMessageEnd = "message_end"
+ ChatEventToolCall = "tool_call"
+ ChatEventToolResult = "tool_result"
+ ChatEventThinking = "thinking"
+ ChatEventScanStarted = "scan_started"
+ ChatEventScanProgress = "scan_progress"
+ ChatEventScanComplete = "scan_complete"
+ ChatEventScanError = "scan_error"
+ ChatEventAgentJoined = "agent_joined"
+ ChatEventSessionCleared = "session_cleared"
+ ChatEventEval = "eval"
+ ChatEventCompact = "compact"
+ ChatEventError = "error"
+)
+
+// System message codes. A backend-generated system message carries a stable
+// Code (+ optional Params) so the client can localize it via i18n; Content
+// holds an English fallback for non-i18n consumers, logs and tests. Keys are
+// mirrored under `sys.*` in web/frontend/src/i18n/locales/*/chat.ts.
+const (
+ SysNoRunningTask = "no_running_task"
+ SysPaused = "paused"
+ SysFileUploaded = "file_uploaded" // params: filename, path
+ SysNoAgentsConnected = "no_agents_connected"
+ SysAgentsList = "agents_list" // params: count, agents[]
+ SysAgentNotConnected = "agent_not_connected"
)
type ChatEvent struct {
@@ -205,11 +210,26 @@ type ChatEvent struct {
Result *output.Result `json:"result,omitempty"`
Data string `json:"data,omitempty"`
Error string `json:"error,omitempty"`
- Transient bool `json:"-"`
+ Code string `json:"code,omitempty"`
+ Params map[string]any `json:"params,omitempty"`
+ // Goal-mode evaluator verdict, carried on ChatEventEval. EvalRound is
+ // 0-indexed (the client renders round+1).
+ EvalRound int `json:"eval_round,omitempty"`
+ EvalPass bool `json:"eval_pass,omitempty"`
+ EvalReason string `json:"eval_reason,omitempty"`
+
+ CompactTokensBefore int `json:"compact_tokens_before,omitempty"`
+ CompactTokensAfter int `json:"compact_tokens_after,omitempty"`
+ CompactKeptMessages int `json:"compact_kept_messages,omitempty"`
+
+ Transient bool `json:"-"`
}
type SendMessageRequest struct {
Content string `json:"content"`
+ // Goal-mode run controls (optional). The frontend sends these when the user
+ // enables the Goal panel; a plain chat send leaves them zero.
+ webproto.ChatPayload
}
type CreateSessionRequest struct {
diff --git a/pkg/webagent/agent.go b/pkg/webagent/agent.go
index 9a5af749..a102692e 100644
--- a/pkg/webagent/agent.go
+++ b/pkg/webagent/agent.go
@@ -7,10 +7,11 @@ import (
"encoding/json"
"fmt"
"io"
+ "net/http"
"net/url"
"os"
- "path/filepath"
"os/user"
+ "path/filepath"
"runtime"
"strings"
"sync"
@@ -21,6 +22,7 @@ import (
"github.com/chainreactors/aiscan/core/output"
"github.com/chainreactors/aiscan/core/runner"
"github.com/chainreactors/aiscan/pkg/agent"
+ "github.com/chainreactors/aiscan/pkg/agent/evaluator"
"github.com/chainreactors/aiscan/pkg/agent/tmux"
"github.com/chainreactors/aiscan/pkg/commands"
"github.com/chainreactors/aiscan/pkg/telemetry"
@@ -123,8 +125,18 @@ func runConnectionOnce(ctx context.Context, serverURL, name string, reg *command
if reg == nil {
return fmt.Errorf("command registry is nil")
}
- wsURL := httpToWS(serverURL) + "/api/agent/ws"
- conn, wsResp, err := websocket.DefaultDialer.DialContext(ctx, wsURL, nil)
+ // The hub gates /api/* behind an access key (see pkg/web/auth.go). The key
+ // rides in the serverURL userinfo (http://@host, set by the local-agent
+ // launcher). gorilla/websocket ignores URL userinfo, so lift the key out and
+ // present it as a Bearer token, dialing a userinfo-free URL. With no key the
+ // header stays nil and behaviour is unchanged (auth-disabled hub / tests).
+ dialURL, accessKey := splitAccessKey(serverURL)
+ wsURL := httpToWS(dialURL) + "/api/agent/ws"
+ var reqHeader http.Header
+ if accessKey != "" {
+ reqHeader = http.Header{"Authorization": {"Bearer " + accessKey}}
+ }
+ conn, wsResp, err := websocket.DefaultDialer.DialContext(ctx, wsURL, reqHeader)
if wsResp != nil && wsResp.Body != nil {
wsResp.Body.Close()
}
@@ -272,7 +284,8 @@ func runConnectionOnce(ctx context.Context, serverURL, name string, reg *command
}(msg, taskCtx, cancel)
case "chat":
- webSessionID := chatSessionID(msg)
+ chatOpts := parseChatPayload(msg)
+ webSessionID := chatOpts.SessionID
ag, agErr := chatRuntime.agentFor(webSessionID)
if agErr != nil {
send(webproto.Message{Type: "error", TaskID: msg.TaskID, Data: agErr.Error()})
@@ -291,12 +304,24 @@ func runConnectionOnce(ctx context.Context, serverURL, name string, reg *command
mu.Unlock()
if ag.IsRunning() {
- // Agent is busy — append to inbox; the loop picks it up.
+ // Agent is busy — append to inbox; the loop picks it up. Leave any
+ // pending upload notes queued so they ride the next idle turn rather
+ // than being drained into a steer that may not surface them.
ag.SteerUserMessage(prompt)
send(webproto.Message{Type: "complete", TaskID: msg.TaskID})
continue
}
+ // Idle turn: fold in files uploaded to this session since the last turn so
+ // the agent learns their absolute on-disk paths and can read them. REPL/`!`
+ // lines are left untouched so a note never corrupts a command; the note
+ // stays queued for the next natural-language turn.
+ if !isREPLCommand(prompt) {
+ if note := chatRuntime.takePendingUploads(webSessionID); note != "" {
+ msg.Data = note + "\n\n" + prompt
+ }
+ }
+
// Agent is idle — start a new run with this message.
chatCtx, chatCancel := context.WithCancel(ctx)
mu.Lock()
@@ -314,11 +339,25 @@ func runConnectionOnce(ctx context.Context, serverURL, name string, reg *command
}
mu.Unlock()
}()
- runChatWithAgent(cCtx, m, ag, rt, send)
+ runChatWithAgent(cCtx, m, chatOpts, ag, rt, send)
}(msg, chatCtx, chatCancel)
case "upload":
- go handleFileUpload(msg, send)
+ go handleFileUpload(msg, send, chatRuntime)
+
+ case "config":
+ // Hub pushed a config change (LLM provider/model/key). Re-fetch and
+ // hot-swap the provider off the read loop so a slow fetch never
+ // stalls it; reloadProvider serializes concurrent pushes. On success,
+ // re-announce identity so the hub/UI reflect the swapped provider/model —
+ // identity is otherwise sent only once, at registration, so its badge
+ // would keep showing the pre-reload model.
+ go func() {
+ if provider, model, ok := reloadAgentConfig(serverURL, rt, chatRuntime); ok {
+ payload, _ := json.Marshal(webproto.AgentIdentity{Provider: provider.Name(), Model: model})
+ send(webproto.Message{Type: "agent.identity", Payload: payload})
+ }
+ }()
case "cancel":
mu.Lock()
@@ -543,21 +582,67 @@ func execCommand(ctx context.Context, taskID, cmdLine string, reg *commands.Comm
send(webproto.Message{Type: "complete", TaskID: taskID, Data: out})
}
-type chatRequestPayload struct {
- SessionID string `json:"session_id,omitempty"`
+// parseChatPayload decodes the "chat" WS payload: the web session to scope the
+// agent conversation to, plus optional Goal-mode run controls.
+func parseChatPayload(msg webproto.Message) webproto.ChatPayload {
+ var payload webproto.ChatPayload
+ if len(msg.Payload) > 0 {
+ _ = json.Unmarshal(msg.Payload, &payload)
+ }
+ payload.SessionID = strings.TrimSpace(payload.SessionID)
+ payload.EvalCriteria = strings.TrimSpace(payload.EvalCriteria)
+ return payload
}
type chatRuntimeManager struct {
rt *runner.AgentRuntime
mu sync.Mutex
sessions map[string]*agent.Agent
+
+ uploadMu sync.Mutex
+ uploads map[string][]string // web sessionID → notes about files uploaded since the last turn
}
func newChatRuntimeManager(rt *runner.AgentRuntime) *chatRuntimeManager {
return &chatRuntimeManager{
rt: rt,
sessions: make(map[string]*agent.Agent),
+ uploads: make(map[string][]string),
+ }
+}
+
+// notePendingUpload records that a file was written to the agent's local disk for
+// a web session. The hub's SysFileUploaded broadcast only reaches the UI, so the
+// LLM never learns the path on its own; the note is folded into the session's next
+// natural-language turn (see the "chat" dispatch) so "read the file" resolves to
+// the real absolute path instead of a bare filename against the cwd.
+func (m *chatRuntimeManager) notePendingUpload(sessionID, note string) {
+ if m == nil || note == "" {
+ return
+ }
+ if sessionID == "" {
+ sessionID = "default"
}
+ m.uploadMu.Lock()
+ m.uploads[sessionID] = append(m.uploads[sessionID], note)
+ m.uploadMu.Unlock()
+}
+
+// takePendingUploads drains and joins the pending upload notes for a session,
+// returning "" when there are none. Draining is one-shot so each note reaches
+// exactly one turn. The empty session ID normalizes to "default" to match agentFor.
+func (m *chatRuntimeManager) takePendingUploads(sessionID string) string {
+ if m == nil {
+ return ""
+ }
+ if sessionID == "" {
+ sessionID = "default"
+ }
+ m.uploadMu.Lock()
+ notes := m.uploads[sessionID]
+ delete(m.uploads, sessionID)
+ m.uploadMu.Unlock()
+ return strings.Join(notes, "\n")
}
func (m *chatRuntimeManager) agentFor(sessionID string) (*agent.Agent, error) {
@@ -580,7 +665,55 @@ func (m *chatRuntimeManager) agentFor(sessionID string) (*agent.Agent, error) {
return ag, nil
}
-func runChatWithAgent(ctx context.Context, msg webproto.Message, ag *agent.Agent, rt *runner.AgentRuntime, send func(webproto.Message)) {
+// reloadProvider rebuilds the LLM provider from option and hot-swaps it across
+// the runtime template (rt.App + rt.Config) and every live session, all under
+// m.mu so a concurrent agentFor never clones a half-updated template. A run
+// already in flight finishes on its old provider; the next message uses the new
+// one.
+func (m *chatRuntimeManager) reloadProvider(option *cfg.Option) (agent.Provider, string, error) {
+ if m == nil || m.rt == nil {
+ return nil, "", fmt.Errorf("agent runtime is not configured")
+ }
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ provider, model, err := m.rt.ReloadProvider(option)
+ if err != nil {
+ return nil, "", err
+ }
+ for _, ag := range m.sessions {
+ ag.SetProvider(provider, model)
+ }
+ return provider, model, nil
+}
+
+// reloadAgentConfig re-fetches the hub config and hot-swaps the LLM provider so
+// a running agent picks up a Settings change without a restart. Best-effort: a
+// fetch/build failure leaves the current provider in place. serverURL is the hub
+// base the agent already dials. Returns the live provider, resolved model, and
+// true when the swap succeeded, so the caller can re-announce identity.
+func reloadAgentConfig(serverURL string, rt *runner.AgentRuntime, cr *chatRuntimeManager) (agent.Provider, string, bool) {
+ if rt == nil {
+ return nil, "", false
+ }
+ logger := rt.Config.Logger
+ if logger == nil {
+ logger = telemetry.NopLogger()
+ }
+ remoteOpt, err := cfg.FetchRemoteConfig(serverURL)
+ if err != nil {
+ logger.Warnf("config reload: fetch remote config: %s", err)
+ return nil, "", false
+ }
+ provider, model, err := cr.reloadProvider(remoteOpt)
+ if err != nil {
+ logger.Warnf("config reload: rebuild provider: %s", err)
+ return nil, "", false
+ }
+ logger.Importantf("config reloaded: provider=%s model=%s", provider.Name(), model)
+ return provider, model, true
+}
+
+func runChatWithAgent(ctx context.Context, msg webproto.Message, opts webproto.ChatPayload, ag *agent.Agent, rt *runner.AgentRuntime, send func(webproto.Message)) {
prompt := strings.TrimSpace(msg.Data)
if rt == nil || rt.App == nil {
send(webproto.Message{
@@ -610,6 +743,23 @@ func runChatWithAgent(ctx context.Context, msg webproto.Message, ag *agent.Agent
return
}
+ // Goal "达成条件" mode: run the agent under an independent evaluator that
+ // judges the natural-language criteria each round and re-drives the agent
+ // with feedback until it passes (or the round budget is spent).
+ if opts.EvalCriteria != "" {
+ ag.SetMaxTurns(rt.Config.MaxTurns) // each eval round runs to natural completion
+ runChatEval(ctx, msg, prompt, opts, ag, rt, send)
+ return
+ }
+
+ // Goal "固定轮次" mode caps this run at PersistMaxTurns; otherwise restore
+ // the session default so a prior capped message never leaks its cap forward.
+ if opts.PersistMaxTurns > 0 {
+ ag.SetMaxTurns(opts.PersistMaxTurns)
+ } else {
+ ag.SetMaxTurns(rt.Config.MaxTurns)
+ }
+
result, err := ag.Run(ctx, prompt)
if err != nil {
send(webproto.Message{Type: "error", TaskID: msg.TaskID, Data: err.Error()})
@@ -622,15 +772,35 @@ func runChatWithAgent(ctx context.Context, msg webproto.Message, ag *agent.Agent
send(webproto.Message{Type: "complete", TaskID: msg.TaskID, Data: trimChatOutput(result.Output)})
}
-func chatSessionID(msg webproto.Message) string {
- var payload chatRequestPayload
- if len(msg.Payload) > 0 && json.Unmarshal(msg.Payload, &payload) == nil {
- return strings.TrimSpace(payload.SessionID)
+// runChatEval drives the agent through the evaluator loop for a Goal with
+// natural-language acceptance criteria, using the agent's own provider/model as
+// the independent judge. The final agent output is returned as the chat reply;
+// per-round progress streams over rt.Bus like any other agent run.
+func runChatEval(ctx context.Context, msg webproto.Message, prompt string, opts webproto.ChatPayload, ag *agent.Agent, rt *runner.AgentRuntime, send func(webproto.Message)) {
+ evalCfg := evaluator.EvalLoopConfig{
+ Evaluator: evaluator.New(evaluator.Config{
+ Provider: rt.App.Provider,
+ Model: rt.Config.Model,
+ Logger: rt.Config.Logger,
+ }),
+ MaxEvalRounds: opts.EvalMaxRounds,
+ Goal: prompt,
+ Criteria: opts.EvalCriteria,
+ Bus: rt.Bus,
}
- return ""
+ result, _, err := evaluator.RunWithEval(ctx, ag, evalCfg)
+ if err != nil {
+ send(webproto.Message{Type: "error", TaskID: msg.TaskID, Data: err.Error()})
+ return
+ }
+ if result == nil {
+ send(webproto.Message{Type: "complete", TaskID: msg.TaskID})
+ return
+ }
+ send(webproto.Message{Type: "complete", TaskID: msg.TaskID, Data: trimChatOutput(result.Output)})
}
-func handleFileUpload(msg webproto.Message, send func(webproto.Message)) {
+func handleFileUpload(msg webproto.Message, send func(webproto.Message), cr *chatRuntimeManager) {
var payload webproto.FileUploadPayload
if len(msg.Payload) > 0 {
_ = json.Unmarshal(msg.Payload, &payload)
@@ -644,7 +814,7 @@ func handleFileUpload(msg webproto.Message, send func(webproto.Message)) {
send(webproto.Message{
Type: "complete",
TaskID: msg.TaskID,
- Payload: mustJSON(webproto.FileUploadResult{Filename: payload.Filename, Error: "decode failed: " + err.Error()}),
+ Payload: webproto.MustJSON(webproto.FileUploadResult{Filename: payload.Filename, Error: "decode failed: " + err.Error()}),
})
return
}
@@ -657,16 +827,23 @@ func handleFileUpload(msg webproto.Message, send func(webproto.Message)) {
send(webproto.Message{
Type: "complete",
TaskID: msg.TaskID,
- Payload: mustJSON(webproto.FileUploadResult{Filename: payload.Filename, Error: "write failed: " + err.Error()}),
+ Payload: webproto.MustJSON(webproto.FileUploadResult{Filename: payload.Filename, Error: "write failed: " + err.Error()}),
})
return
}
+ // Surface the absolute on-disk path to the agent's next turn. Without this the
+ // LLM only ever sees the hub's UI-only "file uploaded" notice and, asked to read
+ // the file, guesses the bare filename against its cwd — which is not the upload dir.
+ cr.notePendingUpload(payload.SessionID, fmt.Sprintf(
+ "[已上传文件] 名称=%q 大小=%d 字节 · agent 本地绝对路径: %s\n(该文件已保存在 agent 磁盘上,需要查看内容时用 read 工具打开上述绝对路径。)",
+ payload.Filename, len(data), dest))
+
send(webproto.Message{
Type: "complete",
TaskID: msg.TaskID,
Data: dest,
- Payload: mustJSON(webproto.FileUploadResult{
+ Payload: webproto.MustJSON(webproto.FileUploadResult{
Filename: payload.Filename,
Path: dest,
Size: int64(len(data)),
@@ -674,11 +851,6 @@ func handleFileUpload(msg webproto.Message, send func(webproto.Message)) {
})
}
-func mustJSON(v any) json.RawMessage {
- data, _ := json.Marshal(v)
- return data
-}
-
func isREPLCommand(prompt string) bool {
return strings.HasPrefix(prompt, "/") || strings.HasPrefix(prompt, "!")
}
@@ -715,13 +887,37 @@ func runChatREPLLine(ctx context.Context, line string, rt *runner.AgentRuntime,
}
return "", err
}
- if out == "" {
- return errOut, nil
- }
- if errOut == "" {
- return out, nil
+ combined := out
+ switch {
+ case out == "":
+ combined = errOut
+ case errOut != "":
+ combined = trimChatOutput(out + "\n" + errOut)
}
- return trimChatOutput(out + "\n" + errOut), nil
+ return fenceTerminalOutput(combined), nil
+}
+
+// fenceTerminalOutput wraps multi-line REPL/`!` command output in a Markdown
+// code fence. runChatREPLLine runs the same TUI console the interactive REPL
+// uses, whose panels (/status, /provider, /nodes …) are drawn with box-drawing
+// characters and column padding that only line up in a fixed-width,
+// newline-preserving context. The web chat renders replies as Markdown prose,
+// which collapses single newlines to spaces and uses a proportional font — so an
+// unfenced panel flattens into one mangled line. A fence makes the frontend
+// render it verbatim in a monospace . Single-line output (short status
+// confirmations like "Provider ready: …") is left as prose.
+func fenceTerminalOutput(s string) string {
+ if !strings.Contains(s, "\n") {
+ return s
+ }
+ // Opening fence must be longer than any backtick run inside the payload
+ // (a `!cat` of a Markdown file could contain ```); grow it until it can't
+ // collide. Panel output never contains backticks, so this is just insurance.
+ fence := "```"
+ for strings.Contains(s, fence) {
+ fence += "`"
+ }
+ return fence + "\n" + s + "\n" + fence
}
func trimChatOutput(value string) string {
@@ -781,10 +977,11 @@ func (t *agentStatsTracker) Observe(e agent.Event) (webproto.AgentStats, bool) {
func agentRegisterPayload(name string, reg *commands.CommandRegistry, rt *runner.AgentRuntime, stats webproto.AgentStats) webproto.RegisterPayload {
payload := webproto.RegisterPayload{
- Name: name,
- Commands: reg.Names(),
- Stats: stats,
- Identity: agentIdentity(rt),
+ Name: name,
+ Commands: reg.Names(),
+ CommandsMenu: agentCommandCatalog(rt),
+ Stats: stats,
+ Identity: agentIdentity(rt),
}
if payload.Identity.NodeName == "" {
payload.Identity.NodeName = name
@@ -792,6 +989,29 @@ func agentRegisterPayload(name string, reg *commands.CommandRegistry, rt *runner
return payload
}
+// agentCommandCatalog is the agent's user-facing "/verb" catalog reported to the
+// hub on register: the static agent-scope menu commands plus one per loaded (and
+// non-internal) skill. The hub merges it with its hub-scope commands to build
+// the web "/" menu and /help, so the menu reflects what this agent can run.
+func agentCommandCatalog(rt *runner.AgentRuntime) []webproto.CommandSpec {
+ // Build a zero-value console to extract command metadata without a live session.
+ r := &tui.AgentConsole{}
+ specs := tui.WebMenuSpecs(r.StaticCommands())
+ if rt == nil || rt.App == nil || rt.App.Skills == nil {
+ return specs
+ }
+ for _, sk := range rt.App.Skills.Skills {
+ if strings.TrimSpace(sk.Name) == "" || sk.Internal {
+ continue
+ }
+ specs = append(specs, webproto.CommandSpec{
+ Name: "/" + strings.TrimPrefix(strings.TrimSpace(sk.Name), "/"),
+ Description: sk.Description,
+ })
+ }
+ return specs
+}
+
func agentIdentity(rt *runner.AgentRuntime) webproto.AgentIdentity {
identity := webproto.AgentIdentity{
OS: runtime.GOOS,
@@ -924,6 +1144,19 @@ func remoteIOAConfig(option *cfg.Option) *cfg.IOAConfig {
}
}
+// splitAccessKey lifts the access token out of a URL's userinfo
+// (http://@host…), returning a userinfo-free URL plus the token. A URL
+// without userinfo (or an unparseable one) comes back unchanged with an empty token.
+func splitAccessKey(rawURL string) (dialURL, token string) {
+ u, err := url.Parse(rawURL)
+ if err != nil || u.User == nil {
+ return rawURL, ""
+ }
+ token = u.User.Username()
+ u.User = nil
+ return u.String(), token
+}
+
func httpToWS(rawURL string) string {
u, err := url.Parse(strings.TrimRight(rawURL, "/"))
if err != nil {
diff --git a/pkg/webagent/agent_test.go b/pkg/webagent/agent_test.go
index 89d4fadf..353509f5 100644
--- a/pkg/webagent/agent_test.go
+++ b/pkg/webagent/agent_test.go
@@ -417,7 +417,7 @@ func TestRunConnectionPushesPTYSessionsOnManagerEvents(t *testing.T) {
func readSessionMessage(t *testing.T, updates <-chan webproto.Message, match func(webproto.Message) bool) webproto.Message {
t.Helper()
- deadline := time.After(5 * time.Second)
+ deadline := time.After(20 * time.Second)
for {
select {
case msg := <-updates:
@@ -433,7 +433,7 @@ func readSessionMessage(t *testing.T, updates <-chan webproto.Message, match fun
func readSessionUpdate(t *testing.T, updates <-chan webproto.Message, match func(webproto.PTYPayload) bool) webproto.Message {
t.Helper()
- deadline := time.After(5 * time.Second)
+ deadline := time.After(20 * time.Second)
for {
select {
case msg := <-updates:
@@ -478,3 +478,28 @@ func payloadHasSessionActivity(raw json.RawMessage, sessionID string) bool {
}
return false
}
+
+func TestFenceTerminalOutput(t *testing.T) {
+ // Single-line status stays prose — no fence.
+ if got := fenceTerminalOutput("Provider ready: anthropic / glm-5.2"); strings.Contains(got, "```") {
+ t.Errorf("single-line output should not be fenced, got %q", got)
+ }
+ // Multi-line panel (box art) gets fenced so the web renders it monospace.
+ panel := "╭────╮\n│ providers │\n╰────╯"
+ got := fenceTerminalOutput(panel)
+ if !strings.HasPrefix(got, "```\n") || !strings.HasSuffix(got, "\n```") {
+ t.Errorf("multi-line panel should be wrapped in a code fence, got %q", got)
+ }
+ if !strings.Contains(got, panel) {
+ t.Errorf("fenced output should preserve the panel verbatim, got %q", got)
+ }
+ // A payload containing a triple-backtick run grows the fence so it can't collide.
+ got = fenceTerminalOutput("line1\n```\nline2")
+ if !strings.HasPrefix(got, "````\n") {
+ t.Errorf("fence must be longer than an inner backtick run, got %q", got)
+ }
+ // Empty stays empty.
+ if got := fenceTerminalOutput(""); got != "" {
+ t.Errorf("empty input should stay empty, got %q", got)
+ }
+}
diff --git a/pkg/webagent/upload_test.go b/pkg/webagent/upload_test.go
new file mode 100644
index 00000000..b1e7c317
--- /dev/null
+++ b/pkg/webagent/upload_test.go
@@ -0,0 +1,98 @@
+package webagent
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/chainreactors/aiscan/pkg/webproto"
+)
+
+// Pending upload notes must drain exactly once per session, stay scoped to their
+// own session, and normalize the empty session ID to "default" (matching agentFor)
+// so an upload and the chat turn that references it land in the same bucket.
+func TestPendingUploadsDrainOncePerSession(t *testing.T) {
+ m := newChatRuntimeManager(nil)
+
+ m.notePendingUpload("s1", "note-a")
+ m.notePendingUpload("s1", "note-b")
+ m.notePendingUpload("s2", "note-c")
+
+ got := m.takePendingUploads("s1")
+ if got != "note-a\nnote-b" {
+ t.Fatalf("s1 first drain = %q, want %q", got, "note-a\nnote-b")
+ }
+ if again := m.takePendingUploads("s1"); again != "" {
+ t.Fatalf("s1 second drain = %q, want empty (drain is one-shot)", again)
+ }
+ if got := m.takePendingUploads("s2"); got != "note-c" {
+ t.Fatalf("s2 drain = %q, want %q", got, "note-c")
+ }
+
+ // Empty session ID collapses to "default" on both sides.
+ m.notePendingUpload("", "note-default")
+ if got := m.takePendingUploads("default"); got != "note-default" {
+ t.Fatalf("default drain = %q, want %q", got, "note-default")
+ }
+}
+
+func TestPendingUploadsNilManagerSafe(t *testing.T) {
+ var m *chatRuntimeManager
+ m.notePendingUpload("s1", "note") // must not panic
+ if got := m.takePendingUploads("s1"); got != "" {
+ t.Fatalf("nil manager drain = %q, want empty", got)
+ }
+}
+
+// handleFileUpload must write the bytes to the agent's local disk AND queue a note
+// carrying that absolute path for the session's next turn — the fix for the LLM
+// only ever seeing the hub's UI-only "file uploaded" notice and then guessing a
+// bare filename against its cwd.
+func TestHandleFileUploadRecordsAbsolutePathForNextTurn(t *testing.T) {
+ m := newChatRuntimeManager(nil)
+
+ const filename = "aiscan_test_upload_probe.txt"
+ const body = "codex public proof\nkey=appImage/probe"
+ dest := filepath.Join(os.TempDir(), "aiscan-uploads", filename)
+ t.Cleanup(func() { _ = os.Remove(dest) })
+
+ payload, _ := json.Marshal(webproto.FileUploadPayload{Filename: filename, SessionID: "sess-1"})
+ msg := webproto.Message{
+ Type: "upload",
+ TaskID: "task-1",
+ DataB64: base64.StdEncoding.EncodeToString([]byte(body)),
+ Payload: payload,
+ }
+
+ var got webproto.Message
+ handleFileUpload(msg, func(out webproto.Message) { got = out }, m)
+
+ // The agent replied with the written path and no error.
+ var res webproto.FileUploadResult
+ if err := json.Unmarshal(got.Payload, &res); err != nil {
+ t.Fatalf("decode result: %v", err)
+ }
+ if res.Error != "" {
+ t.Fatalf("unexpected upload error: %s", res.Error)
+ }
+ if res.Path != dest {
+ t.Fatalf("result path = %q, want %q", res.Path, dest)
+ }
+
+ // The bytes actually landed on disk.
+ if data, err := os.ReadFile(dest); err != nil || string(data) != body {
+ t.Fatalf("file on disk = %q, err=%v; want %q", data, err, body)
+ }
+
+ // The next turn for this session carries the absolute path so `read` resolves.
+ note := m.takePendingUploads("sess-1")
+ if !strings.Contains(note, dest) {
+ t.Fatalf("pending note %q does not carry absolute path %q", note, dest)
+ }
+ if !strings.Contains(note, filename) {
+ t.Fatalf("pending note %q does not name the file %q", note, filename)
+ }
+}
diff --git a/pkg/webproto/config.go b/pkg/webproto/config.go
index b30a9a87..62046ef9 100644
--- a/pkg/webproto/config.go
+++ b/pkg/webproto/config.go
@@ -26,8 +26,7 @@ type DistributeConfig struct {
Limit *int `json:"limit,omitempty" yaml:"limit,omitempty"`
} `json:"recon" yaml:"recon"`
Scan struct {
- Verify string `json:"verify" yaml:"verify"`
- VerifyTimeout int `json:"verify_timeout" yaml:"verify_timeout"`
+ Verify string `json:"verify" yaml:"verify"`
} `json:"scan" yaml:"scan"`
Search struct {
TavilyKeys string `json:"tavily_keys,omitempty" yaml:"tavily_keys"`
diff --git a/pkg/webproto/message.go b/pkg/webproto/message.go
index 83f23023..adf68e58 100644
--- a/pkg/webproto/message.go
+++ b/pkg/webproto/message.go
@@ -20,11 +20,26 @@ type Message struct {
Payload json.RawMessage `json:"payload,omitempty"`
}
+// CommandSpec is the surface-neutral description of one user-facing "/verb" command.
+type CommandSpec struct {
+ Name string `json:"name"`
+ Aliases []string `json:"aliases,omitempty"`
+ Usage string `json:"usage,omitempty"`
+ Description string `json:"description,omitempty"`
+}
+
type RegisterPayload struct {
- Name string `json:"name"`
- Commands []string `json:"commands,omitempty"`
- Identity AgentIdentity `json:"identity,omitempty"`
- Stats AgentStats `json:"stats,omitempty"`
+ Name string `json:"name"`
+ // Commands is the LLM tool/pseudo-command registry (pkg/commands) the agent
+ // exposes to the model — distinct from CommandsMenu.
+ Commands []string `json:"commands,omitempty"`
+ // CommandsMenu is the agent's user-facing "/verb" catalog: the agent-scope,
+ // menu-visible commands it can run, plus one per loaded skill. The hub merges
+ // these with its own hub-scope commands to drive the web "/" menu and /help,
+ // so the surfaces never drift.
+ CommandsMenu []CommandSpec `json:"commands_menu,omitempty"`
+ Identity AgentIdentity `json:"identity,omitempty"`
+ Stats AgentStats `json:"stats,omitempty"`
}
type AgentIdentity struct {
@@ -58,6 +73,17 @@ type AgentStats struct {
LastEvent string `json:"last_event,omitempty"`
}
+// ChatPayload is the WS payload for a "chat" message: it scopes the remote
+// agent conversation to a web session and carries optional Goal-mode run
+// controls. Empty EvalCriteria means a plain turn; a non-empty one makes the
+// agent run the evaluator loop against the criteria for up to EvalMaxRounds.
+type ChatPayload struct {
+ SessionID string `json:"session_id,omitempty"`
+ EvalCriteria string `json:"eval_criteria,omitempty"`
+ EvalMaxRounds int `json:"eval_max_rounds,omitempty"`
+ PersistMaxTurns int `json:"persist_max_turns,omitempty"`
+}
+
type FileUploadPayload struct {
Filename string `json:"filename"`
FileSize int64 `json:"file_size"`
@@ -159,10 +185,10 @@ func FrameToMessage(frame pty.Frame) Message {
Singleton: frame.Singleton,
}
encodePayloadData(&payload, frame.Data)
- msg.Payload = mustMarshal(payload)
+ msg.Payload = MustJSON(payload)
case pty.FrameOutput:
if frame.SessionID != "" {
- msg.Payload = mustMarshal(map[string]any{"session_id": frame.SessionID})
+ msg.Payload = MustJSON(map[string]any{"session_id": frame.SessionID})
}
encodeMessageData(&msg, frame.Data)
case pty.FrameError:
@@ -172,7 +198,7 @@ func FrameToMessage(frame pty.Frame) Message {
msg.Data = string(frame.Data)
}
case pty.FrameOpened:
- msg.Payload = mustMarshal(map[string]any{
+ msg.Payload = MustJSON(map[string]any{
"session_id": frame.SessionID,
"kind": frame.Kind,
"name": frame.Name,
@@ -180,16 +206,16 @@ func FrameToMessage(frame pty.Frame) Message {
"session": frame.Session,
})
case pty.FrameAttached:
- msg.Payload = mustMarshal(map[string]any{
+ msg.Payload = MustJSON(map[string]any{
"session_id": frame.SessionID,
"session": frame.Session,
})
case pty.FrameDetached:
- msg.Payload = mustMarshal(map[string]any{"session_id": frame.SessionID})
+ msg.Payload = MustJSON(map[string]any{"session_id": frame.SessionID})
case pty.FrameSessions:
- msg.Payload = mustMarshal(map[string]any{"sessions": frame.Sessions})
+ msg.Payload = MustJSON(map[string]any{"sessions": frame.Sessions})
case pty.FrameClosed:
- msg.Payload = mustMarshal(map[string]any{
+ msg.Payload = MustJSON(map[string]any{
"session_id": frame.SessionID,
"state": frame.State,
"exit_code": frame.ExitCode,
@@ -277,7 +303,7 @@ func encodePayloadData(payload *PTYPayload, data []byte) {
payload.DataB64 = base64.StdEncoding.EncodeToString(data)
}
-func mustMarshal(v any) json.RawMessage {
+func MustJSON(v any) json.RawMessage {
data, _ := json.Marshal(v)
return data
}
diff --git a/skills/embed.go b/skills/embed.go
index 4a24ffbc..8c70546b 100644
--- a/skills/embed.go
+++ b/skills/embed.go
@@ -368,11 +368,6 @@ func (s *Store) ReadBody(name string) string {
return strings.TrimSpace(body)
}
-// ReadBody reads a skill's markdown body from embeddedFS only (package-level convenience).
-func ReadBody(name string) string {
- return readEmbeddedBody(name)
-}
-
func readEmbeddedBody(name string) string {
filePath := path.Join(name, "SKILL.md")
raw, err := embeddedFS.ReadFile(filePath)
@@ -465,12 +460,6 @@ func (s *Store) FormatInvocation(skill Skill, args string) string {
return formatInvocationBody(skill, body, args)
}
-// FormatInvocation formats a skill invocation (package-level, embedded-only).
-func FormatInvocation(skill Skill, args string) string {
- body := readEmbeddedBody(skill.Name)
- return formatInvocationBody(skill, body, args)
-}
-
func formatInvocationBody(skill Skill, body string, args string) string {
var sb strings.Builder
sb.WriteString(`
-
-
+
+
AIScan
diff --git a/web/frontend/package-lock.json b/web/frontend/package-lock.json
index 3023f835..22c1f4b6 100644
--- a/web/frontend/package-lock.json
+++ b/web/frontend/package-lock.json
@@ -8,6 +8,7 @@
"name": "aiscan-web-frontend",
"version": "0.2.0",
"dependencies": {
+ "@radix-ui/react-context-menu": "^2.3.3",
"@radix-ui/react-dialog": "^1.1.0",
"@radix-ui/react-dropdown-menu": "^2.1.0",
"@radix-ui/react-popover": "^1.1.0",
@@ -18,21 +19,29 @@
"@radix-ui/react-switch": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.0",
+ "@tanstack/react-table": "^8.21.3",
+ "@types/node": "^26.0.1",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"@xyflow/react": "^12.11.1",
+ "chroma-js": "^3.2.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "i18next": "^26.3.3",
+ "i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^0.468.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
+ "react-i18next": "^17.0.8",
"react-markdown": "^9.0.1",
"react-syntax-highlighter": "^15.6.1",
+ "recharts": "^2.15.4",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^2.6.0"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.15",
+ "@types/chroma-js": "^2.4.5",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/react-syntax-highlighter": "^15.5.13",
@@ -1008,6 +1017,330 @@
}
}
},
+ "node_modules/@radix-ui/react-context-menu": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.3.3.tgz",
+ "integrity": "sha512-PS+gKE0z2prJ74Y0sM+brAGK4mYOHIR7TlcV5EJgUQ6E0xMvyswkK2X4yRqyganrzsRL+WCSKAPu0NQITICRWg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.5",
+ "@radix-ui/react-context": "1.2.0",
+ "@radix-ui/react-menu": "2.1.20",
+ "@radix-ui/react-primitive": "2.1.7",
+ "@radix-ui/react-use-controllable-state": "1.2.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/primitive": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.5.tgz",
+ "integrity": "sha512-d86WIWFYNtGA0H/d8exstrTRTp7eWJYlYJbtNofxr/3ljupZYn6EFDG/Qgu/0Kc8v7yMUxySagqJsL1+PdYjWg==",
+ "license": "MIT"
+ },
+ "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-arrow": {
+ "version": "1.1.11",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.11.tgz",
+ "integrity": "sha512-Kdil9BB1rIFC/khmf4hC35bn8701AJcizTU7G7cUbEbk5XqqbjDuHW60uUfKqO5WojjZcbAW51Q7P0hRmMLw8A==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.1.7"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-collection": {
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.12.tgz",
+ "integrity": "sha512-nb67INpE0IahJKN7EYPp9m9YGwYeKlnzxT3MwXVkgCskaSJia97kG4T0ywpjNUSSnoJk/uvk12V8vbrEHEj+/Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.3",
+ "@radix-ui/react-context": "1.2.0",
+ "@radix-ui/react-primitive": "2.1.7",
+ "@radix-ui/react-slot": "1.3.0"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-context": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.0.tgz",
+ "integrity": "sha512-fOE+JtN9rygNZkCnHRBEP0TAvLldlhyOxMsbwFvTP4nAs+nBmfnna+o/Zski2wkmY1YMrFC0aSzsHoLY47iLrg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-dismissable-layer": {
+ "version": "1.1.15",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.15.tgz",
+ "integrity": "sha512-b0XaRlzn2QKuo10XyNgi2DAJDf5XC9d1nD3FJcuvCjbR7+4Ad28zmZsLsqx+hvDEzMnRuZaZxZm9gYObV6RmRA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.5",
+ "@radix-ui/react-compose-refs": "1.1.3",
+ "@radix-ui/react-primitive": "2.1.7",
+ "@radix-ui/react-use-callback-ref": "1.1.2",
+ "@radix-ui/react-use-effect-event": "0.0.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-focus-scope": {
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.12.tgz",
+ "integrity": "sha512-jjk/lqTeNL0azUx5ZYzVrl4NgaDIrdzTNE4mABV9yBFI7FQqN7pIgzV1bTleUezP2QiTGA1BFTqY8MegDgWX9A==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.3",
+ "@radix-ui/react-primitive": "2.1.7",
+ "@radix-ui/react-use-callback-ref": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-menu": {
+ "version": "2.1.20",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.20.tgz",
+ "integrity": "sha512-VsUrXxFe9d2ScbZF0fR/oPR1+qjyeLs5p0jzG8h90puMoA9bq4SirYlXbE+USRg9Q2qTeJSFNqjw2nts8jJe4w==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.5",
+ "@radix-ui/react-collection": "1.1.12",
+ "@radix-ui/react-compose-refs": "1.1.3",
+ "@radix-ui/react-context": "1.2.0",
+ "@radix-ui/react-direction": "1.1.2",
+ "@radix-ui/react-dismissable-layer": "1.1.15",
+ "@radix-ui/react-focus-guards": "1.1.4",
+ "@radix-ui/react-focus-scope": "1.1.12",
+ "@radix-ui/react-id": "1.1.2",
+ "@radix-ui/react-popper": "1.3.3",
+ "@radix-ui/react-portal": "1.1.13",
+ "@radix-ui/react-presence": "1.1.7",
+ "@radix-ui/react-primitive": "2.1.7",
+ "@radix-ui/react-roving-focus": "1.1.15",
+ "@radix-ui/react-slot": "1.3.0",
+ "@radix-ui/react-use-callback-ref": "1.1.2",
+ "aria-hidden": "^1.2.4",
+ "react-remove-scroll": "^2.7.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-popper": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.3.tgz",
+ "integrity": "sha512-mS7dGpyjv6b+gsDjLF7e0ia1W4Im1B1hSCy2yuXlHuvnZxHKagfDaobt/KAKt27EpZMit2pss8eJBVyVjEWM+g==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/react-dom": "^2.0.0",
+ "@radix-ui/react-arrow": "1.1.11",
+ "@radix-ui/react-compose-refs": "1.1.3",
+ "@radix-ui/react-context": "1.2.0",
+ "@radix-ui/react-primitive": "2.1.7",
+ "@radix-ui/react-use-callback-ref": "1.1.2",
+ "@radix-ui/react-use-layout-effect": "1.1.2",
+ "@radix-ui/react-use-rect": "1.1.2",
+ "@radix-ui/react-use-size": "1.1.2",
+ "@radix-ui/rect": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-portal": {
+ "version": "1.1.13",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.13.tgz",
+ "integrity": "sha512-z3oXfmaHLJTF1wktbjgD6cn9jiEbq3WSondB10LIuIt2m2Ym4iJlrW04/euMwENDdWDdE7z+OuY7Qyp1YpRSwA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.1.7",
+ "@radix-ui/react-use-layout-effect": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-presence": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.7.tgz",
+ "integrity": "sha512-zBZ4QM5XG3JRanDmqXYf3MD6th4AFXFmgU6KNMFzUaV6F3uw9I5/zjMUvFriSEn5ewo1nxuibvyxJdmLlDcslA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-layout-effect": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-primitive": {
+ "version": "2.1.7",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.7.tgz",
+ "integrity": "sha512-bC3NiwsprbxKjuon9l7X6BUTw7FPVzEYaL92MPEY5SCd/9hUTPXVFtVwRix7778wtRsVao+zE062gL79FZleeQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-slot": "1.3.0"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-roving-focus": {
+ "version": "1.1.15",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.15.tgz",
+ "integrity": "sha512-40svmmugfM3mUN7VUDGVE1tQGOhyi8enlGD0CNJEcMM36C1f71PKM21DFgNHUfem0XnA+d8H8oN3Z9ZpJjSslg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.5",
+ "@radix-ui/react-collection": "1.1.12",
+ "@radix-ui/react-compose-refs": "1.1.3",
+ "@radix-ui/react-context": "1.2.0",
+ "@radix-ui/react-direction": "1.1.2",
+ "@radix-ui/react-id": "1.1.2",
+ "@radix-ui/react-primitive": "2.1.7",
+ "@radix-ui/react-use-callback-ref": "1.1.2",
+ "@radix-ui/react-use-controllable-state": "1.2.3",
+ "@radix-ui/react-use-is-hydrated": "0.1.1",
+ "@radix-ui/react-use-layout-effect": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-dialog": {
"version": "1.1.17",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.17.tgz",
@@ -1662,6 +1995,21 @@
}
}
},
+ "node_modules/@radix-ui/react-use-is-hydrated": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.1.tgz",
+ "integrity": "sha512-qwOiz4Tjo8CNnrOLAYUMXeZwDzXgXpvK4TKQPmWLECM9XoWvA6+0Z2/7Ag3A4ivjS4ovbLJPbskkxioFyBhr8A==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz",
@@ -2127,6 +2475,39 @@
"tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1"
}
},
+ "node_modules/@tanstack/react-table": {
+ "version": "8.21.3",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
+ "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/table-core": "8.21.3"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": ">=16.8",
+ "react-dom": ">=16.8"
+ }
+ },
+ "node_modules/@tanstack/table-core": {
+ "version": "8.21.3",
+ "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
+ "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -2172,6 +2553,19 @@
"@babel/types": "^7.28.2"
}
},
+ "node_modules/@types/chroma-js": {
+ "version": "2.4.5",
+ "resolved": "https://registry.npmjs.org/@types/chroma-js/-/chroma-js-2.4.5.tgz",
+ "integrity": "sha512-6ISjhzJViaPCy2q2e6PgK+8HcHQDQ0V2LDiKmYAh+jJlLqDa6HbwDh0wOevHY0kHHUx0iZwjSRbVD47WOUx5EQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-array": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
+ "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
+ "license": "MIT"
+ },
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
@@ -2187,6 +2581,12 @@
"@types/d3-selection": "*"
}
},
+ "node_modules/@types/d3-ease": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
+ "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
+ "license": "MIT"
+ },
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
@@ -2196,12 +2596,48 @@
"@types/d3-color": "*"
}
},
+ "node_modules/@types/d3-path": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
+ "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-scale": {
+ "version": "4.0.9",
+ "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
+ "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-time": "*"
+ }
+ },
"node_modules/@types/d3-selection": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
"license": "MIT"
},
+ "node_modules/@types/d3-shape": {
+ "version": "3.1.8",
+ "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
+ "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-path": "*"
+ }
+ },
+ "node_modules/@types/d3-time": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
+ "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-timer": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
+ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
+ "license": "MIT"
+ },
"node_modules/@types/d3-transition": {
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
@@ -2269,6 +2705,15 @@
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"license": "MIT"
},
+ "node_modules/@types/node": {
+ "version": "26.0.1",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.1.tgz",
+ "integrity": "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==",
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~8.3.0"
+ }
+ },
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
@@ -2674,6 +3119,12 @@
"node": ">= 6"
}
},
+ "node_modules/chroma-js": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/chroma-js/-/chroma-js-3.2.0.tgz",
+ "integrity": "sha512-os/OippSlX1RlWWr+QDPcGUZs0uoqr32urfxESG9U93lhUfbnlyckte84Q8P1UQY/qth983AS1JONKmLS4T0nw==",
+ "license": "(BSD-3-Clause AND Apache-2.0)"
+ },
"node_modules/class-variance-authority": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
@@ -2747,6 +3198,18 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
+ "node_modules/d3-array": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
+ "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
+ "license": "ISC",
+ "dependencies": {
+ "internmap": "1 - 2"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
@@ -2787,6 +3250,15 @@
"node": ">=12"
}
},
+ "node_modules/d3-format": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
+ "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
@@ -2799,6 +3271,31 @@
"node": ">=12"
}
},
+ "node_modules/d3-path": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
+ "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-scale": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
+ "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2.10.0 - 3",
+ "d3-format": "1 - 3",
+ "d3-interpolate": "1.2.0 - 3",
+ "d3-time": "2.1.1 - 3",
+ "d3-time-format": "2 - 4"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/d3-selection": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
@@ -2808,6 +3305,42 @@
"node": ">=12"
}
},
+ "node_modules/d3-shape": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
+ "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-path": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-time": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
+ "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-time-format": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
+ "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-time": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
@@ -2869,6 +3402,12 @@
}
}
},
+ "node_modules/decimal.js-light": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
+ "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
+ "license": "MIT"
+ },
"node_modules/decode-named-character-reference": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
@@ -2924,6 +3463,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/dom-helpers": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
+ "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.8.7",
+ "csstype": "^3.0.2"
+ }
+ },
"node_modules/electron-to-chromium": {
"version": "1.5.364",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.364.tgz",
@@ -3015,12 +3564,27 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/eventemitter3": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
+ "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
+ "license": "MIT"
+ },
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT"
},
+ "node_modules/fast-equals": {
+ "version": "5.4.1",
+ "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.1.tgz",
+ "integrity": "sha512-DjlFSM5Pk9cGcL0q5QXl66eGzx0N6szNgaswwc5ZphlBohjTVJSnGgI+rJVOgOi65qUoQnDZN4nDqi33udtydQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
"node_modules/fast-glob": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
@@ -3309,6 +3873,15 @@
"integrity": "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==",
"license": "CC0-1.0"
},
+ "node_modules/html-parse-stringify": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
+ "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
+ "license": "MIT",
+ "dependencies": {
+ "void-elements": "3.1.0"
+ }
+ },
"node_modules/html-url-attributes": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
@@ -3319,12 +3892,58 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/i18next": {
+ "version": "26.3.3",
+ "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.3.tgz",
+ "integrity": "sha512-aYVegyBdXSO93CMMihvr47jI7GHSOcIahMpJX+qzUXDzW4xDJf2uenIA+45vDU+YhiVdcfsql70AC9RVdMNrHg==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://www.locize.com/i18next"
+ },
+ {
+ "type": "individual",
+ "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
+ },
+ {
+ "type": "individual",
+ "url": "https://www.locize.com"
+ }
+ ],
+ "license": "MIT",
+ "peerDependencies": {
+ "typescript": "^5 || ^6"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/i18next-browser-languagedetector": {
+ "version": "8.2.1",
+ "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz",
+ "integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.23.2"
+ }
+ },
"node_modules/inline-style-parser": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
"integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==",
"license": "MIT"
},
+ "node_modules/internmap": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
+ "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/is-alphabetical": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
@@ -3505,6 +4124,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/lodash": {
+ "version": "4.18.1",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
+ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
+ "license": "MIT"
+ },
"node_modules/longest-streak": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
@@ -4488,7 +5113,6 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -4762,6 +5386,23 @@
"node": ">=6"
}
},
+ "node_modules/prop-types": {
+ "version": "15.8.1",
+ "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
+ "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.4.0",
+ "object-assign": "^4.1.1",
+ "react-is": "^16.13.1"
+ }
+ },
+ "node_modules/prop-types/node_modules/react-is": {
+ "version": "16.13.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
+ "license": "MIT"
+ },
"node_modules/property-information": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz",
@@ -4818,6 +5459,39 @@
"react": "^18.3.1"
}
},
+ "node_modules/react-i18next": {
+ "version": "17.0.8",
+ "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.8.tgz",
+ "integrity": "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.29.2",
+ "html-parse-stringify": "^3.0.1",
+ "use-sync-external-store": "^1.6.0"
+ },
+ "peerDependencies": {
+ "i18next": ">= 26.2.0",
+ "react": ">= 16.8.0",
+ "typescript": "^5 || ^6"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ },
+ "react-native": {
+ "optional": true
+ },
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-is": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
+ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
+ "license": "MIT"
+ },
"node_modules/react-markdown": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-9.1.0.tgz",
@@ -4902,6 +5576,21 @@
}
}
},
+ "node_modules/react-smooth": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
+ "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-equals": "^5.0.1",
+ "prop-types": "^15.8.1",
+ "react-transition-group": "^4.4.5"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/react-style-singleton": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
@@ -4941,6 +5630,22 @@
"react": ">= 0.14.0"
}
},
+ "node_modules/react-transition-group": {
+ "version": "4.4.5",
+ "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
+ "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@babel/runtime": "^7.5.5",
+ "dom-helpers": "^5.0.1",
+ "loose-envify": "^1.4.0",
+ "prop-types": "^15.6.2"
+ },
+ "peerDependencies": {
+ "react": ">=16.6.0",
+ "react-dom": ">=16.6.0"
+ }
+ },
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -4964,6 +5669,39 @@
"node": ">=8.10.0"
}
},
+ "node_modules/recharts": {
+ "version": "2.15.4",
+ "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
+ "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==",
+ "deprecated": "1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide",
+ "license": "MIT",
+ "dependencies": {
+ "clsx": "^2.0.0",
+ "eventemitter3": "^4.0.1",
+ "lodash": "^4.17.21",
+ "react-is": "^18.3.1",
+ "react-smooth": "^4.0.4",
+ "recharts-scale": "^0.4.4",
+ "tiny-invariant": "^1.3.1",
+ "victory-vendor": "^36.6.8"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "peerDependencies": {
+ "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/recharts-scale": {
+ "version": "0.4.5",
+ "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz",
+ "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
+ "license": "MIT",
+ "dependencies": {
+ "decimal.js-light": "^2.4.1"
+ }
+ },
"node_modules/refractor": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/refractor/-/refractor-3.6.0.tgz",
@@ -5450,6 +6188,12 @@
"node": ">=0.8"
}
},
+ "node_modules/tiny-invariant": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
+ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
+ "license": "MIT"
+ },
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -5548,7 +6292,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
- "dev": true,
+ "devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -5558,6 +6302,12 @@
"node": ">=14.17"
}
},
+ "node_modules/undici-types": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
+ "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
+ "license": "MIT"
+ },
"node_modules/unified": {
"version": "11.0.5",
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
@@ -5763,6 +6513,28 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/victory-vendor": {
+ "version": "36.9.2",
+ "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
+ "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
+ "license": "MIT AND ISC",
+ "dependencies": {
+ "@types/d3-array": "^3.0.3",
+ "@types/d3-ease": "^3.0.0",
+ "@types/d3-interpolate": "^3.0.1",
+ "@types/d3-scale": "^4.0.2",
+ "@types/d3-shape": "^3.1.0",
+ "@types/d3-time": "^3.0.0",
+ "@types/d3-timer": "^3.0.0",
+ "d3-array": "^3.1.6",
+ "d3-ease": "^3.0.1",
+ "d3-interpolate": "^3.0.1",
+ "d3-scale": "^4.0.2",
+ "d3-shape": "^3.1.0",
+ "d3-time": "^3.0.0",
+ "d3-timer": "^3.0.1"
+ }
+ },
"node_modules/vite": {
"version": "6.4.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
@@ -5869,6 +6641,15 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/void-elements": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
+ "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
diff --git a/web/frontend/package.json b/web/frontend/package.json
index 4afb8fca..9f642048 100644
--- a/web/frontend/package.json
+++ b/web/frontend/package.json
@@ -9,6 +9,7 @@
"preview": "vite preview"
},
"dependencies": {
+ "@radix-ui/react-context-menu": "^2.3.3",
"@radix-ui/react-dialog": "^1.1.0",
"@radix-ui/react-dropdown-menu": "^2.1.0",
"@radix-ui/react-popover": "^1.1.0",
@@ -19,21 +20,29 @@
"@radix-ui/react-switch": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.0",
+ "@tanstack/react-table": "^8.21.3",
+ "@types/node": "^26.0.1",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"@xyflow/react": "^12.11.1",
+ "chroma-js": "^3.2.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "i18next": "^26.3.3",
+ "i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^0.468.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
+ "react-i18next": "^17.0.8",
"react-markdown": "^9.0.1",
"react-syntax-highlighter": "^15.6.1",
+ "recharts": "^2.15.4",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^2.6.0"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.15",
+ "@types/chroma-js": "^2.4.5",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/react-syntax-highlighter": "^15.5.13",
diff --git a/web/frontend/src/App.tsx b/web/frontend/src/App.tsx
index 17cc5242..a97af7c4 100644
--- a/web/frontend/src/App.tsx
+++ b/web/frontend/src/App.tsx
@@ -1,25 +1,42 @@
-import { useState, useEffect, useCallback, type ReactNode } from 'react'
-import { AlertTriangle, CheckCircle2, Monitor, Settings, RefreshCw, MessageSquare, Activity, PanelRight, PanelRightClose } from 'lucide-react'
-import AppSidebar from './components/AppSidebar'
+import { useState, useEffect, useCallback, useMemo, lazy, Suspense, type ReactNode } from 'react'
+import { useTranslation } from 'react-i18next'
+import { Box, Menu, Monitor, Settings } from 'lucide-react'
+import LanguageToggle from './components/LanguageToggle'
+import SessionList from './components/SessionList'
import ChatPanel from './components/ChatPanel'
-import DetailPanel from './components/DetailPanel'
-import ScanWorkspace from './components/ScanWorkspace'
import ConfigPanel from './components/ConfigPanel'
import AgentPanel from './components/AgentPanel'
-import AgentTerminal from './components/terminal'
-import { ThemeToggle } from '@aspect/ui'
-import { ThemeProvider, useTheme } from '@aspect/theme'
-import { getStatus } from './api'
-import type { ScanJob, ServerStatus } from './api'
-import { useScanSession } from './hooks/useScanSession'
-import { useChatSession } from './hooks/useChatSession'
-import { parseRoute, sessionRoutePath } from './lib/scan-route'
-import { TooltipProvider } from '@aspect/ui'
-import { cn } from '@aspect/theme'
+import AssetPanel, { assetMentionables } from './components/AssetPanel'
+import AssetMentionPicker from './components/AssetMentionPicker'
+import LLMHealth from './components/LLMHealth'
+import QuickConnect from './components/QuickConnect'
+import BrandLogo from './components/brand/BrandLogo'
+// Lazy: the agent terminal drags in @xterm (~its own chunk) but only renders
+// when a node's console is opened — keep it out of the first-paint bundle.
+const AgentTerminal = lazy(() => import('./components/terminal'))
+import { Button, ThemeToggle, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, useConfirm } from '@cyber/ui'
+import { ThemeProvider, useTheme } from '@cyber/theme'
+import { getStatus, listSCONodes } from './api'
+import type { ServerStatus } from './api'
+import type { SCONode } from '@cyber/cstx-easm'
+import { useChatSession, agentNodeKey } from './hooks/useChatSession'
+import { usePolling } from './hooks/usePolling'
+import { isSessionAgentOnline } from './lib/session-agent'
+import { cn } from '@cyber/theme'
const sidebarStorageKey = 'aiscan-sidebar-open'
-type AppView = 'chat' | 'scan'
+const EMPTY_SEED = { text: '', nonce: 0 }
+
+// Respect a previously-chosen theme on boot. ThemeProvider's own initializer is
+// short-circuited by the `initial` prop (it returns `initial` before ever reading
+// storage), so we read the persisted value here and feed it in as the initial —
+// otherwise every reload snaps back to the light default.
+function getInitialTheme(): 'light' | 'dark' {
+ if (typeof window === 'undefined') return 'light'
+ const v = window.localStorage.getItem('aiscan-theme')
+ return v === 'dark' || v === 'light' ? v : 'light'
+}
function getInitialSidebarOpen() {
if (typeof window === 'undefined') return true
@@ -29,30 +46,29 @@ function getInitialSidebarOpen() {
return window.matchMedia('(min-width: 1024px)').matches
}
-function getInitialView(): AppView {
- if (typeof window === 'undefined') return 'chat'
- return parseRoute(window.location.pathname).kind === 'scan' ? 'scan' : 'chat'
-}
-
export default function App() {
+ const { t } = useTranslation('app')
+ const { t: tc } = useTranslation('chat')
+ const confirm = useConfirm()
const chat = useChatSession()
- const scanSession = useScanSession()
- const [analysisAvailable, setAnalysisAvailable] = useState(true)
const [serverStatus, setServerStatus] = useState(null)
const [configOpen, setConfigOpen] = useState(false)
const [agentPanelOpen, setAgentPanelOpen] = useState(false)
+ const [assetPanelOpen, setAssetPanelOpen] = useState(false)
+ const [agentPanelFocusID, setAgentPanelFocusID] = useState(null)
const [sidebarOpen, setSidebarOpen] = useState(getInitialSidebarOpen)
- const [detailOpen, setDetailOpen] = useState(true)
- const [terminalAgentID, setTerminalAgentID] = useState(null)
- const [view, setView] = useState(getInitialView)
+ // Bumped after a settings save so the header LLM health dot re-probes.
+ const [healthNonce, setHealthNonce] = useState(0)
+ // Track the terminal target by the node's STABLE key, not its transient agent
+ // id: the hub mints a fresh id on every reconnect, so keying on id would drop
+ // the terminal (and never restore it) when a node bounces to reload config.
+ const [terminalNodeKey, setTerminalNodeKey] = useState(null)
const refreshStatus = useCallback(async () => {
try {
- const status = await getStatus()
- setServerStatus(status)
- setAnalysisAvailable(status.llm_available)
+ setServerStatus(await getStatus())
} catch {
- setAnalysisAvailable(true)
+ /* leave prior status; the settings panel surfaces connectivity */
}
}, [])
@@ -60,348 +76,286 @@ export default function App() {
refreshStatus()
}, [refreshStatus])
+ // Keep the header (model + agent count + health base) fresh without a reload.
+ usePolling(refreshStatus, 30000)
+
useEffect(() => {
window.localStorage.setItem(sidebarStorageKey, String(sidebarOpen))
}, [sidebarOpen])
- useEffect(() => {
- const syncViewFromRoute = () => {
- const route = parseRoute(window.location.pathname)
- setView(route.kind === 'scan' ? 'scan' : 'chat')
- if (route.kind !== 'scan') {
- setAgentPanelOpen(false)
- }
- }
- syncViewFromRoute()
- window.addEventListener('popstate', syncViewFromRoute)
- return () => window.removeEventListener('popstate', syncViewFromRoute)
+ // SCO nodes for @-mention in chat input
+ const [scoNodes, setScoNodes] = useState([])
+ const [composerSeed, setComposerSeed] = useState(EMPTY_SEED)
+
+ const refreshSCONodes = useCallback(async () => {
+ try {
+ const data = await listSCONodes({ limit: 2000 })
+ setScoNodes(data)
+ } catch { /* non-critical */ }
}, [])
- // Keyboard shortcuts: Ctrl/Cmd+1 for Chat, Ctrl/Cmd+2 for Scan
- useEffect(() => {
- function handleKeyDown(e: KeyboardEvent) {
- if (!e.metaKey && !e.ctrlKey) return
- if (e.key === '1') {
- e.preventDefault()
- handleOpenChatWorkspace()
- } else if (e.key === '2') {
- e.preventDefault()
- handleOpenScanWorkspace()
- }
- }
- window.addEventListener('keydown', handleKeyDown)
- return () => window.removeEventListener('keydown', handleKeyDown)
- }, [chat.activeSessionID])
+ useEffect(() => { void refreshSCONodes() }, [refreshSCONodes])
+ // Refresh mentionables when scans finish (timeline changes often signal new results)
+ useEffect(() => { void refreshSCONodes() }, [chat.timeline.length, refreshSCONodes])
+
+ const mentionables = useMemo(() => assetMentionables(scoNodes), [scoNodes])
- const detailResult = chat.detailScanID ? chat.scanResults.get(chat.detailScanID) ?? null : null
- const showDetail = detailOpen && !!chat.detailScanID && !!detailResult
- const terminalAgent = terminalAgentID ? chat.agents.find((a) => a.id === terminalAgentID) ?? null : null
+ const handleAssetSendToChat = useCallback((text: string) => {
+ setComposerSeed({ text, nonce: Date.now() })
+ }, [])
+
+ const renderMentionPopup = useMemo(() => {
+ if (scoNodes.length === 0) return undefined
+ return (api: { query: string; onSelect: (targets: string[]) => void; onDismiss: () => void }) => (
+
+ )
+ }, [scoNodes])
+
+ const terminalAgent = terminalNodeKey ? chat.agents.find((a) => agentNodeKey(a) === terminalNodeKey) ?? null : null
+
+ const model = serverStatus?.llm_model || chat.agents.find((a) => a.identity?.model)?.identity?.model || 'cortex'
+ const activeSession = chat.sessions.find((s) => s.id === chat.activeSessionID) || null
+ // The open session's bound agent has dropped off the live roster (its node
+ // exited / the hub restarted). The transcript still shows, but a new turn
+ // can't be dispatched until it reconnects — surface that in the chat panel.
+ const activeAgentOffline = !!activeSession && !isSessionAgentOnline(activeSession, chat.agents)
+
+ // On phones the sidebar is an overlay drawer (see SessionList); entering a
+ // conversation or terminal should dismiss it so the content isn't left covered.
+ // No-op at md+ where the sidebar is a docked rail that shares the row.
+ function closeSidebarOnMobile() {
+ if (typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches) {
+ setSidebarOpen(false)
+ }
+ }
function handleOpenTerminal(agentID: string) {
- setTerminalAgentID(agentID)
+ const a = chat.agents.find((x) => x.id === agentID)
+ setTerminalNodeKey(a ? agentNodeKey(a) : agentID)
chat.selectAgent(agentID)
- setView('chat')
+ closeSidebarOnMobile()
}
function handleSelectSession(id: string) {
- setTerminalAgentID(null)
+ setTerminalNodeKey(null)
chat.selectSession(id)
- setView('chat')
- const path = sessionRoutePath(id)
- window.history.pushState({}, '', path)
+ closeSidebarOnMobile()
}
function handleCreateSession(agentID: string) {
- setTerminalAgentID(null)
+ setTerminalNodeKey(null)
chat.createSession(agentID)
- setView('chat')
- }
-
- function handleOpenScanWorkspace() {
- setTerminalAgentID(null)
- setView('scan')
+ closeSidebarOnMobile()
}
- function handleOpenChatWorkspace() {
- setTerminalAgentID(null)
- setView('chat')
- const path = chat.activeSessionID ? sessionRoutePath(chat.activeSessionID) : '/'
- window.history.pushState({}, '', path)
+ // Deleting a session also tears down its live subscription, so confirm first —
+ // matches every other destructive action in the app (node / config).
+ async function handleDeleteSession(id: string) {
+ if (!(await confirm({ description: tc('deleteSessionConfirm'), destructive: true }))) return
+ void chat.deleteSession(id)
}
- function handleChangeView(newView: AppView) {
- if (newView === 'scan') handleOpenScanWorkspace()
- else handleOpenChatWorkspace()
+ // Agent node clicked (roster / terminal open) → open the agent console focused
+ // on that node.
+ function handleOpenNode(agentID: string) {
+ setAgentPanelFocusID(agentID)
+ setAgentPanelOpen(true)
}
- function handleSelectScan(scan: ScanJob) {
- setTerminalAgentID(null)
- setView('scan')
- scanSession.selectScan(scan)
+ function handleOpenAgentPanel() {
+ setAgentPanelFocusID(null)
+ setAgentPanelOpen(true)
}
- const runningScans = scanSession.scans.filter((s) => s.status === 'running' || s.status === 'queued').length
-
return (
-
+
-
- {/* Unified sidebar */}
- setSidebarOpen(!sidebarOpen)}
- view={view}
- onChangeView={handleChangeView}
- agents={chat.agents}
- sessions={chat.sessions}
- activeSessionID={chat.activeSessionID}
- selectedAgentID={chat.selectedAgentID}
- terminalAgentID={terminalAgentID}
- onSelectAgent={chat.selectAgent}
- onSelectSession={handleSelectSession}
- onCreateSession={handleCreateSession}
- onDeleteSession={chat.deleteSession}
- onOpenTerminal={handleOpenTerminal}
- scans={scanSession.scans}
- activeScanID={scanSession.activeScan?.id}
- onSelectScan={handleSelectScan}
- />
-
- {/* Main area */}
-
- {/* Unified header */}
-
-
-
-
-
-
- setAgentPanelOpen(true)} />
- {view === 'scan' && (
-
-
-
- )}
- {view === 'chat' && chat.detailScanID && (
- setDetailOpen(!detailOpen)}>
- {detailOpen ? : }
-
- )}
- setConfigOpen(true)}>
-
-
-
-
-
-
- {/* Content area with transition */}
-
- {/* Chat view */}
-
- {terminalAgent ? (
-
-
-
-
-
- ) : (
- <>
- {
- chat.showScanDetail(scanID)
- setDetailOpen(true)
- }}
- detailOpen={showDetail}
- />
-
- {showDetail && (
- setDetailOpen(false)}
- />
- )}
-
- >
- )}
-
-
- {/* Scan view */}
-
+
+
+ {/* Phone-only drawer opener — the collapsed sidebar is hidden below md,
+ so the session history opens from here (Doubao-style). */}
+
+
+
+
+ AIScan
+ {model}
+ setConfigOpen(true)} reloadSignal={healthNonce} />
+
+
+ setAssetPanelOpen(true)} />
+
+
+ setConfigOpen(true)}>
+
+
+
+
+
+
+
+ setSidebarOpen(!sidebarOpen)}
+ agents={chat.agents}
+ sessions={chat.sessions}
+ activeSessionID={chat.activeSessionID}
+ selectedAgentID={chat.selectedAgentID}
+ terminalAgentID={terminalAgent?.id ?? null}
+ onSelectAgent={chat.selectAgent}
+ onSelectSession={handleSelectSession}
+ onCreateSession={handleCreateSession}
+ onDeleteSession={handleDeleteSession}
+ onOpenTerminal={handleOpenTerminal}
+ />
+
+ {terminalAgent ? (
+
+
+ }>
+
+
+
+
+ ) : (
+ ({ id: a.id, name: a.identity?.node_name }))}
+ onCreateSession={handleCreateSession}
+ onOpenTerminal={handleOpenTerminal}
+ mentionables={mentionables}
+ renderMentionPopup={renderMentionPopup}
+ injectText={composerSeed}
+ onSend={chat.sendMessage}
+ onPause={chat.cancelMessage}
+ onClearError={chat.clearError}
+ />
+ )}
+ setConfigOpen(false)}
+ onSaved={() => { refreshStatus(); setHealthNonce((n) => n + 1) }}
+ />
+
setAgentPanelOpen(false)}
/>
- setConfigOpen(false)}
- onSaved={refreshStatus}
+ setAssetPanelOpen(false)}
+ onSendToChat={handleAssetSendToChat}
/>
)
}
-/* ---------- View Switcher ---------- */
-
-function ViewSwitcher({
- view,
- onChangeView,
- runningScans,
-}: {
- view: AppView
- onChangeView: (v: AppView) => void
- runningScans: number
-}) {
- return (
-
-
-
-
-
- )
-}
-
-/* ---------- Shared header components ---------- */
-
function ConnectedThemeToggle() {
const { isDark, toggle } = useTheme()
return
}
-function ScanAgentsButton({ count, onClick }: { count: number; onClick: () => void }) {
+function AssetPoolButton({ count, onClick }: { count: number; onClick: () => void }) {
+ const { t } = useTranslation('assets')
const active = count > 0
return (
-
+
+
+
+
+ {t('openAssets')}
+
)
}
-function HeaderIconButton({ children, label, onClick }: { children: ReactNode; label: string; onClick: () => void }) {
+function AgentsButton({ count, onClick }: { count: number; onClick: () => void }) {
+ const { t } = useTranslation('app')
+ const active = count > 0
return (
-
+
+
+
+
+ {active ? t('agentsConnected', { count }) : t('noAgents')}
+
)
}
-function StatusPill({ active }: { active: boolean }) {
+function HeaderIconButton({ children, label, onClick, active }: { children: ReactNode; label: string; onClick: () => void; active?: boolean }) {
return (
-
- {active ? : }
- {active ? 'LLM Ready' : 'LLM Offline'}
-
+
+
+
+
+ {label}
+
)
}
diff --git a/web/frontend/src/__preview_sidebar.tsx b/web/frontend/src/__preview_sidebar.tsx
new file mode 100644
index 00000000..094e69de
--- /dev/null
+++ b/web/frontend/src/__preview_sidebar.tsx
@@ -0,0 +1,63 @@
+// THROWAWAY preview harness for visual QA of the sidebar redesign. Not imported
+// by the app; loaded only by /preview.html under `vite dev`. Delete after QA.
+import React from 'react'
+import ReactDOM from 'react-dom/client'
+import i18n from './i18n'
+import './index.css'
+import { ThemeProvider } from '@cyber/theme'
+import { TooltipProvider } from '@cyber/ui'
+import SessionList from './components/SessionList'
+import type { AgentInfo, ChatSession } from './api'
+
+const params = new URLSearchParams(location.search)
+const theme = params.get('theme') === 'dark' ? 'dark' : 'light'
+const lang = params.get('lang') === 'en' ? 'en' : 'zh'
+document.documentElement.lang = lang
+window.localStorage.setItem('aiscan-locale', lang)
+void i18n.changeLanguage(lang)
+
+const agents: AgentInfo[] = [
+ {
+ id: 'a1', name: 'recon-01', busy: true, connected_at: '',
+ identity: { provider: 'anthropic', model: 'claude-opus-4-8' },
+ stats: { current_tool: 'gogo', current_detail: '10.0.0.0/24' } as never,
+ },
+ {
+ id: 'a2', name: 'osint-tokyo', busy: false, connected_at: '',
+ identity: { provider: 'openai', model: 'gpt-5' },
+ },
+]
+const sessions: ChatSession[] = [
+ { id: 's1', agent_id: 'a1', agent_name: 'recon-01', title: '内网横向探测', status: 'active', created_at: '2026-07-06T00:00:00Z', updated_at: '2026-07-06T10:00:00Z' },
+ { id: 's2', agent_id: 'a1', agent_name: 'recon-01', title: '子域接管复测', status: 'active', created_at: '2026-07-05T00:00:00Z', updated_at: '2026-07-05T09:00:00Z' },
+ { id: 's3', agent_id: 'a2', agent_name: 'osint-tokyo', title: 'API 密钥泄露排查', status: 'active', created_at: '2026-07-04T00:00:00Z', updated_at: '2026-07-04T08:00:00Z' },
+]
+
+function Harness() {
+ const [open, setOpen] = React.useState(true)
+ return (
+
+
+
+ setOpen((o) => !o)}
+ agents={agents}
+ sessions={sessions}
+ activeSessionID="s1"
+ selectedAgentID="a1"
+ terminalAgentID={null}
+ onSelectAgent={() => {}}
+ onSelectSession={() => {}}
+ onCreateSession={() => {}}
+ onDeleteSession={() => {}}
+ onOpenTerminal={() => {}}
+ />
+
+
+
+
+ )
+}
+
+ReactDOM.createRoot(document.getElementById('root')!).render( )
diff --git a/web/frontend/src/api.ts b/web/frontend/src/api.ts
index 27418654..ba86f3d2 100644
--- a/web/frontend/src/api.ts
+++ b/web/frontend/src/api.ts
@@ -11,13 +11,20 @@ export interface ScanJob {
report?: string;
result?: ScanResult;
error?: string;
+ project?: string;
+ /** Batch targets dropped by validation on submit (transient, create only). */
+ skipped?: { target: string; reason: string }[];
created_at: string;
updated_at: string;
}
+import type { SCONode } from '@cyber/cstx-easm';
+export type { SCONode };
+
export interface ScanResult {
summary: ScanResultSummary;
assets?: Asset[];
+ nodes?: SCONode[];
services?: unknown[];
web_probes?: unknown[];
loots?: Loot[];
@@ -76,8 +83,26 @@ export interface ResultError {
message: string;
}
+// One entry in the shared asset pool (deduplicated by target). Source is
+// 'scan' | 'agent' | 'manual'.
+export interface PoolAsset {
+ id: string;
+ project_id?: string;
+ target: string;
+ label?: string;
+ source?: string;
+ status?: string;
+ note?: string;
+ services?: number;
+ webs?: number;
+ loots?: number;
+ last_scan_id?: string;
+ first_seen: string;
+ last_seen: string;
+}
+
export interface ScanEvent {
- type: 'progress' | 'status' | 'complete' | 'error';
+ type: 'progress' | 'status' | 'stats' | 'complete' | 'error';
scan_id: string;
data?: string;
status?: string;
@@ -94,6 +119,7 @@ export interface ScanOptions {
}
export interface ServerStatus {
+ version: string;
llm_available: boolean;
llm_provider?: string;
llm_model?: string;
@@ -101,6 +127,7 @@ export interface ServerStatus {
config_path?: string;
config_loaded: boolean;
agents: number;
+ ioa_url?: string;
}
export interface AgentInfo {
@@ -142,6 +169,8 @@ export interface AgentStats {
assets?: number;
loots?: number;
last_event?: string;
+ current_tool?: string;
+ current_detail?: string;
}
// ConfigStatus — GET /api/config response (secrets masked, *_configured flags)
@@ -197,31 +226,209 @@ export async function saveConfig(config: DistributeConfig): Promise {
+// LLMTestRequest — POST /api/config/llm/test body. Leave api_key blank to
+// reuse the key already stored on the server.
+export interface LLMTestRequest {
+ provider: string;
+ base_url: string;
+ api_key: string;
+ model: string;
+ proxy: string;
+}
+
+// LLMTestResult — outcome of a connectivity probe. ok=false carries the
+// failure reason in `error`; transport/HTTP errors never reject the promise.
+export interface LLMTestResult {
+ ok: boolean;
+ provider: string;
+ model: string;
+ latency_ms: number;
+ reply?: string;
+ error?: string;
+}
+
+export async function testLLM(req: LLMTestRequest): Promise {
+ return apiJSON('/api/config/llm/test', 'Failed to test LLM', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(req),
+ });
+}
+
+// LLMModelsRequest — POST /api/config/llm/models body. Like LLMTestRequest but
+// without a model (listing is what fills the model field). Leave api_key blank
+// to reuse the key already stored on the server.
+export interface LLMModelsRequest {
+ provider: string;
+ base_url: string;
+ api_key: string;
+ proxy: string;
+}
+
+// LLMModelsResult — models the endpoint advertises via the OpenAI-compatible
+// GET /models route. ok=false carries the reason in `error`.
+export interface LLMModelsResult {
+ ok: boolean;
+ models?: string[];
+ error?: string;
+}
+
+export async function listLLMModels(req: LLMModelsRequest): Promise {
+ return apiJSON('/api/config/llm/models', 'Failed to list models', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(req),
+ });
+}
+
+// ConnCheck — outcome of probing one external dependency within a settings
+// section. A section may return several checks (Recon probes FOFA and Hunter
+// independently). ok=false carries the reason in `error`.
+export interface ConnCheck {
+ name: string; // fofa | hunter | cyberhub | tavily | ioa | recon
+ ok: boolean;
+ latency_ms: number;
+ detail?: string;
+ error?: string;
+}
+
+export interface ConnTestResponse {
+ checks: ConnCheck[];
+}
+
+// testConn probes the external dependencies of a settings section
+// (cyberhub | recon | search | ioa). The current (possibly unsaved) form is
+// sent so edits are tested; blank secrets fall back to stored values server-side.
+export async function testConn(section: string, config: DistributeConfig): Promise {
+ return apiJSON(`/api/config/${section}/test`, 'Failed to test connection', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(config),
+ });
+}
+
+// --- Local agents (hub-hosted nodes: one-click launch + delete) ---
+
+export interface LocalAgentView {
+ name: string
+ pid: number
+ registered: boolean
+ busy?: boolean
+}
+
+export async function launchLocalAgent(): Promise {
+ return apiJSON('/api/deploy/local', 'Failed to launch local agent', {
+ method: 'POST',
+ })
+}
+
+export async function listLocalAgents(): Promise {
+ return apiJSON('/api/deploy/local', 'Failed to list local agents')
+}
+
+export async function stopLocalAgent(name: string): Promise {
+ await apiJSON(`/api/deploy/local/${encodeURIComponent(name)}`, 'Failed to delete local agent', {
+ method: 'DELETE',
+ })
+}
+
+export async function submitScan(target: string, mode: string, options: ScanOptions, project?: string): Promise {
return apiJSON('/api/scans', 'Failed to submit scan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ target, mode, ...options }),
+ body: JSON.stringify({ target, mode, ...options, project }),
});
}
+export async function getAssets(project?: string): Promise {
+ const q = project ? `?project=${encodeURIComponent(project)}` : '';
+ return apiJSON(`/api/assets${q}`, 'Failed to load assets');
+}
+
+export async function addAssets(targets: string[], source?: string, label?: string, project?: string): Promise {
+ return apiJSON('/api/assets', 'Failed to add assets', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ targets, source, label, project }),
+ });
+}
+
+export async function deleteAsset(id: string, project?: string): Promise {
+ const q = project ? `?project=${encodeURIComponent(project)}` : '';
+ await apiJSON(`/api/assets/${encodeURIComponent(id)}${q}`, 'Failed to delete asset', { method: 'DELETE' });
+}
+
+// One passive-recon hit, normalized to an importable pool target + display bits.
+export interface ReconHit {
+ target: string;
+ ip?: string;
+ port?: string;
+ host?: string;
+ url?: string;
+ title?: string;
+ icp?: string;
+}
+
+export interface ReconSearchResult {
+ source: string;
+ sources: string[]; // every source the hub has credentials for (UI selector)
+ hits: ReconHit[];
+}
+
+// Run a passive-recon query (FOFA / Hunter / …) via the hub. Errors — no
+// credentials, bad query, upstream failure — reject with the server's message.
+export async function reconSearch(source: string, query: string, limit?: number): Promise {
+ return apiJSON('/api/recon/search', 'Recon search failed', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ source, query, limit }),
+ });
+}
+
+// --- Projects (asset-pool scope) ---
+
+export interface Project {
+ id: string
+ name: string
+ assets: number
+ created_at: string
+}
+
+export async function getProjects(): Promise {
+ return apiJSON('/api/projects', 'Failed to load projects');
+}
+
+export async function createProject(name: string, id?: string): Promise {
+ return apiJSON('/api/projects', 'Failed to create project', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name, id }),
+ });
+}
+
+// Cascades on the server: the project and its entire asset pool are removed.
+export async function deleteProject(id: string): Promise {
+ await apiJSON(`/api/projects/${encodeURIComponent(id)}`, 'Failed to delete project', { method: 'DELETE' });
+}
+
export async function getScan(id: string): Promise {
return apiJSON(`/api/scans/${encodeURIComponent(id)}`, 'Scan not found');
}
-export async function listScans(): Promise {
- return apiJSON('/api/scans', 'Failed to list scans');
+export async function listScans(project?: string): Promise {
+ const q = project ? `?project=${encodeURIComponent(project)}` : '';
+ return apiJSON(`/api/scans${q}`, 'Failed to list scans');
}
-export async function cancelScan(id: string): Promise {
- await apiJSON(`/api/scans/${encodeURIComponent(id)}`, 'Failed to cancel scan', { method: 'DELETE' });
+export async function deleteScan(id: string): Promise {
+ await apiJSON(`/api/scans/${encodeURIComponent(id)}`, 'Failed to delete scan', { method: 'DELETE' });
}
export function subscribeScanEvents(
id: string,
onEvent: (event: ScanEvent) => void,
): () => void {
- const es = new EventSource(`/api/scans/${encodeURIComponent(id)}/events`);
+ const es = new EventSource(authURL(`/api/scans/${encodeURIComponent(id)}/events`));
const handler = (type: RawScanEventType) => (e: Event) => {
const data = 'data' in e ? (e as MessageEvent).data : undefined;
if (typeof data !== 'string' || data === '') {
@@ -266,6 +473,7 @@ export function subscribeScanEvents(
};
es.addEventListener('progress', handler('progress'));
es.addEventListener('status', handler('status'));
+ es.addEventListener('stats', handler('stats'));
es.addEventListener('complete', handler('complete'));
es.addEventListener('error', handler('error'));
es.addEventListener('output', handler('output'));
@@ -301,7 +509,7 @@ export type ChatEventType =
| 'message' | 'message_start' | 'message_delta' | 'message_end'
| 'tool_call' | 'tool_result' | 'thinking'
| 'scan_started' | 'scan_progress' | 'scan_complete' | 'scan_error'
- | 'agent_joined' | 'error'
+ | 'agent_joined' | 'eval' | 'session_cleared' | 'error'
export interface ChatEvent {
type: ChatEventType
@@ -320,15 +528,22 @@ export interface ChatEvent {
result?: ScanResult
data?: string
error?: string
+ // System/error messages carry a stable code (+ params) so the client can
+ // localize them via i18n; `content`/`error` stay as English fallbacks.
+ code?: string
+ params?: Record
+ eval_round?: number
+ eval_pass?: boolean
+ eval_reason?: string
}
// --- Chat session API ---
-export async function createChatSession(agentID: string, title?: string): Promise {
+export async function createChatSession(agentID: string, title?: string, scanID?: string): Promise {
return apiJSON('/api/chat/sessions', 'Failed to create session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ agent_id: agentID, title: title || '' }),
+ body: JSON.stringify({ agent_id: agentID, title: title || '', scan_id: scanID || '' }),
})
}
@@ -346,11 +561,43 @@ export async function deleteChatSession(id: string): Promise {
})
}
-export async function sendChatMessage(sessionID: string, content: string): Promise {
+// SlashCommandSpec mirrors pkg/slashcmd.Spec — the server's canonical view of a
+// "/verb" command. The web "/" menu is built from these so it always reflects
+// the hub's + bound agent's real command set instead of a hardcoded list.
+export interface SlashCommandSpec {
+ name: string
+ aliases?: string[]
+ usage?: string
+ description?: string
+ scope: number
+ web_menu?: boolean
+}
+
+export async function fetchSessionCommands(sessionID: string): Promise {
+ return apiJSON(`/api/chat/sessions/${encodeURIComponent(sessionID)}/commands`, 'Failed to load commands')
+}
+
+export async function sendChatMessage(
+ sessionID: string,
+ content: string,
+ opts?: { persist?: boolean; evalCriteria?: string; evalMaxRounds?: number },
+): Promise {
+ const body: Record = { content }
+ // Goal mode: the only run-control the backend acts on is a natural-language
+ // completion criteria judged by an independent evaluator for up to N rounds
+ // (webagent runChatEval). Persist without criteria is just a normal message.
+ if (opts?.persist) {
+ const criteria = opts.evalCriteria?.trim()
+ if (criteria) {
+ body.persist = true
+ body.eval_criteria = criteria
+ if (opts.evalMaxRounds && opts.evalMaxRounds > 0) body.eval_max_rounds = opts.evalMaxRounds
+ }
+ }
return apiJSON(`/api/chat/sessions/${encodeURIComponent(sessionID)}/messages`, 'Failed to send message', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ content }),
+ body: JSON.stringify(body),
})
}
@@ -370,8 +617,12 @@ export interface FileUploadResult {
export async function uploadChatFile(sessionID: string, file: File): Promise {
const form = new FormData()
form.append('file', file)
+ const headers: Record = {}
+ const key = getAccessKey()
+ if (key) headers['Authorization'] = `Bearer ${key}`
const resp = await fetch(`/api/chat/sessions/${encodeURIComponent(sessionID)}/upload`, {
method: 'POST',
+ headers,
body: form,
})
if (!resp.ok) {
@@ -385,18 +636,31 @@ export async function listChatMessages(sessionID: string): Promise {
+ const headers: Record = {}
+ const key = getAccessKey()
+ if (key) headers['Authorization'] = `Bearer ${key}`
+ const res = await fetch(`/api/scans/${encodeURIComponent(scanID)}/report?lang=${encodeURIComponent(lang)}`, { headers })
+ if (!res.ok) return ''
+ return res.text()
+}
+
export function subscribeChatEvents(
sessionID: string,
onEvent: (event: ChatEvent) => void,
+ onReconnect?: () => void,
): () => void {
- const url = `/api/chat/sessions/${encodeURIComponent(sessionID)}/events`
+ const url = authURL(`/api/chat/sessions/${encodeURIComponent(sessionID)}/events`)
const es = new EventSource(url)
const eventTypes: ChatEventType[] = [
'message', 'message_start', 'message_delta', 'message_end',
'tool_call', 'tool_result', 'thinking',
'scan_started', 'scan_progress', 'scan_complete', 'scan_error',
- 'agent_joined', 'error',
+ 'agent_joined', 'eval', 'session_cleared', 'error',
]
for (const type of eventTypes) {
@@ -413,7 +677,12 @@ export function subscribeChatEvents(
}
es.addEventListener('error', () => {
- // EventSource auto-reconnects; no action needed.
+ // EventSource auto-reconnects, but the chat SSE topic keeps no backlog, so a
+ // terminal event (message_end / tool_result / the aggregate 'message')
+ // broadcast during the drop is lost — which strands the composer in a
+ // permanent "thinking" state. Reconcile from REST truth on each connection
+ // error (idempotent), mirroring the scan path's getScan-on-error recovery.
+ onReconnect?.()
})
return () => es.close()
@@ -421,11 +690,69 @@ export function subscribeChatEvents(
export function agentTerminalWebSocketURL(agentID: string): string {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
- return `${protocol}//${window.location.host}/api/agents/${encodeURIComponent(agentID)}/terminal/ws`;
+ const base = `${protocol}//${window.location.host}/api/agents/${encodeURIComponent(agentID)}/terminal/ws`;
+ return authURL(base);
+}
+
+// ── SCO Nodes ──
+
+export async function listSCONodes(opts?: { type?: string; scanId?: string; limit?: number }): Promise {
+ const params = new URLSearchParams();
+ if (opts?.type) params.set('type', opts.type);
+ if (opts?.scanId) params.set('scan_id', opts.scanId);
+ if (opts?.limit) params.set('limit', String(opts.limit));
+ const qs = params.toString();
+ return apiJSON(`/api/sco/nodes${qs ? '?' + qs : ''}`, 'Failed to load SCO nodes');
+}
+
+export async function getSCONode(id: string): Promise {
+ return apiJSON(`/api/sco/nodes/${encodeURIComponent(id)}`, 'SCO node not found');
+}
+
+export async function getSCOStats(): Promise> {
+ return apiJSON('/api/sco/stats', 'Failed to load SCO stats');
+}
+
+export async function getSupportedArtifacts(): Promise {
+ return apiJSON('/api/sco/artifacts', 'Failed to load artifacts');
+}
+
+export async function importSCOData(
+ file: File,
+ artifact: string,
+ scanId = 'import',
+): Promise<{ status: string; nodes: number; artifact: string; duplicates: number }> {
+ const form = new FormData();
+ form.append('file', file);
+ form.append('artifact', artifact);
+ form.append('scan_id', scanId);
+ const resp = await fetch('/api/sco/import', { method: 'POST', body: form });
+ if (!resp.ok) {
+ const err = await resp.json().catch(() => ({ error: resp.statusText }));
+ throw new Error(err.error || `Import failed: ${resp.status}`);
+ }
+ return resp.json();
+}
+
+function getAccessKey(): string {
+ return (window as any).__AISCAN_ACCESS_KEY__ || ''
+}
+
+// For SSE/WebSocket, append access_key as query param since EventSource/WebSocket can't set headers.
+function authURL(path: string): string {
+ const key = getAccessKey()
+ if (!key) return path
+ const sep = path.includes('?') ? '&' : '?'
+ return `${path}${sep}access_key=${encodeURIComponent(key)}`
}
async function apiJSON(path: string, fallbackMessage: string, init?: RequestInit): Promise {
- const res = await fetch(path, init);
+ const key = getAccessKey()
+ const headers = new Headers(init?.headers)
+ if (key) {
+ headers.set('Authorization', `Bearer ${key}`)
+ }
+ const res = await fetch(path, { ...init, headers });
if (!res.ok) {
throw new Error(await errorMessage(res, fallbackMessage));
}
diff --git a/web/frontend/src/components/AgentPanel.tsx b/web/frontend/src/components/AgentPanel.tsx
index 9f145f87..ab80b23e 100644
--- a/web/frontend/src/components/AgentPanel.tsx
+++ b/web/frontend/src/components/AgentPanel.tsx
@@ -1,63 +1,70 @@
-import { useCallback, useEffect, useState } from 'react'
-import { Circle, Monitor, RefreshCw, X } from 'lucide-react'
-import { listAgents } from '../api'
+import { lazy, Suspense, useEffect, useMemo, useRef, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import type { TFunction } from 'i18next'
+import { Monitor, Search } from 'lucide-react'
import type { AgentInfo } from '../api'
-import AgentTerminal from './terminal'
-import { cn } from '@aspect/theme'
-import { Spinner } from '@aspect/ui'
+// Lazy — same @xterm chunk App splits; a static import here would pull it back
+// into the first-paint bundle.
+const AgentTerminal = lazy(() => import('./terminal'))
+import {
+ Badge,
+ EmptyState,
+ Input,
+ ListRow,
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetTitle,
+ StatusDot,
+} from '@cyber/ui'
interface AgentPanelProps {
open: boolean
+ /** The roster from useChatSession — avoids a redundant listAgents poll. */
+ agents: AgentInfo[]
+ /** When opened from a deck node click, focus this agent's console. */
+ focusAgentID?: string
onClose: () => void
}
-export default function AgentPanel({ open, onClose }: AgentPanelProps) {
- const { agents, error, loading, refresh, selected, selectedID, setSelectedID } = useAgentDirectory(open)
+export default function AgentPanel({ open, agents: rosterAgents, focusAgentID, onClose }: AgentPanelProps) {
+ const { t } = useTranslation('agent')
+ const { agents, selected, selectedID, setSelectedID } = useAgentDirectory(open, rosterAgents, focusAgentID)
const showAgentList = agents.length > 1
- if (!open) return null
-
+ // Sheet is a controlled Radix dialog: it owns the overlay, right-slide
+ // animation, focus trap/restore, Esc and the corner close button — a11y the
+ // panel previously hand-rolled.
return (
-
-
-
+ { if (!next) onClose() }}>
+
+
- Agent Console
-
+ {t('agentConsole')}
+
{agents.length}
-
-
-
- {selected ? `${selected.name} · ${selected.busy ? 'busy' : 'idle'}` : 'No agent selected'}
+
+
+ {selected ? `${selected.name} · ${selected.busy ? t('busy') : t('idle')}` : t('noAgentSelected')}
+
-
- {loading ? (
-
-
-
- ) : error ? (
-
- {error}
-
- ) : agents.length === 0 ? (
-
-
- No agents connected
+ {agents.length === 0 ? (
+
+
) : (
@@ -65,113 +72,134 @@ export default function AgentPanel({ open, onClose }: AgentPanelProps) {
refresh(true)}
onSelect={setSelectedID}
/>
)}
- {selected && }
+ {selected && (
+ }>
+
+
+ )}
)}
-
-
+
+
)
}
-function useAgentDirectory(open: boolean) {
- const [agents, setAgents] = useState([])
- const [loading, setLoading] = useState(false)
- const [error, setError] = useState('')
+// Derives selection state from the parent-provided roster (useChatSession
+// already polls listAgents every 5s). No internal fetch or polling — the agent
+// list is a prop, so the panel never double-polls.
+function useAgentDirectory(open: boolean, roster: AgentInfo[], focusAgentID?: string) {
const [selectedID, setSelectedID] = useState('')
- const refresh = useCallback((silent = false) => {
- if (!silent) {
- setLoading(true)
- setError('')
- }
- return listAgents()
- .then((items) => {
- setAgents(items)
- setSelectedID((current) => items.some((agent) => agent.id === current) ? current : items[0]?.id || '')
- })
- .catch((err: Error) => {
- if (!silent) setError(err.message || 'Failed to load agents')
- })
- .finally(() => {
- if (!silent) setLoading(false)
- })
- }, [])
-
+ // Keep selection valid as the roster changes (agents disconnect / reconnect).
useEffect(() => {
- if (!open) return
- refresh()
- }, [open, refresh])
+ setSelectedID((current) => roster.some((a) => a.id === current) ? current : roster[0]?.id || '')
+ }, [roster])
+ // Focus a specific node when the panel is opened from a deck node click.
+ // Apply it exactly ONCE per open/focus request: `roster` is in the deps only
+ // so we can wait for the roster to load, but the 5s poll replaces `roster`
+ // every tick — without this guard the effect would re-fire and yank the
+ // selection back to the focused node, defeating any manual agent switch.
+ const focusAppliedRef = useRef(false)
+ useEffect(() => {
+ focusAppliedRef.current = false
+ }, [open, focusAgentID])
useEffect(() => {
- if (!open) return
- const interval = setInterval(() => refresh(true), 5000)
- return () => clearInterval(interval)
- }, [open, refresh])
+ if (focusAppliedRef.current) return
+ if (open && focusAgentID && roster.some((a) => a.id === focusAgentID)) {
+ setSelectedID(focusAgentID)
+ focusAppliedRef.current = true
+ }
+ }, [open, focusAgentID, roster])
- const selected = agents.find((agent) => agent.id === selectedID) || agents[0] || null
+ const selected = roster.find((agent) => agent.id === selectedID) || roster[0] || null
- return { agents, error, loading, refresh, selected, selectedID, setSelectedID }
+ return { agents: roster, selected, selectedID, setSelectedID }
}
function AgentList({
agents,
- onRefresh,
onSelect,
selectedID,
}: {
agents: AgentInfo[]
- onRefresh: () => void
onSelect: (id: string) => void
selectedID: string
}) {
+ const { t } = useTranslation('agent')
+ const [query, setQuery] = useState('')
+
+ // Busy agents first, then alphabetical — keeps active nodes at the top.
+ const sorted = useMemo(
+ () =>
+ [...agents].sort((a, b) => {
+ if (a.busy !== b.busy) return a.busy ? -1 : 1
+ return (a.name || '').localeCompare(b.name || '')
+ }),
+ [agents],
+ )
+ const filtered = useMemo(() => {
+ const q = query.trim().toLowerCase()
+ if (!q) return sorted
+ return sorted.filter((a) =>
+ `${a.name} ${a.identity?.model || ''} ${a.identity?.provider || ''} ${a.identity?.hostname || ''}`
+ .toLowerCase()
+ .includes(q),
+ )
+ }, [sorted, query])
+ const busy = agents.filter((a) => a.busy).length
+ const showFilter = agents.length > 6
+
return (