From a0df3ec026bcff77527633f8a76f0aded9096d18 Mon Sep 17 00:00:00 2001 From: Nathaniel Leonardjoi Date: Tue, 7 Jul 2026 02:58:59 -0700 Subject: [PATCH 01/25] =?UTF-8?q?chore:=20=E7=A7=BB=E9=99=A4=20cyber-ui=20?= =?UTF-8?q?submodule,=E6=94=B6=E6=95=9B=20.gitignore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cyber-ui 已 vendored 进 src/{ui,markdown,viewer,terminal-view,lib/theme}, 删除 submodule 引用;整理忽略规则(运营产物、静态构建残留、泄漏配置)。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 69 ++++++++++++++++--------------------------- .gitmodules | 3 -- web/frontend/cyber-ui | 1 - 3 files changed, 26 insertions(+), 47 deletions(-) delete mode 160000 web/frontend/cyber-ui diff --git a/.gitignore b/.gitignore index 6fb9b397..0d55b21f 100644 --- a/.gitignore +++ b/.gitignore @@ -23,53 +23,36 @@ dist/ *.sock.lock /aiscan /spray -aiscan_test -aiscan_test_eval -aiscan-test-provider -aiscan-test-ioa* out/ scan_results.jsonl pw_driver_bin node_modules/ community.yaml -aiscan.yaml -config.yaml -# Local data / logs -.aiscan/ -.playwright-cli/ -tmp/ -*.log -*.jsonl -*.png -*.jar +# Local runtime state / operator artifacts +/aiscan-deploy.yaml +/*.log +/.claude/ +# operator scan outputs dumped at repo root (screenshots, IP lists, app dumps, findings/reports) +/*.png +/*_ips.txt +/apps_*.json +/*_findings.md +/*_report.md + +# leftover from root-build workaround (see memory web-static-build-permission) +web/static.oldroot* +web/static.rootold* +web/static.* +web/static-stale/ -# Test payloads / scratch files -webhook.json -xss_payload.json -xss_prod.json -cookies.txt -cookies_sup.txt -login_data.txt -weak_pwd.txt -sqli_payload.txt -ssrf_payload.json -ssti_payload.json -spoof_prod.json -payload.json -cart.json -chat.json -chat_msg.json -checkout.json -cms_body.html -cms_post.json -cms_post.txt -cms_xss.txt -portal.html -portal_full.html -refer/ -agent -*.events.jsonl -*.err.log -*.out.log -*.pid +# cyber-ui submodule removed (vendored into src/{ui,markdown,viewer,terminal-view,lib/theme}); +# root-owned remnant on disk can't be deleted without sudo — ignore until reclaimed: +# sudo rm -rf web/frontend/cyber-ui .git/modules/web/frontend/cyber-ui +web/frontend/cyber-ui/ + +# operator artifacts kept on disk but out of git (2026-07-06 post-reset cleanup) +/monitor/ +/scripts/ +/scan_report.md +aiscan.yaml diff --git a/.gitmodules b/.gitmodules index 40211975..5055718a 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,3 @@ [submodule "templates"] path = templates url = https://github.com/chainreactors/templates.git -[submodule "web/frontend/cyber-ui"] - path = web/frontend/cyber-ui - url = https://github.com/chainreactors/cyber-ui.git diff --git a/web/frontend/cyber-ui b/web/frontend/cyber-ui deleted file mode 160000 index 1e2a8c2f..00000000 --- a/web/frontend/cyber-ui +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 1e2a8c2f99ed89e15d1186725c511ec3b3db095b From bbd9c0e4e36681777034aec8cdc771bb49e48fe5 Mon Sep 17 00:00:00 2001 From: Nathaniel Leonardjoi Date: Tue, 7 Jul 2026 02:59:26 -0700 Subject: [PATCH 02/25] =?UTF-8?q?fix(provider):=20LLM=20=E5=8D=8F=E8=AE=AE?= =?UTF-8?q?=E6=8E=A8=E6=96=AD/model=20=E8=A6=86=E7=9B=96/=E8=83=BD?= =?UTF-8?q?=E5=8A=9B=E5=AF=B9=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - base_url 空 provider 时按 host 推断 anthropic/openai 协议,避免误走 openai 协议对 Anthropic-only 网关空体 404 - ANTHROPIC_MODEL 等 provider-env 降级为 fallback-only,新增 AISCAN_MODEL 自有命名空间覆盖,避免 prod env 静默盖过 web 配置 - endpoint hint + provider 能力对齐,补充 parity/hint 测试 Co-Authored-By: Claude Opus 4.8 (1M context) --- core/config/env.go | 40 ++++++- core/config/loader_test.go | 105 +++++++++++++++++++ pkg/agent/provider/anthropic.go | 44 +++++++- pkg/agent/provider/capability_parity_test.go | 22 ++++ pkg/agent/provider/endpoint_hint_test.go | 66 ++++++++++++ pkg/agent/provider/http.go | 15 +++ pkg/agent/provider/openai.go | 40 ++++++- pkg/agent/provider/provider.go | 17 ++- 8 files changed, 336 insertions(+), 13 deletions(-) create mode 100644 pkg/agent/provider/capability_parity_test.go create mode 100644 pkg/agent/provider/endpoint_hint_test.go diff --git a/core/config/env.go b/core/config/env.go index b85c7fcc..1daebff5 100644 --- a/core/config/env.go +++ b/core/config/env.go @@ -40,26 +40,58 @@ func applyLLMEnvironment(option *Option, explicit Option, lookup envLookup) { option.Provider = selectedProvider } + // AISCAN_BASE_URL is aiscan's own namespace: an intentional override that wins + // over a base URL set in the config file (CLI --base-url wins via the explicit gate). if strings.TrimSpace(explicit.BaseURL) == "" { if v := firstEnv(lookup, "AISCAN_BASE_URL", "AISCAN_BASEURL", "AISCAN_LLM_BASE_URL", "AISCAN_LLM_BASEURL"); v != "" { option.BaseURL = v - } else if v := providerBaseURLEnv(selectedProvider, lookup); v != "" { + } + } + // Provider-scoped base-URL envs (ANTHROPIC_BASE_URL, OPENAI_BASE_URL, …) are + // commonly injected by the surrounding environment for *other* tools — e.g. + // Claude-Code-style gateways export ANTHROPIC_BASE_URL. Treat them as a fallback + // only: they must not silently override a base URL the user configured for aiscan + // (config file / Settings UI). Apply only when nothing else set one. Mirrors the + // model handling below so a hub-launched agent that inherits the hub's env still + // honors the Settings-saved base URL. + if strings.TrimSpace(option.BaseURL) == "" { + if v := providerBaseURLEnv(selectedProvider, lookup); v != "" { option.BaseURL = v } } + // AISCAN_MODEL / AISCAN_LLM_MODEL are aiscan's *own* namespace: an intentional + // override that still wins over a model set in the config file (CLI --model + // wins over it via the explicit gate). if strings.TrimSpace(explicit.Model) == "" { if v := firstEnv(lookup, "AISCAN_MODEL", "AISCAN_LLM_MODEL"); v != "" { option.Model = v - } else if v := providerModelEnv(selectedProvider, lookup); v != "" { + } + } + // Provider-scoped model envs (ANTHROPIC_MODEL, OPENAI_MODEL, …) are commonly + // injected by the surrounding environment for *other* tools — e.g. Claude-Code + // style gateways export ANTHROPIC_MODEL. Treat them as a fallback only: they + // must not silently override a model the user configured for aiscan (config + // file / Settings UI or --model). Apply only when nothing else set a model. + if strings.TrimSpace(option.Model) == "" { + if v := providerModelEnv(selectedProvider, lookup); v != "" { option.Model = v } } + // AISCAN_API_KEY is aiscan's own namespace: an intentional override that wins + // over a key set in the config file (CLI --api-key wins via the explicit gate). if strings.TrimSpace(explicit.APIKey) == "" { - if v := providerAPIKeyEnv(selectedProvider, lookup); v != "" { + if v := firstEnv(lookup, "AISCAN_API_KEY", "AISCAN_LLM_API_KEY"); v != "" { option.APIKey = v - } else if v := firstEnv(lookup, "AISCAN_API_KEY", "AISCAN_LLM_API_KEY"); v != "" { + } + } + // Provider-scoped key envs (ANTHROPIC_API_KEY, OPENAI_API_KEY) are commonly + // present for *other* tools. Treat them as a fallback only so they never override + // a key the user configured for aiscan (config file / Settings UI). Apply only + // when nothing else set one — same rationale as base URL and model above. + if strings.TrimSpace(option.APIKey) == "" { + if v := providerAPIKeyEnv(selectedProvider, lookup); v != "" { option.APIKey = v } } diff --git a/core/config/loader_test.go b/core/config/loader_test.go index 59459e35..9e2636e3 100644 --- a/core/config/loader_test.go +++ b/core/config/loader_test.go @@ -602,6 +602,111 @@ llm: }) } +// A provider-scoped model env (ANTHROPIC_MODEL) is often injected by the +// surrounding environment for another tool (a Claude-Code style gateway). It must +// NOT override a model the user configured for aiscan itself — otherwise editing +// the model in the config file / Settings UI has no effect at runtime. AISCAN_MODEL +// (aiscan's own namespace) keeps overriding; the borrowed provider env only fills +// an empty slot. +func TestResolveRuntimeConfigConfigModelBeatsBorrowedProviderModelEnv(t *testing.T) { + dir := t.TempDir() + writeTestConfig(t, dir, ` +llm: + provider: anthropic + base_url: https://kiro.example/v1 + api_key: config-key + model: kimi-for-coding +`) + t.Setenv("ANTHROPIC_MODEL", "claude-opus-4-8") + + withDefaults(t, func() { + origDir, _ := os.Getwd() + os.Chdir(dir) + defer os.Chdir(origDir) + + option := Option{} + if _, err := ResolveRuntimeConfig(&option); err != nil { + t.Fatal(err) + } + if option.Model != "kimi-for-coding" { + t.Errorf("config model should win over borrowed ANTHROPIC_MODEL: got %q, want %q", option.Model, "kimi-for-coding") + } + }) + + // With no model in the config, the borrowed provider env still fills the gap. + withDefaults(t, func() { + emptyDir := t.TempDir() + writeTestConfig(t, emptyDir, "llm:\n provider: anthropic\n base_url: https://kiro.example/v1\n api_key: config-key\n") + origDir, _ := os.Getwd() + os.Chdir(emptyDir) + defer os.Chdir(origDir) + + option := Option{} + if _, err := ResolveRuntimeConfig(&option); err != nil { + t.Fatal(err) + } + if option.Model != "claude-opus-4-8" { + t.Errorf("borrowed ANTHROPIC_MODEL should fill an empty model: got %q, want %q", option.Model, "claude-opus-4-8") + } + }) +} + +// Same borrowed-env hazard as the model case, but for base_url and api_key: a +// hub-launched agent inherits the hub's env, which on a Claude-Code style gateway +// exports ANTHROPIC_BASE_URL / ANTHROPIC_API_KEY. Those must NOT override the +// base URL / key the user saved via the Settings UI (the config file) — otherwise +// "启动本地 Agent … 模型/密钥沿用当前配置" silently uses the borrowed env instead. +func TestResolveRuntimeConfigConfigBaseURLAndKeyBeatBorrowedProviderEnv(t *testing.T) { + dir := t.TempDir() + writeTestConfig(t, dir, ` +llm: + provider: anthropic + base_url: https://kiro.example/v1 + api_key: config-key + model: kimi-for-coding +`) + t.Setenv("ANTHROPIC_BASE_URL", "https://borrowed.example/v1") + t.Setenv("ANTHROPIC_API_KEY", "borrowed-key") + + withDefaults(t, func() { + origDir, _ := os.Getwd() + os.Chdir(dir) + defer os.Chdir(origDir) + + option := Option{} + if _, err := ResolveRuntimeConfig(&option); err != nil { + t.Fatal(err) + } + if option.BaseURL != "https://kiro.example/v1" { + t.Errorf("config base_url should win over borrowed ANTHROPIC_BASE_URL: got %q, want %q", option.BaseURL, "https://kiro.example/v1") + } + if option.APIKey != "config-key" { + t.Errorf("config api_key should win over borrowed ANTHROPIC_API_KEY: got %q, want %q", option.APIKey, "config-key") + } + }) + + // With no base_url / api_key in the config, the borrowed provider env still + // fills the gap (unchanged fallback behavior). + withDefaults(t, func() { + emptyDir := t.TempDir() + writeTestConfig(t, emptyDir, "llm:\n provider: anthropic\n model: kimi-for-coding\n") + origDir, _ := os.Getwd() + os.Chdir(emptyDir) + defer os.Chdir(origDir) + + option := Option{} + if _, err := ResolveRuntimeConfig(&option); err != nil { + t.Fatal(err) + } + if option.BaseURL != "https://borrowed.example/v1" { + t.Errorf("borrowed ANTHROPIC_BASE_URL should fill an empty base_url: got %q, want %q", option.BaseURL, "https://borrowed.example/v1") + } + if option.APIKey != "borrowed-key" { + t.Errorf("borrowed ANTHROPIC_API_KEY should fill an empty api_key: got %q, want %q", option.APIKey, "borrowed-key") + } + }) +} + func TestProvidersListOnly(t *testing.T) { option := Option{} option.Providers = []LLMProviderEntry{ 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..ae7f3b2a 100644 --- a/pkg/agent/provider/http.go +++ b/pkg/agent/provider/http.go @@ -86,6 +86,21 @@ func (r *apiRequest) do(ctx context.Context, method, endpoint string, body []byt 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 { 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" } From 8f801d7af28d4516d7acc498d4b444b4854b4cdd Mon Sep 17 00:00:00 2001 From: Nathaniel Leonardjoi Date: Tue, 7 Jul 2026 02:59:37 -0700 Subject: [PATCH 03/25] =?UTF-8?q?feat(probe):=20=E8=BF=9E=E6=8E=A5?= =?UTF-8?q?=E4=B8=8E=20LLM=20=E4=B8=BB=E5=8A=A8=E6=8E=A2=E6=B4=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 pkg/probe(conn/llm)做真实可达性探测:llm_available 只表示 client 建好,不查 404;顶栏 LLMHealth 主动 testLLM 才真正探活。 web 侧 probe 端点 + 回归测试。 Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/probe/conn.go | 334 +++++++++++++++++++++++++++++++++++++ pkg/probe/llm.go | 169 +++++++++++++++++++ pkg/web/conn_probe_test.go | 254 ++++++++++++++++++++++++++++ pkg/web/llm_probe_test.go | 264 +++++++++++++++++++++++++++++ pkg/web/probe.go | 55 ++++++ 5 files changed, 1076 insertions(+) create mode 100644 pkg/probe/conn.go create mode 100644 pkg/probe/llm.go create mode 100644 pkg/web/conn_probe_test.go create mode 100644 pkg/web/llm_probe_test.go create mode 100644 pkg/web/probe.go diff --git a/pkg/probe/conn.go b/pkg/probe/conn.go new file mode 100644 index 00000000..9f86bd0c --- /dev/null +++ b/pkg/probe/conn.go @@ -0,0 +1,334 @@ +// 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"` +} + +// ConnTestResponse bundles every check run for one settings section. +type ConnTestResponse struct { + Checks []ConnCheck `json:"checks"` +} + +// 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) (ConnTestResponse, 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 ConnTestResponse{}, fmt.Errorf("section %q has no connection to test", section) + } +} + +// --- section probes --- + +func testCyberhub(ctx context.Context, in, stored webproto.DistributeConfig) ConnTestResponse { + hubURL := fallbackStr(in.Cyberhub.URL, stored.Cyberhub.URL) + key := fallbackStr(in.Cyberhub.Key, stored.Cyberhub.Key) + return ConnTestResponse{Checks: []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) ConnTestResponse { + 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 ConnTestResponse{Checks: checks} +} + +func testSearch(ctx context.Context, in, stored webproto.DistributeConfig) ConnTestResponse { + keys := fallbackStr(in.Search.TavilyKeys, stored.Search.TavilyKeys) + return ConnTestResponse{Checks: []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) ConnTestResponse { + ioaURL := fallbackStr(in.IOA.URL, stored.IOA.URL) + token := fallbackStr(in.IOA.Token, stored.IOA.Token) + return ConnTestResponse{Checks: []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") + resp, err := proxyHTTPClient(proxy, connProbeTimeout).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 { + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, snippet(body)) + } + 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 "" +} + +func proxyHTTPClient(proxy string, timeout time.Duration) *http.Client { + transport := &http.Transport{Proxy: http.ProxyFromEnvironment} + if strings.TrimSpace(proxy) != "" { + if proxyURL, err := url.Parse(proxy); err == nil { + transport.Proxy = http.ProxyURL(proxyURL) + } + } + return &http.Client{Timeout: timeout, Transport: transport} +} + +func snippet(body []byte) string { + s := strings.TrimSpace(string(body)) + if len(s) > 200 { + return s[:200] + "…" + } + return s +} diff --git a/pkg/probe/llm.go b/pkg/probe/llm.go new file mode 100644 index 00000000..745ac0db --- /dev/null +++ b/pkg/probe/llm.go @@ -0,0 +1,169 @@ +package probe + +import ( + "context" + "strings" + "time" + + "github.com/chainreactors/aiscan/pkg/agent" +) + +// LLMTestRequest carries the connection parameters the user wants to verify. +// 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). +type LLMTestRequest struct { + Provider string `json:"provider"` + BaseURL string `json:"base_url"` + APIKey string `json:"api_key"` + Model string `json:"model"` + 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 + +// LLMModelsRequest carries the connection parameters needed to enumerate the +// models an endpoint advertises. It mirrors LLMTestRequest minus Model, since +// listing is what populates the model field in the first place. An empty APIKey +// means "use the key already stored in the config". +type LLMModelsRequest struct { + Provider string `json:"provider"` + BaseURL string `json:"base_url"` + APIKey string `json:"api_key"` + Proxy string `json:"proxy"` +} + +// 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 LLMModelsRequest, 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 LLMTestRequest, 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/web/conn_probe_test.go b/pkg/web/conn_probe_test.go new file mode 100644 index 00000000..d190ff2a --- /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/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(resp probe.ConnTestResponse, name string) (probe.ConnCheck, bool) { + for _, c := range resp.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.Checks) + } +} + +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.Checks) + } + 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.Checks) + } + 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.Checks) + } +} + +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.Checks) + } + 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.Checks) + } +} + +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.ConnTestResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + t.Fatalf("decode: %v", err) + } + if len(out.Checks) != 1 || out.Checks[0].Name != "cyberhub" { + t.Fatalf("expected one cyberhub check, got %+v", out.Checks) + } + + // 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.Checks) + } + if !strings.Contains(c.Detail, "1 space") { + t.Fatalf("expected space count in detail, got %q", c.Detail) + } +} diff --git a/pkg/web/llm_probe_test.go b/pkg/web/llm_probe_test.go new file mode 100644 index 00000000..7637329c --- /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/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.LLMTestRequest{ + 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.LLMTestRequest{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.LLMTestRequest{ + 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.LLMTestRequest{ + 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.LLMModelsRequest{ + 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.LLMModelsRequest{ + 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.LLMModelsRequest{ + 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.LLMModelsRequest{ + 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/probe.go b/pkg/web/probe.go new file mode 100644 index 00000000..3e462d7d --- /dev/null +++ b/pkg/web/probe.go @@ -0,0 +1,55 @@ +package web + +import ( + "context" + "strings" + + "github.com/chainreactors/aiscan/pkg/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.ConnTestResponse, 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.LLMTestRequest) (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.LLMModelsRequest) (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 +} From e23a3a35841de30a008d5438cd5c6399361372a7 Mon Sep 17 00:00:00 2001 From: Nathaniel Leonardjoi Date: Tue, 7 Jul 2026 03:00:25 -0700 Subject: [PATCH 04/25] =?UTF-8?q?feat(slashcmd):=20=E7=BB=9F=E4=B8=80=20sl?= =?UTF-8?q?ash=20=E5=91=BD=E4=BB=A4=E7=9B=AE=E5=BD=95,=E5=9B=BA=E5=AE=9A?= =?UTF-8?q?=E7=9B=92=E5=AD=90=E6=B8=B2=E6=9F=93=20TUI=20=E9=9D=A2=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 pkg/slashcmd 单一目录,驱动 REPL + web + 菜单;hub 按 Scope 路由; /shell 退役改 !bash - TUI /spaces /nodes /messages 改 renderFixedBox(定宽 + ANSI 裁剪 + runewidth CJK 对齐);/status 的 IOA URL 走 redactIOAURL 脱敏 - 补 banner/catalog 渲染回归测试 Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/commands/command.go | 26 ++---- pkg/commands/factory.go | 3 - pkg/slashcmd/slashcmd.go | 130 ++++++++++++++++++++++++++++++ pkg/slashcmd/slashcmd_test.go | 67 ++++++++++++++++ pkg/tui/banner.go | 132 +++++++++++++++++++++++++++--- pkg/tui/banner_render_test.go | 147 ++++++++++++++++++++++++++++++++++ pkg/tui/catalog_test.go | 53 ++++++++++++ pkg/tui/commands.go | 16 +++- pkg/tui/console.go | 10 +-- pkg/tui/ioa.go | 89 ++++++++++++++++++++ pkg/tui/output.go | 4 - pkg/tui/remote_console.go | 18 ----- 12 files changed, 633 insertions(+), 62 deletions(-) create mode 100644 pkg/slashcmd/slashcmd.go create mode 100644 pkg/slashcmd/slashcmd_test.go create mode 100644 pkg/tui/banner_render_test.go create mode 100644 pkg/tui/catalog_test.go diff --git a/pkg/commands/command.go b/pkg/commands/command.go index 95091038..a3c50586 100644 --- a/pkg/commands/command.go +++ b/pkg/commands/command.go @@ -9,7 +9,6 @@ import ( "sync" "github.com/chainreactors/aiscan/pkg/agent/provider" - ) type ToolDefinition = provider.ToolDefinition @@ -41,12 +40,12 @@ type WorkDirAware interface { } 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 @@ -60,23 +59,12 @@ func (r *CommandRegistry) SetOutput(w io.Writer) { 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/commands/factory.go b/pkg/commands/factory.go index bd749af1..04b06a1b 100644 --- a/pkg/commands/factory.go +++ b/pkg/commands/factory.go @@ -3,8 +3,6 @@ package commands import ( "sync" - "github.com/chainreactors/aiscan/core/eventbus" - "github.com/chainreactors/aiscan/core/output" "github.com/chainreactors/aiscan/pkg/telemetry" ) @@ -28,7 +26,6 @@ type Deps struct { NodeName string NodeMeta map[string]any TavilyKeys string // comma-separated Tavily API keys (build-time fallback) - DataBus *eventbus.Bus[output.ToolDataEvent] } func (d *Deps) GetLogger() telemetry.Logger { diff --git a/pkg/slashcmd/slashcmd.go b/pkg/slashcmd/slashcmd.go new file mode 100644 index 00000000..a2eda4b2 --- /dev/null +++ b/pkg/slashcmd/slashcmd.go @@ -0,0 +1,130 @@ +// Package slashcmd is the single source of truth for aiscan's user-facing slash +// commands. It is a leaf package — types plus a static catalog, with no imports +// of pkg/tui, pkg/web, pkg/webagent, or pkg/agent — so every surface describes +// the same command set without drifting: +// +// - the interactive REPL (pkg/tui) keeps its command set in parity with Core() +// (guarded by a test), so /help and completion never diverge from the web; +// - the web hub (pkg/web) routes a typed slash command by its Scope instead of +// a hardcoded switch, and renders /help + the "/" menu from the catalog; +// - the web frontend populates its "/" menu from the catalog the agent + hub +// report, so it always reflects what actually works. +// +// This is deliberately distinct from pkg/commands, which is the LLM *tool* +// registry (the structured tools/pseudo-commands the model calls). slashcmd is +// only about the "/verb" commands a human types. +package slashcmd + +import "strings" + +// Scope declares where a command executes when typed in the web chat. It is only +// meaningful for web dispatch; the standalone REPL runs every command it has a +// handler for, regardless of Scope. +type Scope uint8 + +const ( + // ScopeAgent runs against the live agent — model, tools, skills, IOA, and + // the conversation. In the web this is forwarded to the agent node over the + // existing AgentConsole bridge (pkg/webagent.runChatREPLLine). + ScopeAgent Scope = iota + // ScopeHub runs on the web hub — the multi-agent roster, the scan pipeline, + // and the session store. It has no counterpart in the standalone REPL. + ScopeHub +) + +// Spec is the surface-neutral description of one slash command. +type Spec struct { + Name string `json:"name"` // "/scan" + Aliases []string `json:"aliases,omitempty"` // ["/goal"] + Usage string `json:"usage,omitempty"` // "/scan [--mode full]" + Description string `json:"description,omitempty"` + Scope Scope `json:"scope"` + // WebMenu advertises the command in the web "/" menu. Run-control commands + // (/stop, /followup, /eval, /loop, /exit) are false: the web expresses those + // through first-class UI (the Pause button, the Goal toggle), not slash text, + // and the per-line web bridge cannot honor "act on the running task" anyway. + WebMenu bool `json:"web_menu,omitempty"` +} + +// core is the canonical catalog. Agent-scope entries come first, in REPL /help +// order; hub-scope entries follow. Descriptions are kept verbatim from the +// former pkg/tui definitions so REPL output is unchanged. +var core = []Spec{ + {Name: "/help", Scope: ScopeHub, WebMenu: true, Description: "查看命令面板"}, + {Name: "/status", Scope: ScopeAgent, WebMenu: true, Description: "查看模型、渲染模式、IOA 和 skills"}, + {Name: "/clear", Scope: ScopeAgent, WebMenu: true, Description: "清空当前会话上下文"}, + {Name: "/stop", Scope: ScopeAgent, WebMenu: false, Description: "停止当前正在运行的任务"}, + {Name: "/followup", Scope: ScopeAgent, WebMenu: false, Usage: "/followup ", Description: "排队到当前任务结束后再发送"}, + {Name: "/eval", Aliases: []string{"/goal"}, Scope: ScopeAgent, WebMenu: false, Usage: "/eval | /eval off", Description: "设置/查看/关闭 goal evaluation (/eval off 关闭)"}, + {Name: "/loop", Scope: ScopeAgent, WebMenu: false, Usage: "/loop 30s | /loop list | /loop stop ", Description: "定时循环任务 (/loop 30s | /loop list | /loop stop )"}, + {Name: "/exit", Aliases: []string{"/quit"}, Scope: ScopeAgent, WebMenu: false, Description: "退出交互模式"}, + {Name: "/provider", Scope: ScopeAgent, WebMenu: true, Usage: "/provider [list | set ]", Description: "查看/管理 LLM provider 链"}, + {Name: "/spaces", Scope: ScopeAgent, WebMenu: true, Description: "List all IOA spaces"}, + {Name: "/messages", Scope: ScopeAgent, WebMenu: true, Usage: "/messages ", Description: "List start messages in a space"}, + {Name: "/context", Scope: ScopeAgent, WebMenu: true, Usage: "/context ", Description: "View message thread/context"}, + {Name: "/nodes", Scope: ScopeAgent, WebMenu: true, Usage: "/nodes [space]", Description: "List nodes (optionally scoped to a space)"}, + + {Name: "/scan", Scope: ScopeHub, WebMenu: true, Usage: "/scan [--mode full] [--verify] [--sniper] [--deep]", Description: "在本会话运行扫描"}, + {Name: "/agents", Scope: ScopeHub, WebMenu: true, Description: "列出已连接的 agent"}, +} + +// Core returns a copy of the full static catalog in canonical order. +func Core() []Spec { return append([]Spec(nil), core...) } + +// Lookup resolves a bare verb (leading "/" optional) to its Spec, matching Name +// or any alias case-insensitively. ok is false for an unknown verb — which the +// web dispatch treats identically to an agent-scope command (both fall through +// to the agent), so skill commands need not be classified here. +func Lookup(verb string) (Spec, bool) { + verb = strings.ToLower(strings.TrimPrefix(strings.TrimSpace(verb), "/")) + if verb == "" { + return Spec{}, false + } + for _, s := range core { + if specMatches(s, verb) { + return s, true + } + } + return Spec{}, false +} + +func specMatches(s Spec, verb string) bool { + if strings.ToLower(strings.TrimPrefix(s.Name, "/")) == verb { + return true + } + for _, a := range s.Aliases { + if strings.ToLower(strings.TrimPrefix(a, "/")) == verb { + return true + } + } + return false +} + +// HubWebMenu returns the hub-scope, menu-visible static specs (/scan, /agents, +// /help). The hub prepends these to a session agent's reported commands. +func HubWebMenu() []Spec { return webMenuByScope(ScopeHub) } + +// AgentWebMenu returns the agent-scope, menu-visible static specs (/status, +// /provider, the IOA commands, ...). An agent reports these — plus one SkillSpec +// per loaded skill — to the hub so the web menu shows what that agent offers. +func AgentWebMenu() []Spec { return webMenuByScope(ScopeAgent) } + +func webMenuByScope(scope Scope) []Spec { + var out []Spec + for _, s := range core { + if s.WebMenu && s.Scope == scope { + out = append(out, s) + } + } + return out +} + +// SkillSpec builds a runtime menu spec for a loaded skill command. +func SkillSpec(name, description string) Spec { + return Spec{ + Name: "/" + strings.TrimPrefix(strings.TrimSpace(name), "/"), + Description: description, + Scope: ScopeAgent, + WebMenu: true, + } +} diff --git a/pkg/slashcmd/slashcmd_test.go b/pkg/slashcmd/slashcmd_test.go new file mode 100644 index 00000000..0ed11fe2 --- /dev/null +++ b/pkg/slashcmd/slashcmd_test.go @@ -0,0 +1,67 @@ +package slashcmd + +import "testing" + +func TestLookupScope(t *testing.T) { + cases := []struct { + verb string + wantOK bool + wantScope Scope + }{ + {"scan", true, ScopeHub}, + {"agents", true, ScopeHub}, + {"help", true, ScopeHub}, + {"status", true, ScopeAgent}, + {"provider", true, ScopeAgent}, + {"spaces", true, ScopeAgent}, + {"stop", true, ScopeAgent}, + {"eval", true, ScopeAgent}, + {"goal", true, ScopeAgent}, // alias of /eval + {"quit", true, ScopeAgent}, // alias of /exit + {"/scan", true, ScopeHub}, // leading slash tolerated + {"SCAN", true, ScopeHub}, // case-insensitive + {"unknownxyz", false, 0}, // unknown → falls through to chat + {"etc/passwd", false, 0}, // path-like → not a command + {"", false, 0}, + } + for _, tc := range cases { + spec, ok := Lookup(tc.verb) + if ok != tc.wantOK { + t.Errorf("Lookup(%q) ok = %v, want %v", tc.verb, ok, tc.wantOK) + continue + } + if ok && spec.Scope != tc.wantScope { + t.Errorf("Lookup(%q) scope = %v, want %v", tc.verb, spec.Scope, tc.wantScope) + } + } +} + +func TestRunControlNotInWebMenu(t *testing.T) { + // Run-control commands are REPL-only: the web expresses them via the Pause + // button and the Goal toggle, so they must never appear in a web menu. + runControl := map[string]bool{"/stop": true, "/followup": true, "/eval": true, "/loop": true, "/exit": true} + for _, s := range append(HubWebMenu(), AgentWebMenu()...) { + if runControl[s.Name] { + t.Errorf("run-control command %q leaked into the web menu", s.Name) + } + if !s.WebMenu { + t.Errorf("menu builder returned non-WebMenu command %q", s.Name) + } + } +} + +func TestWebMenuScopes(t *testing.T) { + for _, s := range HubWebMenu() { + if s.Scope != ScopeHub { + t.Errorf("HubWebMenu returned non-hub command %q", s.Name) + } + } + for _, s := range AgentWebMenu() { + if s.Scope != ScopeAgent { + t.Errorf("AgentWebMenu returned non-agent command %q", s.Name) + } + } + if got := len(HubWebMenu()); got != 3 { // scan, agents, help + t.Errorf("HubWebMenu size = %d, want 3", got) + } +} diff --git a/pkg/tui/banner.go b/pkg/tui/banner.go index 6c327cbf..b946b820 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" ) @@ -105,24 +106,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 +135,68 @@ 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 { + r := []rune(s) + if len(r) <= max || max < 5 { + return s + } + keep := max - 1 + head := keep / 2 + tail := keep - head + return string(r[:head]) + "…" + string(r[len(r)-tail:]) } func ansiWrap(s, code string, enabled bool) string { @@ -225,12 +284,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: "history", Detail: truncMiddle(info.History, detailBudget)}, } if info.Skills != "" { rows = append(rows, helpRow{Command: "skills", Detail: info.Skills}) @@ -252,13 +312,61 @@ 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 { + 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 { + if w := visibleWidth(cell); 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] + } + 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..e066a0f8 --- /dev/null +++ b/pkg/tui/banner_render_test.go @@ -0,0 +1,147 @@ +package tui + +import ( + "strings" + "testing" + + outputpkg "github.com/chainreactors/aiscan/core/output" +) + +// 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 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 := len([]rune(got)); n > 40 { + t.Errorf("truncMiddle len = %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) + } +} + +// 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 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 (colour 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/catalog_test.go b/pkg/tui/catalog_test.go new file mode 100644 index 00000000..c8124eeb --- /dev/null +++ b/pkg/tui/catalog_test.go @@ -0,0 +1,53 @@ +package tui + +import ( + "reflect" + "testing" + + "github.com/chainreactors/aiscan/pkg/slashcmd" +) + +// TestREPLCommandsMatchCatalog guards the single-source-of-truth contract: the +// REPL's static command set (metadata) must stay in parity with the slashcmd +// catalog that the web hub + frontend menu read, so /help and the "/" menu never +// describe the same command differently. Building the command lists only creates +// closures (never invokes them), so a zero-value console is sufficient. +func TestREPLCommandsMatchCatalog(t *testing.T) { + r := &AgentConsole{} + var repl []Command + repl = append(repl, r.builtinCommands()...) + repl = append(repl, r.providerCommands()...) + repl = append(repl, r.ioaCommands()...) + + catalog := make(map[string]slashcmd.Spec) + for _, s := range slashcmd.Core() { + catalog[s.Name] = s + } + + replNames := make(map[string]bool, len(repl)) + for _, c := range repl { + replNames[c.Name] = true + s, ok := catalog[c.Name] + if !ok { + t.Errorf("REPL command %q has no slashcmd.Core() entry — add it to the catalog", c.Name) + continue + } + if s.Description != c.Description { + t.Errorf("%q description drift:\n REPL: %q\n catalog: %q", c.Name, c.Description, s.Description) + } + if !reflect.DeepEqual(s.Aliases, c.Aliases) { + t.Errorf("%q alias drift:\n REPL: %v\n catalog: %v", c.Name, c.Aliases, s.Aliases) + } + } + + // Every agent-scope, runnable catalog command must exist in the REPL (hub-scope + // entries like /scan, /agents run only on the web hub and have no REPL handler). + for _, s := range slashcmd.Core() { + if s.Scope != slashcmd.ScopeAgent { + continue + } + if !replNames[s.Name] { + t.Errorf("agent-scope catalog command %q has no REPL command", s.Name) + } + } +} diff --git a/pkg/tui/commands.go b/pkg/tui/commands.go index 6bc74a58..54146444 100644 --- a/pkg/tui/commands.go +++ b/pkg/tui/commands.go @@ -3,6 +3,7 @@ package tui import ( "context" "fmt" + "net/url" "strings" cfg "github.com/chainreactors/aiscan/core/config" @@ -151,7 +152,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 +174,16 @@ 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. On a parse failure or a token-less URL it returns +// the input unchanged. +func redactIOAURL(raw string) string { + u, err := url.Parse(raw) + if err != nil || u.User == nil { + return raw + } + u.User = nil + return u.String() +} diff --git a/pkg/tui/console.go b/pkg/tui/console.go index bc36bc4f..a9c4c692 100644 --- a/pkg/tui/console.go +++ b/pkg/tui/console.go @@ -561,7 +561,7 @@ func (r *AgentConsole) ioaCommands() []Command { if err != nil { return err } - return RunIOASpaces(ctx, client, r.option, r.stdout, r.stderr) + return r.renderIOASpaces(ctx, client) }, }, { @@ -572,7 +572,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 +598,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) }, }, } 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/output.go b/pkg/tui/output.go index fcf16cc6..399b8a85 100644 --- a/pkg/tui/output.go +++ b/pkg/tui/output.go @@ -115,8 +115,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 } @@ -247,8 +245,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 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 } - From d3e5bab3c999efaa2b4412c395661ee24f85b12a Mon Sep 17 00:00:00 2001 From: Nathaniel Leonardjoi Date: Tue, 7 Jul 2026 03:00:35 -0700 Subject: [PATCH 05/25] =?UTF-8?q?refactor(agent):=20=E8=AF=84=E4=BC=B0?= =?UTF-8?q?=E5=99=A8=E7=BB=93=E6=9E=84=E5=8C=96=E6=96=AD=E8=A8=80=E3=80=81?= =?UTF-8?q?=E4=BA=8B=E4=BB=B6=20JSON=E3=80=81=E4=B8=8A=E4=B8=8B=E6=96=87?= =?UTF-8?q?=E6=88=AA=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - evaluator 结构化断言(criteria→count/exists 确定性 Go + judge 有据判定) - event_json 评估事件序列化 + provider 热切换测试 - truncate/clip 上下文裁剪调整与测试 Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/agent/agent.go | 32 ++++++++++++- pkg/agent/evaluator/loop.go | 8 +++- pkg/agent/event_json.go | 11 +++++ pkg/agent/event_json_eval_test.go | 47 +++++++++++++++++++ pkg/agent/loop_scheduler.go | 68 +++++++--------------------- pkg/agent/provider_swap_test.go | 70 +++++++++++++++++++++++++++++ pkg/agent/subagent.go | 18 +++----- pkg/agent/truncate/clip.go | 24 ---------- pkg/agent/truncate/clip_test.go | 33 -------------- pkg/agent/truncate/truncate.go | 9 ---- pkg/agent/truncate/truncate_test.go | 17 ------- 11 files changed, 188 insertions(+), 149 deletions(-) create mode 100644 pkg/agent/event_json_eval_test.go create mode 100644 pkg/agent/provider_swap_test.go diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go index dc5702b8..8881c405 100644 --- a/pkg/agent/agent.go +++ b/pkg/agent/agent.go @@ -27,7 +27,7 @@ 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() if cfg.Inbox == nil { @@ -55,7 +55,7 @@ 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() result, runErr := runLoop(runCtx, cfg) @@ -63,6 +63,34 @@ func (a *Agent) Continue(ctx context.Context) (*Result, error) { 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 +} + +// 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 { diff --git a/pkg/agent/evaluator/loop.go b/pkg/agent/evaluator/loop.go index 29d25b80..6c56373e 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 } diff --git a/pkg/agent/event_json.go b/pkg/agent/event_json.go index 65b2ddb6..eaedb8ea 100644 --- a/pkg/agent/event_json.go +++ b/pkg/agent/event_json.go @@ -32,6 +32,13 @@ 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"` }{ Timestamp: ts.UTC().Format(time.RFC3339Nano), Type: e.Type, @@ -45,6 +52,10 @@ 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, } 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_scheduler.go b/pkg/agent/loop_scheduler.go index baff77a7..ff3f7a83 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 } @@ -86,18 +79,9 @@ func NewLoopScheduler(ib inbox.Inbox, logger telemetry.Logger) *LoopScheduler { } } -func (s *LoopScheduler) SetMinInterval(d time.Duration) { - s.mu.Lock() - defer s.mu.Unlock() - s.minInterval = d -} - 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 +168,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 +191,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 +220,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/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/subagent.go b/pkg/agent/subagent.go index 5ddbc5a6..b5aae258 100644 --- a/pkg/agent/subagent.go +++ b/pkg/agent/subagent.go @@ -32,12 +32,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 { @@ -267,12 +267,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..5b467709 100644 --- a/pkg/agent/truncate/clip.go +++ b/pkg/agent/truncate/clip.go @@ -37,27 +37,3 @@ func ClipRunes(s string, maxRunes int) string { } return s[:bytePos] + "…" } - -// ClipLines keeps at most maxLines lines, each truncated to maxWidth runes. -// Returns the selected lines and the count of hidden lines. -func ClipLines(text string, maxLines, maxWidth int) ([]string, int) { - all := strings.Split(text, "\n") - total := len(all) - if total > maxLines { - all = all[:maxLines] - } - out := make([]string, len(all)) - for i, line := range all { - if utf8.RuneCountInString(line) > maxWidth { - runes := []rune(line) - out[i] = string(runes[:maxWidth]) + "…" - } else { - out[i] = line - } - } - hidden := total - len(out) - if hidden < 0 { - hidden = 0 - } - return out, hidden -} diff --git a/pkg/agent/truncate/clip_test.go b/pkg/agent/truncate/clip_test.go index d91d66d0..8302f79c 100644 --- a/pkg/agent/truncate/clip_test.go +++ b/pkg/agent/truncate/clip_test.go @@ -73,36 +73,3 @@ func TestClipRunes_CJK(t *testing.T) { t.Fatalf("expected … suffix, got %q", got) } } - -func TestClipLines_Basic(t *testing.T) { - text := "line1\nline2\nline3\nline4\nline5" - lines, hidden := ClipLines(text, 3, 100) - if len(lines) != 3 { - t.Fatalf("expected 3 lines, got %d", len(lines)) - } - if hidden != 2 { - t.Fatalf("expected 2 hidden, got %d", hidden) - } - if lines[0] != "line1" { - t.Fatalf("expected 'line1', got %q", lines[0]) - } -} - -func TestClipLines_WidthTruncation(t *testing.T) { - text := "short\n" + strings.Repeat("x", 200) - lines, _ := ClipLines(text, 10, 50) - if len(lines) != 2 { - t.Fatalf("expected 2 lines, got %d", len(lines)) - } - if utf8.RuneCountInString(lines[1]) > 51 { // 50 + "…" - t.Fatalf("line too long: %d runes", utf8.RuneCountInString(lines[1])) - } -} - -func TestClipLines_NoTruncation(t *testing.T) { - text := "a\nb" - lines, hidden := ClipLines(text, 10, 100) - if len(lines) != 2 || hidden != 0 { - t.Fatalf("expected 2 lines 0 hidden, got %d lines %d hidden", len(lines), hidden) - } -} diff --git a/pkg/agent/truncate/truncate.go b/pkg/agent/truncate/truncate.go index 39f985a5..b68e64ab 100644 --- a/pkg/agent/truncate/truncate.go +++ b/pkg/agent/truncate/truncate.go @@ -219,15 +219,6 @@ 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 utf8.RuneCountInString(line) <= maxChars { - return line, false - } - runes := []rune(line) - return string(runes[:maxChars]) + "... [truncated]", true -} - // FormatSize returns a human-readable size string. func FormatSize(bytes int) string { return util.FormatSize(bytes) diff --git a/pkg/agent/truncate/truncate_test.go b/pkg/agent/truncate/truncate_test.go index f3153f1e..875db209 100644 --- a/pkg/agent/truncate/truncate_test.go +++ b/pkg/agent/truncate/truncate_test.go @@ -160,23 +160,6 @@ func TestTail_UTF8Boundary(t *testing.T) { } } -func TestLine_NoTruncation(t *testing.T) { - text, trunc := Line("short", 100) - if trunc || text != "short" { - t.Fatalf("unexpected: %q trunc=%v", text, trunc) - } -} - -func TestLine_Truncation(t *testing.T) { - text, trunc := Line("abcdefghij", 5) - if !trunc { - t.Fatal("expected truncation") - } - if text != "abcde... [truncated]" { - t.Fatalf("unexpected: %q", text) - } -} - func TestFormatSize(t *testing.T) { tests := []struct { bytes int From f70dfccf4712a30e2818bb56e8c926ff46203fc3 Mon Sep 17 00:00:00 2001 From: Nathaniel Leonardjoi Date: Tue, 7 Jul 2026 03:00:54 -0700 Subject: [PATCH 06/25] =?UTF-8?q?refactor(tools):=20=E6=89=AB=E6=8F=8F?= =?UTF-8?q?=E9=87=87=E9=9B=86/spray/gogo/zombie=20=E4=B8=8E=E8=BE=93?= =?UTF-8?q?=E5=87=BA=E6=B8=B2=E6=9F=93=E7=B2=BE=E7=AE=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scan collector/jsonl_writer、spray、gogo、zombie、toolargs 调整 - core/output 颜色/格式精简,移除 tool_data - headless request、tavily 搜索小改 Co-Authored-By: Claude Opus 4.8 (1M context) --- core/output/color.go | 53 ++------------- core/output/format.go | 15 ----- core/output/record.go | 1 + core/output/tool_data.go | 18 ------ pkg/headless/request.go | 4 +- pkg/tools/gogo/gogo.go | 11 ---- pkg/tools/gogo/register.go | 2 +- pkg/tools/scan/collector.go | 6 +- pkg/tools/scan/jsonl_writer.go | 19 +----- pkg/tools/search/tavily.go | 114 +++++++++++++++++++++++---------- pkg/tools/spray/register.go | 2 +- pkg/tools/spray/spray.go | 11 ---- pkg/tools/toolargs/base.go | 18 ------ pkg/tools/toolargs/resolve.go | 8 +-- pkg/tools/zombie/register.go | 2 +- pkg/tools/zombie/zombie.go | 7 -- 16 files changed, 99 insertions(+), 192 deletions(-) delete mode 100644 core/output/tool_data.go diff --git a/core/output/color.go b/core/output/color.go index d460ee34..f2f98c39 100644 --- a/core/output/color.go +++ b/core/output/color.go @@ -6,15 +6,13 @@ import ( ) const ( - ANSIReset = "\033[0m" - ANSIBold = "\033[1m" - ANSIDim = "\033[2m" - ANSIRed = "\033[31m" - ANSIGreen = "\033[32m" - ANSIYellow = "\033[33m" - ANSIBlue = "\033[34m" - ANSIMagenta = "\033[35m" - ANSICyan = "\033[36m" + ANSIReset = "\033[0m" + ANSIBold = "\033[1m" + ANSIDim = "\033[2m" + ANSIRed = "\033[31m" + ANSIGreen = "\033[32m" + ANSIYellow = "\033[33m" + ANSICyan = "\033[36m" ) type Color struct { @@ -60,13 +58,6 @@ func (c Color) Red(s string) string { return logs.Red(s) } -func (c Color) RedBold(s string) string { - if !c.Enabled { - return s - } - return logs.RedBold(s) -} - func (c Color) Yellow(s string) string { if !c.Enabled { return s @@ -88,20 +79,6 @@ func (c Color) Cyan(s string) string { return logs.Cyan(s) } -func (c Color) Blue(s string) string { - if !c.Enabled { - return s - } - return logs.Blue(s) -} - -func (c Color) Magenta(s string) string { - if !c.Enabled { - return s - } - return logs.Purple(s) -} - func (c Color) Bold(s string) string { if !c.Enabled { return s @@ -140,19 +117,3 @@ func (c Color) ForPriority(p string) func(string) string { return c.Dim } } - -func (c Color) ForStatus(status string) func(string) string { - if !c.Enabled { - return func(s string) string { return s } - } - switch status { - case "confirmed": - return logs.Green - case "not_confirmed", "failed": - return logs.Red - case "info": - return logs.Yellow - default: - return logs.Yellow - } -} diff --git a/core/output/format.go b/core/output/format.go index fd574427..53de61cf 100644 --- a/core/output/format.go +++ b/core/output/format.go @@ -75,21 +75,6 @@ func ExtractQuotedMarkdown(raw string) string { return "" } -func ExtractQuotedSummary(raw string) string { - fields := quotedFields(raw) - if len(fields) == 0 { - return "" - } - for _, value := range fields { - value = strings.TrimSpace(value) - if value == "" || looksLikeMarkdown(value) { - continue - } - return value - } - return "" -} - func quotedFields(input string) []string { var values []string for i := 0; i < len(input); i++ { diff --git a/core/output/record.go b/core/output/record.go index f4627a5d..93bcb2ab 100644 --- a/core/output/record.go +++ b/core/output/record.go @@ -61,6 +61,7 @@ func (r Record) Marshal() []byte { return b } + func ParseRecord(line []byte) (Record, error) { var r Record err := json.Unmarshal(line, &r) diff --git a/core/output/tool_data.go b/core/output/tool_data.go deleted file mode 100644 index 82cc1031..00000000 --- a/core/output/tool_data.go +++ /dev/null @@ -1,18 +0,0 @@ -package output - -import "time" - -type ToolDataEvent struct { - Tool string `json:"tool"` - Kind string `json:"kind"` - Target string `json:"target,omitempty"` - Data any `json:"data"` - Timestamp time.Time `json:"timestamp"` -} - -const ( - ToolDataService = "service" - ToolDataWeb = "web" - ToolDataWeakpass = "weakpass" - ToolDataVuln = "vuln" -) 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/tools/gogo/gogo.go b/pkg/tools/gogo/gogo.go index 4ce92aa5..14e60008 100644 --- a/pkg/tools/gogo/gogo.go +++ b/pkg/tools/gogo/gogo.go @@ -7,13 +7,10 @@ import ( "path/filepath" "strings" - "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" gogocore "github.com/chainreactors/gogo/v2/core" - "github.com/chainreactors/utils/parsers" "github.com/chainreactors/sdk/gogo" ) @@ -38,11 +35,6 @@ 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 "gogo" } func (c *Command) Usage() string { @@ -88,9 +80,6 @@ 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) - }, } if err := gogocore.RunWithArgs(ctx, args, opts); err != nil { fmt.Fprint(commands.Output, buf.String()) diff --git a/pkg/tools/gogo/register.go b/pkg/tools/gogo/register.go index 88b74b15..921c29eb 100644 --- a/pkg/tools/gogo/register.go +++ b/pkg/tools/gogo/register.go @@ -14,7 +14,7 @@ func init() { return } reg.Register( - New(es.Gogo).WithLogger(deps.GetLogger()).WithProxy(deps.ScannerProxy).WithDataBus(deps.DataBus), + New(es.Gogo).WithLogger(deps.GetLogger()).WithProxy(deps.ScannerProxy), "scanner", ) }, 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/jsonl_writer.go b/pkg/tools/scan/jsonl_writer.go index 51f0398c..aa1590a3 100644 --- a/pkg/tools/scan/jsonl_writer.go +++ b/pkg/tools/scan/jsonl_writer.go @@ -3,12 +3,12 @@ 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" + "github.com/chainreactors/utils/parsers" ) type scanJSONLWriter struct { @@ -110,21 +110,6 @@ func capabilityRecordType(source string) output.RecordType { } } -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 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/register.go b/pkg/tools/spray/register.go index 732b29e7..1a6ba59f 100644 --- a/pkg/tools/spray/register.go +++ b/pkg/tools/spray/register.go @@ -14,7 +14,7 @@ func init() { return } reg.Register( - New(es.Spray).WithLogger(deps.GetLogger()).WithProxy(deps.ScannerProxy).WithDataBus(deps.DataBus), + New(es.Spray).WithLogger(deps.GetLogger()).WithProxy(deps.ScannerProxy), "scanner", ) }, diff --git a/pkg/tools/spray/spray.go b/pkg/tools/spray/spray.go index 3daf1d54..404f8627 100644 --- a/pkg/tools/spray/spray.go +++ b/pkg/tools/spray/spray.go @@ -6,12 +6,9 @@ import ( "fmt" "strings" - "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" - "github.com/chainreactors/utils/parsers" "github.com/chainreactors/sdk/spray" spraycore "github.com/chainreactors/spray/core" ) @@ -37,11 +34,6 @@ 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 "spray" } func (c *Command) Usage() string { @@ -104,9 +96,6 @@ 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) - }, } if err := spraycore.RunWithArgs(ctx, withDefaultScannerFlags(args), runOpts); err != nil { fmt.Fprint(commands.Output, buf.String()) diff --git a/pkg/tools/toolargs/base.go b/pkg/tools/toolargs/base.go index b75fd9a2..5235f344 100644 --- a/pkg/tools/toolargs/base.go +++ b/pkg/tools/toolargs/base.go @@ -1,10 +1,6 @@ package toolargs import ( - "time" - - "github.com/chainreactors/aiscan/core/eventbus" - "github.com/chainreactors/aiscan/core/output" "github.com/chainreactors/aiscan/pkg/telemetry" ) @@ -12,7 +8,6 @@ type Base struct { Logger telemetry.Logger Proxy string WorkDir string - DataBus *eventbus.Bus[output.ToolDataEvent] } func (b *Base) SetWorkDir(dir string) { b.WorkDir = dir } @@ -25,16 +20,3 @@ func (b *Base) InitLogger(logger telemetry.Logger) { b.Logger = telemetry.NopLogger() } } - -func (b *Base) EmitData(tool, kind, target string, data any) { - if b.DataBus == nil { - return - } - b.DataBus.Emit(output.ToolDataEvent{ - Tool: tool, - Kind: kind, - Target: target, - Data: data, - 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/register.go b/pkg/tools/zombie/register.go index d9cd00e1..86be9cc9 100644 --- a/pkg/tools/zombie/register.go +++ b/pkg/tools/zombie/register.go @@ -14,7 +14,7 @@ func init() { return } reg.Register( - New(es.Zombie).WithLogger(deps.GetLogger()).WithProxy(deps.ScannerProxy).WithDataBus(deps.DataBus), + New(es.Zombie).WithLogger(deps.GetLogger()).WithProxy(deps.ScannerProxy), "scanner", ) }, diff --git a/pkg/tools/zombie/zombie.go b/pkg/tools/zombie/zombie.go index c000a67e..8982fa34 100644 --- a/pkg/tools/zombie/zombie.go +++ b/pkg/tools/zombie/zombie.go @@ -5,8 +5,6 @@ import ( "context" "fmt" - "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" @@ -35,11 +33,6 @@ 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 "zombie" } func (c *Command) Usage() string { From 93ed515139633cf88ae0a499c3006ab262da6839 Mon Sep 17 00:00:00 2001 From: Nathaniel Leonardjoi Date: Tue, 7 Jul 2026 03:01:19 -0700 Subject: [PATCH 07/25] =?UTF-8?q?feat(web):=20=E5=90=8E=E7=AB=AF=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D/SSE/store/=E9=85=8D=E7=BD=AE=E7=83=AD=E9=87=8D?= =?UTF-8?q?=E8=BD=BD/webagent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SSE hub 终端事件可靠投递、config 热重载(ReloadProvider 热切换 provider) - store 迁移到 sqlite(删除内存 store),会话/scan 级联删除、agent 稳定身份 - webagent 上传路径注入 LLM、eval 转发 extractEventData 修复 - slashcmd web 端点、localagent 本地启动;补 config_reload/command/sse/ eval_forward/upload 回归测试 Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/aiscan/cli.go | 14 +- cmd/aiscan/web_full.go | 59 ++++++- core/runner/remote_repl.go | 4 +- core/runner/runner.go | 29 +++- pkg/web/agents.go | 208 ++++++++++++++++++------ pkg/web/agents_test.go | 120 +++++++++++++- pkg/web/command_test.go | 148 +++++++++++++++++ pkg/web/config_reload_test.go | 67 ++++++++ pkg/web/eval_forward_test.go | 108 +++++++++++++ pkg/web/handler.go | 94 ++++++++++- pkg/web/localagent.go | 283 +++++++++++++++++++++++++++++++++ pkg/web/service.go | 290 ++++++++++++++++++++++++++++------ pkg/web/sse.go | 35 ++-- pkg/web/sse_test.go | 196 +++++++++++++++++++++++ pkg/web/store.go | 35 ---- pkg/web/store_sqlite.go | 37 +++-- pkg/web/types.go | 56 +++++-- pkg/webagent/agent.go | 221 ++++++++++++++++++++++++-- pkg/webagent/upload_test.go | 98 ++++++++++++ pkg/webproto/message.go | 28 +++- 20 files changed, 1911 insertions(+), 219 deletions(-) create mode 100644 pkg/web/command_test.go create mode 100644 pkg/web/config_reload_test.go create mode 100644 pkg/web/eval_forward_test.go create mode 100644 pkg/web/localagent.go create mode 100644 pkg/web/sse_test.go delete mode 100644 pkg/web/store.go create mode 100644 pkg/webagent/upload_test.go diff --git a/cmd/aiscan/cli.go b/cmd/aiscan/cli.go index e51bc836..813e1aa4 100644 --- a/cmd/aiscan/cli.go +++ b/cmd/aiscan/cli.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "os/signal" + "slices" "strconv" "strings" "sync" @@ -30,6 +31,8 @@ type webCommand struct { MaxScans int `long:"max-scans" default:"3" description:"Maximum concurrent scans"` ScanTimeout int `long:"scan-timeout" default:"600" description:"Maximum scan runtime in seconds"` IOAToken string `long:"ioa-token" description:"IOA access key (auto-generated if empty)"` + AdminToken string `long:"admin-token" env:"AISCAN_ADMIN_TOKEN" description:"Admin token gating local-agent launch endpoints (empty = open)"` + AgentBinary string `long:"agent-binary" env:"AISCAN_AGENT_BINARY" description:"Path to the aiscan binary used to launch local agents (default: this executable)"` } type cliOptions struct { @@ -543,7 +546,7 @@ func applyScannerCommandArgs(scannerName string, args []string, option *cfg.Opti key, value, hasValue := strings.Cut(arg, "=") matched := false for _, f := range scannerKnownFlags { - if !containsString(f.names, key) { + if !slices.Contains(f.names, key) { continue } if scannerName == "scan" && key == "--ai" { @@ -583,15 +586,6 @@ func flagValue(arg string, hasValue bool, value string, args []string, i *int) ( return args[*i], nil } -func containsString(haystack []string, needle string) bool { - for _, s := range haystack { - if s == needle { - return true - } - } - return false -} - func truthyFlagValue(value string) bool { switch strings.ToLower(strings.TrimSpace(value)) { case "", "1", "t", "true", "y", "yes", "on": diff --git a/cmd/aiscan/web_full.go b/cmd/aiscan/web_full.go index 4c05956b..943ebac7 100644 --- a/cmd/aiscan/web_full.go +++ b/cmd/aiscan/web_full.go @@ -6,6 +6,7 @@ import ( "context" "fmt" "io/fs" + "net" "net/http" "os" "path" @@ -80,7 +81,26 @@ func runWeb(ctx context.Context, option *cfg.Option, opts webCommand, logger tel ioaSvc := ioaserver.NewService(ioaserver.NewMemoryStore(), accessKey) ioaHandler := ioaserver.AuthMiddleware(ioaSvc)(ioaserver.NewHandler(ioaSvc)) - handler := web.NewHandler(service, pool, ioaHandler, newSPAFileServer(staticSub)) + // Local agents: the hub can spawn `aiscan agent` children on its own host + // (one-click launch/stop from the UI). Each child dials the hub's loopback + // web + IOA endpoints — the IOA access key is embedded into the IOA URL — and + // registers in the pool like any node. The hub holds the only handle to them, + // so they are all killed on shutdown. + localAgents := web.NewLocalAgents(hubLocalURL(opts.Addr), accessKey, opts.AgentBinary, pool) + go func() { + <-ctx.Done() + localAgents.StopAll() + }() + + // The local-agent launcher spawns processes on the hub host. When reachable + // off-loopback without an admin token it is wide open; warn loudly rather than + // silently. (Kept back-compat/open so an existing loopback hub works out of + // the box — set --admin-token to gate it.) + if opts.AdminToken == "" && !isLoopbackListen(opts.Addr) { + logger.Warnf("SECURITY: local-agent launch API is UNAUTHENTICATED on non-loopback %s — set --admin-token or AISCAN_ADMIN_TOKEN to gate /api/deploy/local", opts.Addr) + } + + handler := web.NewHandler(service, pool, localAgents, opts.AdminToken, ioaHandler, newSPAFileServer(staticSub)) srv := &http.Server{ Addr: opts.Addr, @@ -244,3 +264,40 @@ func findWebConfigFile(explicit string) string { return "" } +// --------------------------------------------------------------------------- +// Listen-address helpers +// --------------------------------------------------------------------------- + +// isLoopbackListen reports whether addr binds only to loopback, so an open +// (token-less) control plane is not actually reachable from the network. A +// wildcard/empty host, a non-loopback IP, or an unparseable address is treated +// as exposed (returns false) so the security warning errs toward firing. +func isLoopbackListen(addr string) bool { + host, _, err := net.SplitHostPort(strings.TrimSpace(addr)) + if err != nil { + return false + } + switch host { + case "", "0.0.0.0", "::", "[::]": + return false + } + if ip := net.ParseIP(strings.Trim(host, "[]")); ip != nil { + return ip.IsLoopback() + } + return host == "localhost" +} + +// hubLocalURL derives the loopback URL a local agent child should dial from the +// server listen address. A wildcard/empty host becomes 127.0.0.1; an +// unparseable address yields "". +func hubLocalURL(addr string) string { + host, port, err := net.SplitHostPort(strings.TrimSpace(addr)) + if err != nil || port == "" { + return "" + } + switch host { + case "", "0.0.0.0", "::", "[::]": + host = "127.0.0.1" + } + return "http://" + net.JoinHostPort(host, port) +} diff --git a/core/runner/remote_repl.go b/core/runner/remote_repl.go index f7c3ae1b..c60f74d7 100644 --- a/core/runner/remote_repl.go +++ b/core/runner/remote_repl.go @@ -9,8 +9,8 @@ import ( "github.com/chainreactors/aiscan/pkg/agent" "github.com/chainreactors/aiscan/pkg/agent/tmux" "github.com/chainreactors/aiscan/pkg/tui" - "github.com/chainreactors/utils/pty" rlterm "github.com/chainreactors/tui/readline/terminal" + "github.com/chainreactors/utils/pty" ) func NewRemoteREPLOpener(rt *AgentRuntime, mgr *tmux.Manager) pty.OpenFunc { @@ -27,7 +27,7 @@ func NewRemoteREPLOpener(rt *AgentRuntime, mgr *tmux.Manager) pty.OpenFunc { } session := agent.NewAgent(rt.Config. WithSystemPrompt(rt.SystemPrompt). - WithStream(tui.AgentStreamingEnabled(option))) + WithStream(true)) appInfo := tui.AppInfo{ Provider: rt.App.Provider, ProviderConfig: rt.App.ProviderConfig, diff --git a/core/runner/runner.go b/core/runner/runner.go index f615e0fd..49e430fe 100644 --- a/core/runner/runner.go +++ b/core/runner/runner.go @@ -280,6 +280,31 @@ func (rt *AgentRuntime) Close() { } } +// ReloadProvider rebuilds the LLM provider from option and hot-swaps it into the +// running runtime: rt.App (used by the REPL and scan paths) and rt.Config (the +// template every new chat agent is cloned from). It returns the live provider +// and resolved model so callers can propagate the swap to already-running +// agents. On a build failure the runtime is left untouched and the error is +// returned, so a bad config push never knocks out a working provider. +func (rt *AgentRuntime) ReloadProvider(option *cfg.Option) (agent.Provider, string, error) { + if rt == nil || rt.App == nil { + return nil, "", fmt.Errorf("agent runtime is not configured") + } + logger := rt.Config.Logger + if logger == nil { + logger = telemetry.NopLogger() + } + provider, resolved, err := initProvider(cfg.ProviderConfig(option), logger) + if err != nil { + return nil, "", err + } + rt.App.Provider = provider + rt.App.ProviderConfig = *resolved + rt.Config.Provider = provider + rt.Config.Model = resolved.Model + return provider, resolved.Model, nil +} + // --------------------------------------------------------------------------- // Mode dispatch // --------------------------------------------------------------------------- @@ -321,7 +346,7 @@ func runOneShotMode(ctx context.Context, option *cfg.Option, logger telemetry.Lo a := agent.NewAgent(rt.Config. WithSystemPrompt(rt.SystemPrompt). - WithStream(tui.AgentStreamingEnabled(option))) + WithStream(true)) if len(rt.ResumeMessages) > 0 { a.LoadMessages(rt.ResumeMessages) } @@ -359,7 +384,7 @@ func runInteractiveMode(ctx context.Context, option *cfg.Option, logger telemetr session := agent.NewAgent(rt.Config. WithSystemPrompt(rt.SystemPrompt). - WithStream(tui.AgentStreamingEnabled(option))) + WithStream(true)) if len(rt.ResumeMessages) > 0 { session.LoadMessages(rt.ResumeMessages) } diff --git a/pkg/web/agents.go b/pkg/web/agents.go index d1ed85dd..3a8c6085 100644 --- a/pkg/web/agents.go +++ b/pkg/web/agents.go @@ -1,6 +1,7 @@ package web import ( + "cmp" "context" "encoding/json" "fmt" @@ -11,6 +12,7 @@ import ( "time" "github.com/chainreactors/aiscan/core/output" + "github.com/chainreactors/aiscan/pkg/slashcmd" "github.com/chainreactors/aiscan/pkg/webproto" "github.com/gorilla/websocket" ) @@ -20,13 +22,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"` + SlashCommands []slashcmd.Spec `json:"slash_commands,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 +40,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 + slashCommands []slashcmd.Spec + 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 +60,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, + SlashCommands: a.slashCommands, + Busy: len(a.tasks) > 0, + ConnectAt: a.connectAt, + Identity: a.identity, + Stats: a.stats, } } +// slashSpecs 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) slashSpecs() []slashcmd.Spec { + if a == nil { + return nil + } + return a.slashCommands +} + // SessionLookup resolves a task ID to its owning chat session. type SessionLookup interface { TaskSession(taskID string) (sessionID string, ok bool) @@ -108,25 +123,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 { @@ -201,17 +242,16 @@ func (p *AgentPool) DispatchCommand(agentID, taskID, command string) (<-chan tas // 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) +// 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) dispatch(agentID, taskID, typ, data string) (<-chan taskResult, error) { @@ -266,6 +306,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 { @@ -443,26 +501,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, + slashCommands: info.SlashCommands, + 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,6 +581,23 @@ 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) @@ -721,6 +801,30 @@ 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 data := extractEventData(msg.Payload); 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, + } default: return } diff --git a/pkg/web/agents_test.go b/pkg/web/agents_test.go index a3f6479f..2e102e2c 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,65 @@ 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{Persist: true, 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) + } + if !payload.Persist { + t.Errorf("persist = false, want true") + } + + 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 +289,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 +368,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/command_test.go b/pkg/web/command_test.go new file mode 100644 index 00000000..185dfe81 --- /dev/null +++ b/pkg/web/command_test.go @@ -0,0 +1,148 @@ +package web + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + "time" + + "github.com/chainreactors/aiscan/pkg/slashcmd" + "github.com/chainreactors/aiscan/pkg/webproto" +) + +func TestParseSlashCommand(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 := parseSlashCommand(tc.in) + if ok != tc.wantOK || cmd != tc.wantCmd || arg != tc.wantArg { + t.Fatalf("parseSlashCommand(%q) = (%q, %q, %v), want (%q, %q, %v)", + tc.in, cmd, arg, ok, tc.wantCmd, tc.wantArg, tc.wantOK) + } + }) + } +} + +// TestHubScopeCommandsCovered guards that every hub-scope command in the catalog +// is handled by runHubCommand's switch. Adding a hub command to slashcmd without +// wiring it here would silently route it to the agent instead. +func TestHubScopeCommandsCovered(t *testing.T) { + handled := map[string]bool{"/scan": true, "/agents": true, "/help": true} + for _, s := range slashcmd.HubWebMenu() { + if !handled[s.Name] { + t.Errorf("hub command %q is in the catalog but not handled by runHubCommand", s.Name) + } + } +} + +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) + } + 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"} { + if !names[want] { + t.Errorf("SessionMenu missing %q", want) + } + } + for _, absent := range []string{"/stop", "/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 slashcmd 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 []slashcmd.Spec + 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"} { + 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/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..10c771e7 100644 --- a/pkg/web/handler.go +++ b/pkg/web/handler.go @@ -6,14 +6,15 @@ import ( "net/http" "strings" + "github.com/chainreactors/aiscan/pkg/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, adminToken string, ioaHandler http.Handler, static http.Handler) *Handler { mux := http.NewServeMux() h := &handlerImpl{service: service, agents: agents} @@ -28,6 +29,9 @@ 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) // Chat session routes @@ -39,6 +43,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,22 +61,26 @@ 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} + // Gate the process-spawning local-agent endpoints behind the admin token + // (no-op if empty). + return &Handler{handler: adminAuthMiddleware(adminToken, 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, X-Admin-Token") if r.Method == http.MethodOptions { w.WriteHeader(http.StatusOK) return } - h.mux.ServeHTTP(w, r) + h.handler.ServeHTTP(w, r) } type handlerImpl struct { @@ -127,6 +136,45 @@ 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.LLMTestRequest + 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.LLMModelsRequest + 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 { @@ -253,7 +301,13 @@ 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 := webproto.ChatPayload{ + Persist: req.Persist, + EvalCriteria: strings.TrimSpace(req.EvalCriteria), + EvalMaxRounds: req.EvalMaxRounds, + PersistMaxTurns: req.PersistMaxTurns, + } + 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 +315,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") @@ -340,3 +402,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/localagent.go b/pkg/web/localagent.go new file mode 100644 index 00000000..40e245b5 --- /dev/null +++ b/pkg/web/localagent.go @@ -0,0 +1,283 @@ +package web + +import ( + "context" + "crypto/subtle" + "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) + ioaURL string // hub IOA endpoint carrying the embedded access token + binary string // agent binary to exec (empty => this executable) + 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; binary overrides the executable served to children (empty => this one). +func NewLocalAgents(hubURL, ioaToken, binary string, pool *AgentPool) *LocalAgents { + return &LocalAgents{ + webURL: hubURL, + ioaURL: nodeIOAURL(hubURL, ioaToken), + binary: binary, + pool: pool, + } +} + +// 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 := l.binary + if bin == "" { + var err error + if bin, err = os.Executable(); 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.webURL, + "--ioa-url", l.ioaURL, + "--space", "default", + "--ioa-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) +} + +// --------------------------------------------------------------------------- +// Admin-token gate +// --------------------------------------------------------------------------- + +// adminAuthMiddleware gates the process-spawning local-agent endpoints behind an +// admin token when one is configured. Empty token => no gating (back-compat, so +// a loopback hub works out of the box); set --admin-token to lock down a hub +// that listens off-loopback. +func adminAuthMiddleware(token string, next http.Handler) http.Handler { + if token == "" { + return next + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/api/deploy") && r.Method != http.MethodOptions { + if !checkAdminToken(r, token) { + writeError(w, http.StatusUnauthorized, "admin token required") + return + } + } + next.ServeHTTP(w, r) + }) +} + +func checkAdminToken(r *http.Request, token string) bool { + if got := r.Header.Get("X-Admin-Token"); tokenEqual(got, token) { + return true + } + if auth := r.Header.Get("Authorization"); strings.HasPrefix(auth, "Bearer ") { + return tokenEqual(strings.TrimPrefix(auth, "Bearer "), token) + } + return false +} + +// tokenEqual compares an admin token in constant time so a timing side channel +// cannot leak it byte by byte. +func tokenEqual(got, want string) bool { + return subtle.ConstantTimeCompare([]byte(got), []byte(want)) == 1 +} diff --git a/pkg/web/service.go b/pkg/web/service.go index 2bdd473b..522ef49f 100644 --- a/pkg/web/service.go +++ b/pkg/web/service.go @@ -15,6 +15,7 @@ import ( "github.com/chainreactors/aiscan/core/output" "github.com/chainreactors/aiscan/core/runner" + "github.com/chainreactors/aiscan/pkg/slashcmd" "github.com/chainreactors/aiscan/pkg/webproto" ) @@ -24,7 +25,7 @@ type ConfigStore interface { } type ServiceConfig struct { - Store Store + Store *SQLiteStore App *runner.App ConfigStore ConfigStore AppFactory func(ctx context.Context) (*runner.App, error) @@ -34,7 +35,7 @@ type ServiceConfig struct { } type Service struct { - store Store + store *SQLiteStore appMu sync.RWMutex app *runner.App config ConfigStore @@ -152,6 +153,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) } @@ -321,8 +327,9 @@ func (s *Service) runScanViaAgent(ctx context.Context, job *ScanJob) { 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}), + Type: "complete", + Data: mustJSON(map[string]any{"scan_id": job.ID, "status": "completed", "result": result}), + Reliable: true, }) s.broadcastScanComplete(job.ID, result) } @@ -356,8 +363,9 @@ func (s *Service) runScanLocally(ctx context.Context, job *ScanJob) { 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}), + Type: "complete", + Data: mustJSON(map[string]any{"scan_id": job.ID, "status": "completed", "result": result}), + Reliable: true, }) s.broadcastScanComplete(job.ID, result) } @@ -375,8 +383,9 @@ func (s *Service) failJob(job *ScanJob, errMsg string) { 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 +453,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 @@ -775,7 +784,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 +794,7 @@ func (s *Service) CancelSession(ctx context.Context, sessionID string) error { } } } - s.broadcastSystemMessage(sessionID, "Paused.") + s.broadcastSystemMessage(sessionID, SysPaused, "Paused.", nil) return nil } @@ -845,7 +854,9 @@ func (s *Service) HandleFileUpload(ctx context.Context, sessionID, filename stri 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 +910,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, }) } +// 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 +953,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 +994,16 @@ 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 + default: return } @@ -956,7 +1014,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 +1041,117 @@ 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 its slashcmd catalog 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 := parseSlashCommand(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 spec, known := slashcmd.Lookup(verb); known && spec.Scope == slashcmd.ScopeHub { + s.runHubCommand(sessionID, strings.TrimPrefix(spec.Name, "/"), 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) + } +} + +// parseSlashCommand 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 parseSlashCommand(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) []slashcmd.Spec { + agentSpecs := s.sessionAgent(sessionID).slashSpecs() + if len(agentSpecs) == 0 { + agentSpecs = slashcmd.AgentWebMenu() + } + return append(slashcmd.HubWebMenu(), agentSpecs...) } func (s *Service) handleScanCommand(sessionID, args string) { @@ -1049,10 +1210,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 +1223,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 { @@ -1131,16 +1298,15 @@ func (s *Service) handleShellCommand(sessionID, command string) { if res.Err != "" { content = "Error: " + res.Err } - if strings.TrimSpace(content) != "" { - s.persistAssistantMessage(sessionID, agent.id, agent.name, content, res.Turn) - } + s.completeAssistantRun(sessionID, agent.id, agent.name, content, res.Turn) }() } -func (s *Service) handleChatMessage(sessionID, content string) { +func (s *Service) handleChatMessage(sessionID, content string, opts webproto.ChatPayload) { agent := s.sessionAgent(sessionID) if agent == nil { - s.broadcastSystemMessage(sessionID, "Agent is not connected. Reconnect the agent to continue chatting.") + s.broadcastSystemMessage(sessionID, SysAgentNotConnected, + "Agent is not connected. Reconnect the agent to continue chatting.", nil) return } @@ -1153,7 +1319,7 @@ func (s *Service) handleChatMessage(sessionID, content string) { AgentName: agent.name, }) - resultCh, err := s.agents.DispatchChatSession(agent.id, taskID, sessionID, content) + resultCh, err := s.agents.DispatchChatSession(agent.id, taskID, sessionID, content, opts) if err != nil { s.finishSessionTask(taskID) s.BroadcastChatEvent(sessionID, ChatEvent{ @@ -1167,6 +1333,13 @@ func (s *Service) handleChatMessage(sessionID, content 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", + }) return } if canceled { @@ -1176,19 +1349,27 @@ func (s *Service) handleChatMessage(sessionID, content string) { 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 +1377,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 +1400,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/sse.go b/pkg/web/sse.go index cba002c1..39367120 100644 --- a/pkg/web/sse.go +++ b/pkg/web/sse.go @@ -13,14 +13,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 +31,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 +54,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) { diff --git a/pkg/web/sse_test.go b/pkg/web/sse_test.go new file mode 100644 index 00000000..04aecd4c --- /dev/null +++ b/pkg/web/sse_test.go @@ -0,0 +1,196 @@ +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 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..d0f1e49a 100644 --- a/pkg/web/store_sqlite.go +++ b/pkg/web/store_sqlite.go @@ -103,6 +103,19 @@ func migrate(db *sql.DB) error { } } + // Re-home existing chat sessions onto the agent's stable identity. The hub + // historically keyed connected agents by a per-connection random id and froze + // that id into the session, so any agent reconnect stranded the session as + // "not connected". The pool now keys by the stable node name (== agent_name); + // align legacy rows onto it. Idempotent — new sessions store agent_id == + // agent_name — so this is a no-op once converged. + if _, err := db.Exec( + `UPDATE chat_sessions SET agent_id = agent_name + WHERE agent_name != '' AND agent_id != agent_name`, + ); err != nil { + return err + } + if _, err := db.Exec(` CREATE TABLE IF NOT EXISTS records ( id TEXT PRIMARY KEY, @@ -129,10 +142,6 @@ func migrate(db *sql.DB) error { 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); `) return err } @@ -396,6 +405,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 +471,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 { @@ -496,4 +504,3 @@ func (s *SQLiteStore) InsertRecords(ctx context.Context, recs []*output.Record) } return tx.Commit() } - diff --git a/pkg/web/types.go b/pkg/web/types.go index 3927f2dd..47084b69 100644 --- a/pkg/web/types.go +++ b/pkg/web/types.go @@ -173,19 +173,34 @@ 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" + 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 +220,24 @@ 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"` + 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. + Persist bool `json:"persist,omitempty"` + EvalCriteria string `json:"eval_criteria,omitempty"` + EvalMaxRounds int `json:"eval_max_rounds,omitempty"` + PersistMaxTurns int `json:"persist_max_turns,omitempty"` } type CreateSessionRequest struct { diff --git a/pkg/webagent/agent.go b/pkg/webagent/agent.go index 9a5af749..c1131e62 100644 --- a/pkg/webagent/agent.go +++ b/pkg/webagent/agent.go @@ -9,8 +9,8 @@ import ( "io" "net/url" "os" - "path/filepath" "os/user" + "path/filepath" "runtime" "strings" "sync" @@ -21,8 +21,10 @@ 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/slashcmd" "github.com/chainreactors/aiscan/pkg/telemetry" "github.com/chainreactors/aiscan/pkg/tui" "github.com/chainreactors/aiscan/pkg/webproto" @@ -291,12 +293,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() @@ -318,7 +332,21 @@ func runConnectionOnce(ctx context.Context, serverURL, name string, reg *command }(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 +571,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,6 +654,54 @@ func (m *chatRuntimeManager) agentFor(sessionID string) (*agent.Agent, error) { return ag, nil } +// 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, ag *agent.Agent, rt *runner.AgentRuntime, send func(webproto.Message)) { prompt := strings.TrimSpace(msg.Data) if rt == nil || rt.App == nil { @@ -610,6 +732,25 @@ func runChatWithAgent(ctx context.Context, msg webproto.Message, ag *agent.Agent return } + opts := parseChatPayload(msg) + + // 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.Persist && 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 +763,39 @@ 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, + } + result, _, err := evaluator.RunWithEval(ctx, ag, evalCfg) + if err != nil { + send(webproto.Message{Type: "error", TaskID: msg.TaskID, Data: err.Error()}) + return } - 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 chatSessionID(msg webproto.Message) string { + return parseChatPayload(msg).SessionID +} + +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) @@ -662,6 +827,13 @@ func handleFileUpload(msg webproto.Message, send func(webproto.Message)) { 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, @@ -781,10 +953,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(), + SlashCommands: agentSlashCatalog(rt), + Stats: stats, + Identity: agentIdentity(rt), } if payload.Identity.NodeName == "" { payload.Identity.NodeName = name @@ -792,6 +965,24 @@ func agentRegisterPayload(name string, reg *commands.CommandRegistry, rt *runner return payload } +// agentSlashCatalog 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 agentSlashCatalog(rt *runner.AgentRuntime) []slashcmd.Spec { + specs := slashcmd.AgentWebMenu() + 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, slashcmd.SkillSpec(sk.Name, sk.Description)) + } + return specs +} + func agentIdentity(rt *runner.AgentRuntime) webproto.AgentIdentity { identity := webproto.AgentIdentity{ OS: runtime.GOOS, 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/message.go b/pkg/webproto/message.go index 83f23023..89142af8 100644 --- a/pkg/webproto/message.go +++ b/pkg/webproto/message.go @@ -8,6 +8,7 @@ import ( "unicode/utf8" "github.com/chainreactors/aiscan/pkg/agent/tmux" + "github.com/chainreactors/aiscan/pkg/slashcmd" "github.com/chainreactors/utils/pty" ) @@ -21,10 +22,17 @@ type Message struct { } 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 SlashCommands. + Commands []string `json:"commands,omitempty"` + // SlashCommands is the agent's user-facing "/verb" catalog (pkg/slashcmd): + // 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. + SlashCommands []slashcmd.Spec `json:"slash_commands,omitempty"` + Identity AgentIdentity `json:"identity,omitempty"` + Stats AgentStats `json:"stats,omitempty"` } type AgentIdentity struct { @@ -58,6 +66,18 @@ 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"` + Persist bool `json:"persist,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"` From 3dca5edbfeb07fc0a162615541c16c84c8dfb1e9 Mon Sep 17 00:00:00 2001 From: Nathaniel Leonardjoi Date: Tue, 7 Jul 2026 03:01:47 -0700 Subject: [PATCH 08/25] =?UTF-8?q?feat(web-ui):=20agent=20=E6=8E=A7?= =?UTF-8?q?=E5=88=B6=E5=8F=B0=E5=89=8D=E7=AB=AF=E9=87=8D=E6=9E=84(i18n=20/?= =?UTF-8?q?=20=E4=B8=BB=E9=A2=98=20/=20viewer=20/=20=E5=8E=9F=E5=AD=90?= =?UTF-8?q?=E7=BB=84=E4=BB=B6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 中英 i18n(i18n/locales,MiSans CJK 字体),LanguageToggle/主题系统 - vendored cyber-ui 进 src/{ui,markdown,viewer,terminal-view,lib/theme}; 手搓原子组件收敛(Button/Badge/Callout/Field/StatTile…) - 聊天页去 chatbot 味:身份归左 rail、AI=蓝、composer 融成单一 well; LLMHealth 主动探活、ModelCombobox 模型选择 - 删除独立 scan 页/侧栏/report 视图,scanning 内联进 agent 聊天 Co-Authored-By: Claude Opus 4.8 (1M context) --- web/frontend/index.html | 7 +- web/frontend/package-lock.json | 150 ++++- web/frontend/package.json | 8 + web/frontend/src/App.tsx | 481 ++++++--------- web/frontend/src/api.ts | 314 +++++++++- web/frontend/src/components/AgentPanel.tsx | 238 ++++--- web/frontend/src/components/AppSidebar.tsx | 575 ----------------- .../src/components/AssetResultView.tsx | 180 +++--- web/frontend/src/components/ChatPanel.tsx | 570 ++++++++++++++--- web/frontend/src/components/ConfigPanel.tsx | 353 ++++++++--- web/frontend/src/components/DetailPanel.tsx | 51 +- web/frontend/src/components/ErrorBoundary.tsx | 77 +++ web/frontend/src/components/FindingsPanel.tsx | 135 ++-- .../src/components/FindingsSummary.tsx | 60 +- .../src/components/InstrumentIdle.tsx | 61 ++ web/frontend/src/components/LLMHealth.tsx | 82 +++ .../src/components/LanguageToggle.tsx | 29 + web/frontend/src/components/ModelCombobox.tsx | 246 ++++++++ web/frontend/src/components/ReportView.tsx | 27 - web/frontend/src/components/ScanForm.tsx | 133 ---- web/frontend/src/components/ScanHistory.tsx | 148 ----- web/frontend/src/components/ScanProgress.tsx | 115 ---- web/frontend/src/components/ScanView.tsx | 129 ---- web/frontend/src/components/ScanWorkspace.tsx | 292 --------- web/frontend/src/components/SessionList.tsx | 421 +++++++++++-- web/frontend/src/components/Sidebar.tsx | 158 ----- .../src/components/brand/BrandLogo.tsx | 82 +++ .../src/components/brand/BrandMark.tsx | 48 ++ .../src/components/chat/ChatInput.tsx | 93 --- .../components/chat/ScanProgressInline.tsx | 28 +- .../src/components/chat/ScanSummaryCard.tsx | 22 +- .../src/components/terminal/AgentTerminal.tsx | 60 +- .../components/terminal/TerminalDetails.tsx | 78 +-- web/frontend/src/hooks/useChatSession.ts | 581 +++++++++++++++--- web/frontend/src/hooks/usePolling.ts | 52 ++ web/frontend/src/hooks/useScanSession.ts | 217 ------- web/frontend/src/i18n/index.ts | 74 +++ web/frontend/src/i18n/locales/en/agent.ts | 68 ++ web/frontend/src/i18n/locales/en/app.ts | 29 + web/frontend/src/i18n/locales/en/chat.ts | 68 ++ web/frontend/src/i18n/locales/en/config.ts | 69 +++ web/frontend/src/i18n/locales/en/findings.ts | 54 ++ web/frontend/src/i18n/locales/en/scan.ts | 58 ++ web/frontend/src/i18n/locales/en/sidebar.ts | 30 + web/frontend/src/i18n/locales/zh/agent.ts | 68 ++ web/frontend/src/i18n/locales/zh/app.ts | 29 + web/frontend/src/i18n/locales/zh/chat.ts | 67 ++ web/frontend/src/i18n/locales/zh/config.ts | 69 +++ web/frontend/src/i18n/locales/zh/findings.ts | 54 ++ web/frontend/src/i18n/locales/zh/scan.ts | 58 ++ web/frontend/src/i18n/locales/zh/sidebar.ts | 30 + web/frontend/src/index.css | 484 +++++++++++++-- web/frontend/src/lib/agent-tools.ts | 3 + web/frontend/src/lib/agentActivity.ts | 21 + web/frontend/src/lib/chat-extensions.tsx | 2 +- web/frontend/src/lib/markdown-report.ts | 307 --------- web/frontend/src/lib/scan-result.ts | 26 +- web/frontend/src/lib/session-agent.ts | 21 + web/frontend/src/lib/theme/ThemeProvider.tsx | 57 ++ web/frontend/src/lib/theme/index.ts | 11 + web/frontend/src/lib/theme/tailwind-preset.ts | 105 ++++ web/frontend/src/lib/theme/tokens.ts | 176 ++++++ web/frontend/src/lib/{ => theme}/utils.ts | 0 web/frontend/src/lib/timeline-mapper.ts | 96 +-- web/frontend/src/lib/tones.ts | 64 ++ web/frontend/src/main.tsx | 23 +- web/frontend/src/markdown/CodeBlock.tsx | 105 ++++ web/frontend/src/markdown/MarkdownContent.tsx | 299 +++++++++ web/frontend/src/markdown/index.ts | 2 + web/frontend/src/terminal-view/index.tsx | 465 ++++++++++++++ web/frontend/src/ui/badge.tsx | 49 ++ web/frontend/src/ui/button.tsx | 69 +++ web/frontend/src/ui/callout.tsx | 56 ++ web/frontend/src/ui/card.tsx | 50 ++ web/frontend/src/ui/chip.tsx | 32 + web/frontend/src/ui/collapsible.tsx | 58 ++ web/frontend/src/ui/confirm-dialog.tsx | 84 +++ web/frontend/src/ui/dialog.tsx | 87 +++ web/frontend/src/ui/dropdown-menu.tsx | 157 +++++ web/frontend/src/ui/empty-state.tsx | 59 ++ web/frontend/src/ui/field.tsx | 34 + web/frontend/src/ui/index.ts | 42 ++ web/frontend/src/ui/input.tsx | 21 + web/frontend/src/ui/popover.tsx | 27 + web/frontend/src/ui/scroll-area.tsx | 43 ++ web/frontend/src/ui/select.tsx | 131 ++++ web/frontend/src/ui/separator.tsx | 23 + web/frontend/src/ui/sheet.tsx | 98 +++ web/frontend/src/ui/skeleton.tsx | 44 ++ web/frontend/src/ui/spinner.tsx | 24 + web/frontend/src/ui/stat-tile.tsx | 41 ++ web/frontend/src/ui/status-indicator.tsx | 70 +++ web/frontend/src/ui/switch.tsx | 26 + web/frontend/src/ui/tabs.tsx | 52 ++ web/frontend/src/ui/textarea.tsx | 20 + web/frontend/src/ui/theme-toggle.tsx | 28 + web/frontend/src/ui/toggle-group.tsx | 63 ++ web/frontend/src/ui/tooltip.tsx | 25 + .../components/chat/AssistantResponse.tsx | 127 ++++ .../src/viewer/components/chat/ChatInput.tsx | 462 ++++++++++++++ .../viewer/components/chat/ChatThinking.tsx | 41 ++ .../viewer/components/chat/MessageBubble.tsx | 89 +++ .../components/chat/ToolCallDisplay.tsx | 338 ++++++++++ web/frontend/src/viewer/index.ts | 40 ++ .../src/viewer/lib/timeline-registry.ts | 86 +++ web/frontend/src/viewer/lib/tool-utils.ts | 50 ++ web/frontend/tailwind.config.js | 36 +- web/frontend/tsconfig.json | 14 +- web/frontend/vite.config.ts | 23 +- 109 files changed, 8860 insertions(+), 3352 deletions(-) delete mode 100644 web/frontend/src/components/AppSidebar.tsx create mode 100644 web/frontend/src/components/ErrorBoundary.tsx create mode 100644 web/frontend/src/components/InstrumentIdle.tsx create mode 100644 web/frontend/src/components/LLMHealth.tsx create mode 100644 web/frontend/src/components/LanguageToggle.tsx create mode 100644 web/frontend/src/components/ModelCombobox.tsx delete mode 100644 web/frontend/src/components/ReportView.tsx delete mode 100644 web/frontend/src/components/ScanForm.tsx delete mode 100644 web/frontend/src/components/ScanHistory.tsx delete mode 100644 web/frontend/src/components/ScanProgress.tsx delete mode 100644 web/frontend/src/components/ScanView.tsx delete mode 100644 web/frontend/src/components/ScanWorkspace.tsx delete mode 100644 web/frontend/src/components/Sidebar.tsx create mode 100644 web/frontend/src/components/brand/BrandLogo.tsx create mode 100644 web/frontend/src/components/brand/BrandMark.tsx delete mode 100644 web/frontend/src/components/chat/ChatInput.tsx create mode 100644 web/frontend/src/hooks/usePolling.ts delete mode 100644 web/frontend/src/hooks/useScanSession.ts create mode 100644 web/frontend/src/i18n/index.ts create mode 100644 web/frontend/src/i18n/locales/en/agent.ts create mode 100644 web/frontend/src/i18n/locales/en/app.ts create mode 100644 web/frontend/src/i18n/locales/en/chat.ts create mode 100644 web/frontend/src/i18n/locales/en/config.ts create mode 100644 web/frontend/src/i18n/locales/en/findings.ts create mode 100644 web/frontend/src/i18n/locales/en/scan.ts create mode 100644 web/frontend/src/i18n/locales/en/sidebar.ts create mode 100644 web/frontend/src/i18n/locales/zh/agent.ts create mode 100644 web/frontend/src/i18n/locales/zh/app.ts create mode 100644 web/frontend/src/i18n/locales/zh/chat.ts create mode 100644 web/frontend/src/i18n/locales/zh/config.ts create mode 100644 web/frontend/src/i18n/locales/zh/findings.ts create mode 100644 web/frontend/src/i18n/locales/zh/scan.ts create mode 100644 web/frontend/src/i18n/locales/zh/sidebar.ts create mode 100644 web/frontend/src/lib/agent-tools.ts create mode 100644 web/frontend/src/lib/agentActivity.ts delete mode 100644 web/frontend/src/lib/markdown-report.ts create mode 100644 web/frontend/src/lib/session-agent.ts create mode 100644 web/frontend/src/lib/theme/ThemeProvider.tsx create mode 100644 web/frontend/src/lib/theme/index.ts create mode 100644 web/frontend/src/lib/theme/tailwind-preset.ts create mode 100644 web/frontend/src/lib/theme/tokens.ts rename web/frontend/src/lib/{ => theme}/utils.ts (100%) create mode 100644 web/frontend/src/lib/tones.ts create mode 100644 web/frontend/src/markdown/CodeBlock.tsx create mode 100644 web/frontend/src/markdown/MarkdownContent.tsx create mode 100644 web/frontend/src/markdown/index.ts create mode 100644 web/frontend/src/terminal-view/index.tsx create mode 100644 web/frontend/src/ui/badge.tsx create mode 100644 web/frontend/src/ui/button.tsx create mode 100644 web/frontend/src/ui/callout.tsx create mode 100644 web/frontend/src/ui/card.tsx create mode 100644 web/frontend/src/ui/chip.tsx create mode 100644 web/frontend/src/ui/collapsible.tsx create mode 100644 web/frontend/src/ui/confirm-dialog.tsx create mode 100644 web/frontend/src/ui/dialog.tsx create mode 100644 web/frontend/src/ui/dropdown-menu.tsx create mode 100644 web/frontend/src/ui/empty-state.tsx create mode 100644 web/frontend/src/ui/field.tsx create mode 100644 web/frontend/src/ui/index.ts create mode 100644 web/frontend/src/ui/input.tsx create mode 100644 web/frontend/src/ui/popover.tsx create mode 100644 web/frontend/src/ui/scroll-area.tsx create mode 100644 web/frontend/src/ui/select.tsx create mode 100644 web/frontend/src/ui/separator.tsx create mode 100644 web/frontend/src/ui/sheet.tsx create mode 100644 web/frontend/src/ui/skeleton.tsx create mode 100644 web/frontend/src/ui/spinner.tsx create mode 100644 web/frontend/src/ui/stat-tile.tsx create mode 100644 web/frontend/src/ui/status-indicator.tsx create mode 100644 web/frontend/src/ui/switch.tsx create mode 100644 web/frontend/src/ui/tabs.tsx create mode 100644 web/frontend/src/ui/textarea.tsx create mode 100644 web/frontend/src/ui/theme-toggle.tsx create mode 100644 web/frontend/src/ui/toggle-group.tsx create mode 100644 web/frontend/src/ui/tooltip.tsx create mode 100644 web/frontend/src/viewer/components/chat/AssistantResponse.tsx create mode 100644 web/frontend/src/viewer/components/chat/ChatInput.tsx create mode 100644 web/frontend/src/viewer/components/chat/ChatThinking.tsx create mode 100644 web/frontend/src/viewer/components/chat/MessageBubble.tsx create mode 100644 web/frontend/src/viewer/components/chat/ToolCallDisplay.tsx create mode 100644 web/frontend/src/viewer/index.ts create mode 100644 web/frontend/src/viewer/lib/timeline-registry.ts create mode 100644 web/frontend/src/viewer/lib/tool-utils.ts diff --git a/web/frontend/index.html b/web/frontend/index.html index 759054dd..85f35282 100644 --- a/web/frontend/index.html +++ b/web/frontend/index.html @@ -3,18 +3,17 @@ - + AIScan diff --git a/web/frontend/package-lock.json b/web/frontend/package-lock.json index 3023f835..ffa9f841 100644 --- a/web/frontend/package-lock.json +++ b/web/frontend/package-lock.json @@ -8,6 +8,10 @@ "name": "aiscan-web-frontend", "version": "0.2.0", "dependencies": { + "@fontsource-variable/inter": "^5.2.8", + "@fontsource-variable/jetbrains-mono": "^5.2.8", + "@fontsource-variable/noto-sans-sc": "^5.2.10", + "@fontsource-variable/space-grotesk": "^5.2.10", "@radix-ui/react-dialog": "^1.1.0", "@radix-ui/react-dropdown-menu": "^2.1.0", "@radix-ui/react-popover": "^1.1.0", @@ -18,14 +22,19 @@ "@radix-ui/react-switch": "^1.1.0", "@radix-ui/react-tabs": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.0", + "@types/node": "^26.0.1", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "@xyflow/react": "^12.11.1", "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", + "misans": "^4.1.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", "remark-gfm": "^4.0.1", @@ -829,6 +838,42 @@ "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "license": "MIT" }, + "node_modules/@fontsource-variable/inter": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.2.8.tgz", + "integrity": "sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource-variable/jetbrains-mono": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz", + "integrity": "sha512-WBA9elru6Jdp5df2mES55wuOO0WIrn3kpXnI4+W2ek5u3ZgLS9XS4gmIlcQhiZOWEKl95meYdvK7xI+ETLCq/Q==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource-variable/noto-sans-sc": { + "version": "5.2.10", + "resolved": "https://registry.npmjs.org/@fontsource-variable/noto-sans-sc/-/noto-sans-sc-5.2.10.tgz", + "integrity": "sha512-zdk10i5HrDQTXI7ldD61zToX1fsgig8vDTsu7zB48SXOitWfuX0e5viZAwnkHuhwh096PU6X6i1AyAsbBCISpA==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource-variable/space-grotesk": { + "version": "5.2.10", + "resolved": "https://registry.npmjs.org/@fontsource-variable/space-grotesk/-/space-grotesk-5.2.10.tgz", + "integrity": "sha512-yJQO/o35/hAP3CFnpdFTwQku2yzJOae2HIpBmqkOVoxhhXJaQP3g+b6Jrz7u+eI7A5ZdCIf88uMWpBJdFiGr5w==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -2269,6 +2314,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", @@ -3309,6 +3363,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,6 +3382,43 @@ "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", @@ -4427,6 +4527,12 @@ "node": ">=8.6" } }, + "node_modules/misans": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/misans/-/misans-4.1.0.tgz", + "integrity": "sha512-CcIRrIVhnt+OpGXvw1Q8llGBVAy5P2mdov/kJ0gGa81sJ0RY7mZp2fNAt2ySTCeZos+wo7ZnzDZxl1In//7FdA==", + "license": "Apache-2.0" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -4818,6 +4924,33 @@ "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-markdown": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-9.1.0.tgz", @@ -5548,7 +5681,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 +5691,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", @@ -5869,6 +6008,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..ded701e6 100644 --- a/web/frontend/package.json +++ b/web/frontend/package.json @@ -9,6 +9,9 @@ "preview": "vite preview" }, "dependencies": { + "@fontsource-variable/inter": "^5.2.8", + "@fontsource-variable/jetbrains-mono": "^5.2.8", + "@fontsource-variable/space-grotesk": "^5.2.10", "@radix-ui/react-dialog": "^1.1.0", "@radix-ui/react-dropdown-menu": "^2.1.0", "@radix-ui/react-popover": "^1.1.0", @@ -19,14 +22,19 @@ "@radix-ui/react-switch": "^1.1.0", "@radix-ui/react-tabs": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.0", + "@types/node": "^26.0.1", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "@xyflow/react": "^12.11.1", "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", + "misans": "^4.1.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", "remark-gfm": "^4.0.1", diff --git a/web/frontend/src/App.tsx b/web/frontend/src/App.tsx index 17cc5242..c98e76e6 100644 --- a/web/frontend/src/App.tsx +++ b/web/frontend/src/App.tsx @@ -1,25 +1,44 @@ -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, lazy, Suspense, type ReactNode } from 'react' +import { useTranslation } from 'react-i18next' +import { 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 LLMHealth from './components/LLMHealth' +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 { ThemeToggle, useConfirm } from '@/ui' +import { ThemeProvider, useTheme } from '@/lib/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 type { ServerStatus } from './api' +import { useChatSession, agentNodeKey } from './hooks/useChatSession' +import { usePolling } from './hooks/usePolling' +import { isSessionAgentOnline } from './lib/session-agent' +import { TooltipProvider } from '@/ui' +import { cn } from '@/lib/theme' const sidebarStorageKey = 'aiscan-sidebar-open' -type AppView = 'chat' | 'scan' +// Stable empty props so ChatPanel's memoized rows aren't re-created every render. +// The asset-pool @-mention source and composer seeding were scan-deck features; +// with the deck gone the chat composer just runs free-text. +const NO_MENTIONABLES: { target: string; label?: string; source?: string }[] = [] +const NO_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 +48,37 @@ 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 [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) + + // Stable so ChatPanel's memoized TimelineEntry rows aren't re-rendered on every + // streamed token. chat.showScanDetail is itself a stable useCallback. Scan + // detail here is an agent-run scan surfaced inside the conversation. + const handleShowScanDetail = useCallback((scanID: string) => { + chat.showScanDetail(scanID) + setDetailOpen(true) + }, [chat.showScanDetail]) 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 +86,207 @@ 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) - }, []) - - // 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]) - 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 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) 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') } function handleSelectSession(id: string) { - setTerminalAgentID(null) + setTerminalNodeKey(null) chat.selectSession(id) - setView('chat') - const path = sessionRoutePath(id) - window.history.pushState({}, '', path) } function handleCreateSession(agentID: string) { - setTerminalAgentID(null) + setTerminalNodeKey(null) chat.createSession(agentID) - setView('chat') } - function handleOpenScanWorkspace() { - setTerminalAgentID(null) - setView('scan') + // 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 handleOpenChatWorkspace() { - setTerminalAgentID(null) - setView('chat') - const path = chat.activeSessionID ? sessionRoutePath(chat.activeSessionID) : '/' - window.history.pushState({}, '', path) + // Agent node clicked (roster / terminal open) → open the agent console focused + // on that node. + function handleOpenNode(agentID: string) { + setAgentPanelFocusID(agentID) + setAgentPanelOpen(true) } - function handleChangeView(newView: AppView) { - if (newView === 'scan') handleOpenScanWorkspace() - else handleOpenChatWorkspace() + function handleOpenAgentPanel() { + setAgentPanelFocusID(null) + setAgentPanelOpen(true) } - function handleSelectScan(scan: ScanJob) { - setTerminalAgentID(null) - setView('scan') - scanSession.selectScan(scan) - } - - 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)}> - - - -
+
+
+
+ + AIScan + {model} + setConfigOpen(true)} reloadSignal={healthNonce} /> +
+
+ + 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 ? ( +
+
+ }> + + +
+
+ ) : ( + <> + - {/* Content area with transition */} -
- {/* Chat view */} -
- {terminalAgent ? ( -
-
- -
-
- ) : ( - <> - { - chat.showScanDetail(scanID) - setDetailOpen(true) - }} - detailOpen={showDetail} + {/* Agent-run scans surface a result card in the transcript; clicking + it opens this detail drawer. */} +
+ {showDetail && ( + setDetailOpen(false)} /> -
- {showDetail && ( - setDetailOpen(false)} - /> - )} -
- - )} -
- - {/* Scan view */} -
- -
-
+ )} +
+ + )}
- setAgentPanelOpen(false)} - /> - setConfigOpen(false)} - onSaved={refreshStatus} + onSaved={() => { refreshStatus(); setHealthNonce((n) => n + 1) }} /> - - - ) -} - -/* ---------- View Switcher ---------- */ -function ViewSwitcher({ - view, - onChangeView, - runningScans, -}: { - view: AppView - onChangeView: (v: AppView) => void - runningScans: number -}) { - return ( -
-
setAgentPanelOpen(false)} /> - - -
+ + ) } -/* ---------- Shared header components ---------- */ - function ConnectedThemeToggle() { const { isDark, toggle } = useTheme() return } -function ScanAgentsButton({ count, onClick }: { count: number; onClick: () => void }) { +function AgentsButton({ count, onClick }: { count: number; onClick: () => void }) { + const { t } = useTranslation('app') const active = count > 0 return ( ) } -function HeaderIconButton({ children, label, onClick }: { children: ReactNode; label: string; onClick: () => void }) { +function HeaderIconButton({ children, label, onClick, active }: { children: ReactNode; label: string; onClick: () => void; active?: boolean }) { return ( - ) -} - -function StatusPill({ active }: { active: boolean }) { - return ( - - {active ? : } - {active ? 'LLM Ready' : 'LLM Offline'} - + {children} + ) } diff --git a/web/frontend/src/api.ts b/web/frontend/src/api.ts index 27418654..634acad5 100644 --- a/web/frontend/src/api.ts +++ b/web/frontend/src/api.ts @@ -11,6 +11,9 @@ 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; } @@ -76,8 +79,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; @@ -142,6 +163,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,24 +220,232 @@ 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), + }); +} + +// --- Admin token (gates local-agent launch endpoints) --- + +const ADMIN_TOKEN_KEY = 'aiscan_admin_token' + +export function getAdminToken(): string { + try { + return window.localStorage.getItem(ADMIN_TOKEN_KEY) || '' + } catch { + return '' + } +} + +export function setAdminToken(token: string): void { + try { + if (token) window.localStorage.setItem(ADMIN_TOKEN_KEY, token) + else window.localStorage.removeItem(ADMIN_TOKEN_KEY) + } catch { + /* ignore */ + } +} + +function adminHeaders(extra?: Record): Record { + const headers: Record = { ...(extra || {}) } + const token = getAdminToken() + if (token) headers['X-Admin-Token'] = token + return headers +} + +// --- 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', + headers: adminHeaders(), + }) +} + +export async function listLocalAgents(): Promise { + return apiJSON('/api/deploy/local', 'Failed to list local agents', { headers: adminHeaders() }) +} + +export async function stopLocalAgent(name: string): Promise { + await apiJSON(`/api/deploy/local/${encodeURIComponent(name)}`, 'Failed to delete local agent', { + method: 'DELETE', + headers: adminHeaders(), + }) +} + +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( @@ -266,6 +497,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 +533,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 +552,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 +585,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), }) } @@ -385,9 +656,19 @@ export async function listChatMessages(sessionID: string): Promise { + const res = await fetch(`/api/scans/${encodeURIComponent(scanID)}/report?lang=${encodeURIComponent(lang)}`) + 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 es = new EventSource(url) @@ -396,7 +677,7 @@ export function subscribeChatEvents( '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 +694,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() diff --git a/web/frontend/src/components/AgentPanel.tsx b/web/frontend/src/components/AgentPanel.tsx index 9f145f87..20d65857 100644 --- a/web/frontend/src/components/AgentPanel.tsx +++ b/web/frontend/src/components/AgentPanel.tsx @@ -1,48 +1,67 @@ -import { useCallback, useEffect, useState } from 'react' -import { Circle, Monitor, RefreshCw, X } from 'lucide-react' +import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import type { TFunction } from 'i18next' +import { Monitor, RefreshCw, Search } from 'lucide-react' import { listAgents } from '../api' 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 { cn } from '@/lib/theme' +import { + Badge, + Button, + Callout, + EmptyState, + Input, + Sheet, + SheetContent, + SheetDescription, + SheetTitle, + Spinner, + StatusDot, +} from '@/ui' +import { usePolling } from '../hooks/usePolling' interface AgentPanelProps { open: boolean + /** 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, focusAgentID, onClose }: AgentPanelProps) { + const { t } = useTranslation('agent') + const { agents, error, loading, refresh, selected, selectedID, setSelectedID } = useAgentDirectory(open, 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')} +
-
@@ -51,13 +70,10 @@ export default function AgentPanel({ open, onClose }: AgentPanelProps) {
) : error ? ( -
- {error} -
+ {error} ) : agents.length === 0 ? ( -
- -

No agents connected

+
+
) : (
@@ -70,22 +86,44 @@ export default function AgentPanel({ open, onClose }: AgentPanelProps) { /> )}
- {selected && } + {selected && ( + }> + + + )}
)}
-
-
+ + ) } -function useAgentDirectory(open: boolean) { +function useAgentDirectory(open: boolean, focusAgentID?: string) { + const { t } = useTranslation('agent') const [agents, setAgents] = useState([]) const [loading, setLoading] = useState(false) const [error, setError] = useState('') const [selectedID, setSelectedID] = useState('') + // Focus a specific node when the panel is opened from a deck node click. + // Apply it exactly ONCE per open/focus request: `agents` is in the deps only + // so we can wait for the roster to load, but the 5s poll replaces `agents` + // 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 (focusAppliedRef.current) return + if (open && focusAgentID && agents.some((a) => a.id === focusAgentID)) { + setSelectedID(focusAgentID) + focusAppliedRef.current = true + } + }, [open, focusAgentID, agents]) + const refresh = useCallback((silent = false) => { if (!silent) { setLoading(true) @@ -94,26 +132,29 @@ function useAgentDirectory(open: boolean) { return listAgents() .then((items) => { setAgents(items) + // Clear on ANY success, including the silent 5s poll. Without this a + // single failed open-time fetch latches the error banner forever: later + // polls succeed and refresh `agents`, but `error` stayed set and the + // `error ?` branch keeps rendering the stale banner (hiding the list, so + // the user can't even hit refresh). Now the roster self-heals. + setError('') setSelectedID((current) => items.some((agent) => agent.id === current) ? current : items[0]?.id || '') }) .catch((err: Error) => { - if (!silent) setError(err.message || 'Failed to load agents') + if (!silent) setError(err.message || t('failedToLoadAgents')) }) .finally(() => { if (!silent) setLoading(false) }) - }, []) + }, [t]) useEffect(() => { if (!open) return refresh() }, [open, refresh]) - useEffect(() => { - if (!open) return - const interval = setInterval(() => refresh(true), 5000) - return () => clearInterval(interval) - }, [open, refresh]) + // Silent roster poll while the console is open — paused when the tab is hidden. + usePolling(() => refresh(true), 5000, open) const selected = agents.find((agent) => agent.id === selectedID) || agents[0] || null @@ -131,47 +172,84 @@ function AgentList({ 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 ( ) @@ -208,15 +286,15 @@ function formatDateTime(iso: string) { } } -function formatRelativeTime(iso: string): string { +function formatRelativeTime(iso: string, t: TFunction<'agent'>): string { try { const diff = Date.now() - new Date(iso).getTime() const mins = Math.floor(diff / 60000) - if (mins < 1) return 'just now' - if (mins < 60) return `${mins}m ago` + if (mins < 1) return t('justNow') + if (mins < 60) return t('minutesAgo', { count: mins }) const hours = Math.floor(mins / 60) - if (hours < 24) return `${hours}h ago` - return `${Math.floor(hours / 24)}d ago` + if (hours < 24) return t('hoursAgo', { count: hours }) + return t('daysAgo', { count: Math.floor(hours / 24) }) } catch { return '' } diff --git a/web/frontend/src/components/AppSidebar.tsx b/web/frontend/src/components/AppSidebar.tsx deleted file mode 100644 index 8b537be5..00000000 --- a/web/frontend/src/components/AppSidebar.tsx +++ /dev/null @@ -1,575 +0,0 @@ -import { useMemo, useState } from 'react' -import { - Shield, PanelLeftClose, PanelLeft, - MessageSquare, Plus, Trash2, Circle, - ChevronDown, ChevronRight, Monitor, Terminal, - Search, X, Layers, ShieldAlert, Activity, -} from 'lucide-react' -import { Button, Tooltip, TooltipTrigger, TooltipContent, Input } from '@aspect/ui' -import { cn } from '@aspect/theme' -import type { AgentInfo, ChatSession, ScanJob } from '../api' - -type SidebarTab = 'agents' | 'scans' - -interface AppSidebarProps { - open: boolean - onToggle: () => void - view: 'chat' | 'scan' - onChangeView: (view: 'chat' | 'scan') => void - agents: AgentInfo[] - sessions: ChatSession[] - activeSessionID: string | null - selectedAgentID: string | null - terminalAgentID: string | null - onSelectAgent: (id: string) => void - onSelectSession: (id: string) => void - onCreateSession: (agentID: string) => void - onDeleteSession: (id: string) => void - onOpenTerminal: (agentID: string) => void - scans: ScanJob[] - activeScanID?: string - onSelectScan: (scan: ScanJob) => void -} - -export default function AppSidebar({ - open, onToggle, view, onChangeView, - agents, sessions, activeSessionID, selectedAgentID, terminalAgentID, - onSelectAgent, onSelectSession, onCreateSession, onDeleteSession, onOpenTerminal, - scans, activeScanID, onSelectScan, -}: AppSidebarProps) { - const [tab, setTab] = useState(view === 'scan' ? 'scans' : 'agents') - - const runningScans = scans.filter((s) => s.status === 'running' || s.status === 'queued').length - - function handleTabChange(newTab: SidebarTab) { - setTab(newTab) - onChangeView(newTab === 'scans' ? 'scan' : 'chat') - } - - function handleSelectScan(scan: ScanJob) { - setTab('scans') - onSelectScan(scan) - } - - function handleSelectSession(id: string) { - setTab('agents') - onSelectSession(id) - } - - return ( - <> - {open && ( - - - ) : ( - - - - - AIScan - - )} -
- - {open ? ( - <> - {/* Segment control */} -
-
-
- - -
-
- - {/* Content */} -
- {tab === 'agents' ? ( - - ) : ( - - )} -
- - ) : ( - /* Collapsed state */ -
- - - - - Chat - - - - - - - - Scan{runningScans > 0 ? ` (${runningScans} running)` : ''} - - - -
- - {agents.map((agent) => ( - - - - - {agent.name} - - ))} - - - - - - Expand sidebar - -
- )} - - - ) -} - -/* ---------- Agents tab content ---------- */ - -function AgentsContent({ - agents, sessions, activeSessionID, selectedAgentID, terminalAgentID, - onSelectAgent, onSelectSession, onCreateSession, onDeleteSession, onOpenTerminal, -}: { - agents: AgentInfo[] - sessions: ChatSession[] - activeSessionID: string | null - selectedAgentID: string | null - terminalAgentID: string | null - onSelectAgent: (id: string) => void - onSelectSession: (id: string) => void - onCreateSession: (agentID: string) => void - onDeleteSession: (id: string) => void - onOpenTerminal: (agentID: string) => void -}) { - const sessionsByAgent = useMemo(() => { - const map = new Map() - for (const s of sessions) { - const list = map.get(s.agent_id) || [] - list.push(s) - map.set(s.agent_id, list) - } - return map - }, [sessions]) - - if (agents.length === 0) { - return ( -
- -

No agents connected

-

Start an aiscan agent to begin

-
- ) - } - - return ( -
- {agents.map((agent) => ( - onSelectAgent(agent.id)} - onSelectSession={onSelectSession} - onCreateSession={() => onCreateSession(agent.id)} - onDeleteSession={onDeleteSession} - onOpenTerminal={() => onOpenTerminal(agent.id)} - /> - ))} -
- ) -} - -function AgentGroup({ - agent, sessions, isSelected, activeSessionID, terminalActive, - onSelectAgent, onSelectSession, onCreateSession, onDeleteSession, onOpenTerminal, -}: { - agent: AgentInfo - sessions: ChatSession[] - isSelected: boolean - activeSessionID: string | null - terminalActive: boolean - onSelectAgent: () => void - onSelectSession: (id: string) => void - onCreateSession: () => void - onDeleteSession: (id: string) => void - onOpenTerminal: () => void -}) { - const [expanded, setExpanded] = useState(isSelected || sessions.some((s) => s.id === activeSessionID)) - const identity = agent.identity || {} - const llm = [identity.provider, identity.model].filter(Boolean).join('/') - - function handleToggle() { - setExpanded(!expanded) - onSelectAgent() - } - - return ( -
-
- -
- - - {sessions.length > 0 && ( - {sessions.length} - )} -
-
- {expanded && sessions.length > 0 && ( -
- {sessions.map((session) => ( - onSelectSession(session.id)} - onDelete={() => onDeleteSession(session.id)} - /> - ))} -
- )} -
- ) -} - -function SessionItem({ session, active, onSelect, onDelete }: { - session: ChatSession; active: boolean; onSelect: () => void; onDelete: () => void -}) { - const title = session.title || 'New session' - const time = new Date(session.updated_at).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) - - return ( -
- - -
- ) -} - -/* ---------- Scans tab content ---------- */ - -function ScansContent({ scans, activeScanID, onSelectScan }: { - scans: ScanJob[]; activeScanID?: string; onSelectScan: (scan: ScanJob) => void -}) { - const [query, setQuery] = useState('') - const filtered = useMemo(() => { - const q = query.trim().toLowerCase() - return q ? scans.filter((s) => s.target.toLowerCase().includes(q)) : scans - }, [query, scans]) - - const running = scans.filter((s) => s.status === 'running' || s.status === 'queued').length - const completed = scans.filter((s) => s.status === 'completed').length - - return ( -
-
-
- - setQuery(e.target.value)} - placeholder="Search targets" - aria-label="Search scan targets" - className="h-8 pl-8 pr-8 text-xs" - /> - {query && ( - - )} -
-
-
- History - - {running > 0 && {running} running} - {running > 0 && completed > 0 && ' · '} - {completed > 0 && `${completed} done`} - {running === 0 && completed === 0 && `${scans.length} total`} - -
-
- {filtered.length === 0 ? ( -
- {query.trim() ? 'No matching targets.' : 'No scans yet.'} -
- ) : ( - filtered.map((scan) => ( - onSelectScan(scan)} - /> - )) - )} -
-
- ) -} - -function ScanNavButton({ active, onClick, scan }: { active: boolean; onClick: () => void; scan: ScanJob }) { - const assets = scan.result?.assets?.length || 0 - const loots = scanLootCount(scan.result) - const badges = scanBadges(scan) - - return ( - - ) -} - -/* ---------- Helpers ---------- */ - -function scanLootCount(result?: ScanJob['result']) { - if (!result) return 0 - if (result.loots && result.loots.length > 0) { - return result.loots.filter((l) => l.kind.toLowerCase() !== 'fingerprint').length - } - return (result.assets || []).reduce((sum, a) => ( - sum + (a.items || []).filter((i) => i.kind === 'loot' && (typeof i.data?.kind === 'string' ? i.data.kind : '').toLowerCase() !== 'fingerprint').length - ), 0) -} - -function scanBadges(scan: ScanJob) { - const b: string[] = [] - if (scan.verify || (scan.ai && !scan.sniper)) b.push('Verify') - if (scan.sniper || (scan.ai && !scan.verify)) b.push('Sniper') - if (scan.deep) b.push('Deep') - return b -} - -function scanStatusLabel(status: string) { - const labels: Record = { queued: 'queued', running: 'running', completed: 'done', failed: 'failed', canceled: 'canceled', ready: 'ready' } - return labels[status] || status || 'ready' -} - -function scanStateColor(status: string) { - const map: Record = { running: 'text-primary', queued: 'text-primary', completed: 'text-muted-foreground', failed: 'text-destructive', canceled: 'text-warning' } - return map[status] || 'text-muted-foreground' -} - -function scanStateTextColor(status: string) { - const map: Record = { running: 'text-primary', queued: 'text-primary', failed: 'text-destructive', canceled: 'text-yellow-700 dark:text-yellow-300' } - return map[status] || 'text-muted-foreground' -} - -function shortTime(value: string) { - try { - return new Date(value).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) - } catch { - return value - } -} diff --git a/web/frontend/src/components/AssetResultView.tsx b/web/frontend/src/components/AssetResultView.tsx index a4f378e8..b8273055 100644 --- a/web/frontend/src/components/AssetResultView.tsx +++ b/web/frontend/src/components/AssetResultView.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useState, type MouseEvent, type ReactNode } from 'react' +import { useTranslation } from 'react-i18next' import { AlertCircle, Brain, CheckCircle2, ChevronRight, Crosshair, File, Fingerprint, Folder, FolderOpen, Globe, Link2, Network, Radar, Server } from 'lucide-react' import type { AssetItem, ScanResult } from '../api' import { @@ -27,8 +28,10 @@ import { type SitemapNode, type ViewAsset, } from '../lib/scan-result' -import { cn } from '@aspect/theme' -import { MarkdownContent } from '@aspect/markdown' +import { cn } from '@/lib/theme' +import { MarkdownContent } from '@/markdown' +import { badgeToneClass } from '../lib/tones' +import { Badge as UIBadge, Chip, StatTile } from '@/ui' import FindingsSummary from './FindingsSummary' interface AssetResultViewProps { @@ -37,38 +40,39 @@ interface AssetResultViewProps { type AssetPanel = { id: string - label: string + labelKey: string count?: number preferred?: boolean render: () => ReactNode } export default function AssetResultView({ result }: AssetResultViewProps) { + const { t } = useTranslation('findings') const model = useMemo(() => buildResultModel(result), [result]) return (
- - - - - - - - - + + + + + + + + +
-
+
{model.hosts.length > 0 ? ( ) : ( -
No hosts.
+
{t('noHosts')}
)}
@@ -86,6 +90,7 @@ function HostList({ hosts }: { hosts: HostGroup[] }) { } function HostPanel({ host }: { host: HostGroup }) { + const { t } = useTranslation('findings') const [open, setOpen] = useState(true) const webCount = host.services.filter((service) => service.web).length const anchor = assetAnchor('host', host.id) @@ -103,9 +108,9 @@ function HostPanel({ host }: { host: HostGroup }) {
{host.host} - + {formatCount(host.services.length, 'service')} - {webCount > 0 && {webCountLabel(webCount)}} + {webCount > 0 && {t('webCount', { count: webCount })}}
@@ -128,6 +133,7 @@ function ServiceList({ services }: { services: ServiceNode[] }) { } function ServiceRow({ service }: { service: ServiceNode }) { + const { t } = useTranslation('findings') const panels = useMemo(() => servicePanels(service), [service]) const [open, setOpen] = useState(false) const [activePanelID, setActivePanelID] = useState(() => defaultPanelID(panels)) @@ -169,7 +175,7 @@ function ServiceRow({ service }: { service: ServiceNode }) { @@ -187,6 +193,7 @@ function ServiceRow({ service }: { service: ServiceNode }) { } function ServiceLine({ service, expandable = false }: { service: ServiceNode; expandable?: boolean }) { + const { t } = useTranslation('findings') const displayTarget = service.web ? service.asset.target : service.target const aiStatus = serviceAIStatus(service) @@ -205,23 +212,17 @@ function ServiceLine({ service, expandable = false }: { service: ServiceNode; ex
{service.service || service.protocol || 'service'} - + {service.protocol && service.protocol !== service.service && {service.protocol}} - {service.web && {service.pathCount > 0 ? webCountLabel(service.pathCount) : 'web'}} + {service.web && {service.pathCount > 0 ? t('webCount', { count: service.pathCount }) : t('web')}} {aiStatus === 'verified' && ( - - AI Verified - + {t('aiVerified')} )} {aiStatus === 'sniper' && ( - - CVE Intel - + {t('cveIntel')} )} {aiStatus === 'deep' && ( - - Deep Test - + {t('deepTest')} )} {service.title && ( {service.title} @@ -238,7 +239,7 @@ function ServiceLine({ service, expandable = false }: { service: ServiceNode; ex ))} {service.analysisItems.length > 0 && ( - {service.analysisItems.length} analysis + {t('analysisCount', { count: service.analysisItems.length })} )}
@@ -253,7 +254,7 @@ function ServiceIcon({ service }: { service: ServiceNode }) { return } if (service.fingers.length > 0) { - return + return } return } @@ -263,7 +264,7 @@ function servicePanels(service: ServiceNode): AssetPanel[] { if (service.paths.length > 0) { panels.push({ id: 'sitemap', - label: 'Sitemap', + labelKey: 'sitemap', count: service.paths.length, preferred: true, render: () => , @@ -272,7 +273,7 @@ function servicePanels(service: ServiceNode): AssetPanel[] { if (service.analysisItems.length > 0) { panels.push({ id: 'analysis', - label: 'Analysis', + labelKey: 'analysis', count: service.analysisItems.length, render: () => , }) @@ -284,10 +285,6 @@ function defaultPanelID(panels: AssetPanel[]) { return panels.find((panel) => panel.preferred)?.id || panels[0]?.id || '' } -function webCountLabel(count: number) { - return `${count} web` -} - function ItemFactLine({ item, search, className }: { item: AssetItem; search?: string; className?: string }) { const facts = itemFacts(item) if (facts.statuses.length === 0 && facts.states.length === 0 && facts.fingers.length === 0 && facts.sources.length === 0 && !search) { @@ -319,6 +316,7 @@ function AssetItemsBlock({ asset, items }: { asset: ViewAsset; items: AssetItem[ } function AssetItemRow({ item, asset }: { item: AssetItem; asset: ViewAsset }) { + const { t } = useTranslation('findings') const markdown = isAnalysisItem(item) const title = markdown ? firstText(item.summary, item.title) : itemTitle(item) const detail = itemContent(item) @@ -333,13 +331,13 @@ function AssetItemRow({ item, asset }: { item: AssetItem; asset: ViewAsset }) { return (
@@ -348,7 +346,7 @@ function AssetItemRow({ item, asset }: { item: AssetItem; asset: ViewAsset }) { {badge.label} ))} - + {showTarget && {item.target}}
{title &&
{title}
} @@ -357,12 +355,12 @@ function AssetItemRow({ item, asset }: { item: AssetItem; asset: ViewAsset }) {
{isAI && ( -
- {item.source === 'verify' ? 'AI Verification' : item.source === 'sniper' ? 'CVE Intelligence' : 'Dynamic Analysis'} +
+ {item.source === 'verify' ? t('aiVerification') : item.source === 'sniper' ? t('cveIntelligence') : t('dynamicAnalysis')}
)} {markdown ? ( @@ -384,35 +382,24 @@ function AssetItemRow({ item, asset }: { item: AssetItem; asset: ViewAsset }) { } function VerificationBadge({ source, status }: { source?: string; status?: string }) { + const { t } = useTranslation('findings') if (source === 'verify') { if (status === 'confirmed') { - return ( - - Confirmed - - ) + return {t('confirmed')} } if (status === 'not_confirmed') { - return Not Confirmed + return {t('notConfirmed')} } if (status === 'inconclusive') { - return Inconclusive + return {t('inconclusive')} } - return Info + return {t('info')} } if (source === 'sniper') { - return ( - - CVE Intel - - ) + return {t('cveIntel')} } if (source === 'deep') { - return ( - - Deep Test - - ) + return {t('deepTest')} } return null } @@ -469,18 +456,19 @@ function firstText(...values: Array) { function ItemIcon({ kind }: { kind: string }) { if (kind === 'loot') { - return + return } if (kind === 'note' || kind === 'response') { - return + return } if (kind === 'fingerprint') { - return + return } return } function SitemapBlock({ items }: { items: AssetItem[] }) { + const { t } = useTranslation('findings') const tree = useMemo(() => buildSitemapTree(items), [items]) const folderIDs = useMemo(() => collectSitemapFolderIDs(tree), [tree]) const [openIDs, setOpenIDs] = useState>(() => defaultOpenSitemapNodes(tree)) @@ -505,15 +493,15 @@ function SitemapBlock({ items }: { items: AssetItem[] }) {
{folderIDs.length > 0 && (
- setOpenIDs(new Set(folderIDs))}> + setOpenIDs(new Set(folderIDs))}> - setOpenIDs(new Set())}> + setOpenIDs(new Set())}>
)} -
+
{tree.map((node) => (
)} -
+
+ {/* Off-screen polite live region — announces the turn phase to screen + readers without reading the streamed tokens one by one. */} +
{liveStatus}
)} {hasActiveSession && timeline.length === 0 && !isThinking && streamingText === null && (
Type a message or use /scan <target> to start scanning + <>{t('readyHintBefore')}/scan <target>{t('readyHintAfter')} } />
@@ -151,7 +309,7 @@ export default function ChatPanel({ key={item.id} item={item} scanResults={scanResults} - detailOpen={detailOpen} + hasThreadNotes={hasThreadNotes} onShowScanDetail={onShowScanDetail} /> ))} @@ -160,7 +318,7 @@ export default function ChatPanel({ )} @@ -172,7 +330,7 @@ export default function ChatPanel({ timestamp: Date.now(), agentName: streamingAgent || undefined, }} - detailOpen={detailOpen} + hasThreadNotes={hasThreadNotes} > @@ -183,47 +341,129 @@ export default function ChatPanel({
{hasActiveSession && ( - +
+ {agentOffline && ( +
+
+ } + className="rounded-lg animate-in fade-in slide-in-from-bottom-1 duration-200" + > + {agentName ? t('agentOfflineBannerNamed', { name: agentName }) : t('agentOfflineBanner')} + +
+
+ )} +
+
+ +
+ + + {t('persistMode')} + + +
+