diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dec57644..f76d78ff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,8 +5,6 @@ on: branches: - master pull_request: - branches: - - master workflow_dispatch: permissions: diff --git a/Makefile b/Makefile index a33ffa39..3691d9f3 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,17 @@ WEB_ADDR ?= 127.0.0.1:8080 WEB_TOKEN ?= BIN_DIR ?= bin -RE2_TAGS := re2_cgo re2_static +# The bundled static CRE2 archive only exists for linux/amd64 and +# windows/amd64. Other native builds use go-re2's embedded WASM runtime. +RE2_TAGS_DEFAULT := +ifeq ($(OS),Windows_NT) +RE2_TAGS_DEFAULT := re2_cgo re2_static +else ifeq ($(shell uname -s),Linux) +ifeq ($(shell uname -m),x86_64) +RE2_TAGS_DEFAULT := re2_cgo re2_static +endif +endif +RE2_TAGS ?= $(RE2_TAGS_DEFAULT) ifeq ($(OS),Windows_NT) EXE := .exe diff --git a/README.md b/README.md index ec1edfc3..af3e91b6 100644 --- a/README.md +++ b/README.md @@ -187,7 +187,7 @@ aiscan agent --ioa-url http://127.0.0.1:8765 --space pentest-project \ export OPENAI_API_KEY="sk-..." # CLI arguments -aiscan agent --provider deepseek --base-url https://api.deepseek.com --api-key sk-... --model deepseek-chat +aiscan agent --provider deepseek --api-key sk-... --model deepseek-chat ``` Config file `aiscan.yaml`: @@ -201,7 +201,7 @@ llm: max_tokens: 16384 # Maximum output per response ``` -The request output limit is dynamically clamped to the remaining context: `min(max_tokens, context_window - current_context - 4096)`. Automatic compaction starts as the context approaches the configured window. +`context_window` is a literal token count: use `128000`, not `128K`. Values below 8192 are accepted, but the Web UI warns that they may be too small. The request output limit is dynamically clamped to the remaining context: `min(max_tokens, context_window - current_context - 4096)`. If no output space remains, AIScan returns a clear error instead of sending a one-token request. Automatic compaction starts as the context approaches the configured window. --- diff --git a/README_CN.md b/README_CN.md index 04ee07c1..034be639 100644 --- a/README_CN.md +++ b/README_CN.md @@ -186,7 +186,7 @@ aiscan agent --ioa-url http://127.0.0.1:8765 --space pentest-project \ export OPENAI_API_KEY="sk-..." # CLI 参数 -aiscan agent --provider deepseek --base-url https://api.deepseek.com --api-key sk-... --model deepseek-chat +aiscan agent --provider deepseek --api-key sk-... --model deepseek-chat ``` 配置文件 `aiscan.yaml`: @@ -200,7 +200,7 @@ llm: max_tokens: 16384 # 单次最大输出 ``` -实际请求的输出上限会按剩余上下文自动收紧:`min(max_tokens, context_window - 当前上下文 - 4096)`。上下文接近配置窗口时会自动压缩。 +`context_window` 填写真实 Token 数,例如 `128000`,不要写 `128K`。小于 8192 的值可以保存,但 Web 页面会提示窗口可能过小。实际请求的输出上限会按剩余上下文自动收紧:`min(max_tokens, context_window - 当前上下文 - 4096)`;如果已没有输出空间,AIScan 会返回明确错误,而不是发送只允许输出 1 Token 的请求。上下文接近配置窗口时会自动压缩。 --- diff --git a/core/config/options.go b/core/config/options.go index fd280825..515ab2a9 100644 --- a/core/config/options.go +++ b/core/config/options.go @@ -27,7 +27,7 @@ type ScanConfigOptions struct { } type LLMOptions struct { - Provider string `long:"provider" config:"provider" description:"LLM provider: openai (default), anthropic, deepseek, openrouter, ollama, groq, moonshot"` + Provider string `long:"provider" config:"provider" description:"LLM provider: openai (default), anthropic, deepseek, openrouter, ollama, groq, moonshot, zhipu"` BaseURL string `long:"base-url" config:"base_url" description:"LLM API base URL (leave empty to use provider default)"` APIKey string `long:"api-key" config:"api_key" description:"LLM API key (or env: OPENAI_API_KEY, ANTHROPIC_API_KEY, AISCAN_API_KEY)"` Model string `long:"model" config:"model" description:"LLM model name"` diff --git a/docs/mechanisms.md b/docs/mechanisms.md index a86cfbc6..e61538ac 100644 --- a/docs/mechanisms.md +++ b/docs/mechanisms.md @@ -113,12 +113,12 @@ eval/compact 徽章仍可由 hub 从 AOP extension 派生为 Web 平台控制事 ### LLM 探活 - `TestLLM`: 发 `maxTokens=16` 的 "ping" completion 验证连通性 -- `ListLLMModels`: 调用 provider 的 `GET /models` 返回 model picklist +- `ListLLMModels`: 调用 provider 的 `GET /models` 返回 model picklist;404 作为“不支持目录”正常降级为手动输入 ### 安全 - `redactURLError`: 从 `*url.Error` 中剥离 query string(FOFA/Hunter API key 在 query 中) -- 空 APIKey 回退到 stored config 中的值(Settings UI 留空表示保持不变) +- 空 APIKey 按请求携带的 `profile_id` 回退到对应 stored config;缺省 ID 才使用 active profile **文件**: `pkg/probe/conn.go`, `pkg/probe/llm.go`, `pkg/web/probe.go`, `pkg/web/handler.go` @@ -128,7 +128,11 @@ eval/compact 徽章仍可由 hub 从 AOP extension 派生为 Web 平台控制事 ### ListModels -两个 provider 都实现 `ListModels(ctx) ([]string, error)`,通过 `GET {base}/models` 返回 model ID 列表。编译期 `capability_parity_test.go` 守卫能力对齐。 +两个协议 provider 都实现 `ListModels(ctx) ([]string, error)`,通过 `GET {base}/models` 返回 model ID 列表。编译期 `capability_parity_test.go` 守卫能力对齐。 + +### Provider presets + +品牌 preset 在协议归一化前解析,为 OpenAI、Anthropic、DeepSeek、OpenRouter、Groq、Moonshot、Ollama 和 Zhipu GLM 提供默认 Base URL。`glm`、`bigmodel` 映射到 `zhipu`;显式 Base URL 不会被覆盖。Ollama preset 不要求 API Key。 ### hint404 协议提示 @@ -136,7 +140,7 @@ chat endpoint 返回 404 时包裹 actionable 建议(如"设置 `llm.provider= ### InferFromBaseURL -检测 `anthropic.com` 域名自动推断 provider,其他默认 `openai`。 +这里只推断传输协议:检测 `anthropic.com` 域名选择 `anthropic`,其他自定义地址默认使用 `openai` 兼容协议。品牌默认地址由 preset 解析,不依赖域名猜测。 **文件**: `pkg/agent/provider/anthropic.go`, `pkg/agent/provider/openai.go`, `pkg/agent/provider/http.go`, `pkg/agent/provider/provider.go` @@ -186,7 +190,7 @@ agent 端的 skill 命令和 `!bash` 从浏览器也能用。 - `params`: 插值变量(如 `{"filename": "note.txt", "path": "/tmp/..."}`) - `fallback`: 英文文本,供非 i18n 消费者 / 日志 / 测试使用 -持久化时 code+params 存入 `ChatMessage.Metadata` JSON,前端从中渲染本地化文本。 +AOP error 事件把 code 保存在标准 data 中,并把 params 保存在 `ext["aiscan.web"]`。通用 reducer 会保留该扩展块,前端从中渲染本地化文本;因此实时流和重放使用同一参数来源。 已定义的 code: diff --git a/docs/reference.md b/docs/reference.md index 9d429180..0e834342 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -52,11 +52,11 @@ aiscan -c /path/to/aiscan.yaml scan -i 192.168.1.0/24 # 指定配置文件 ```yaml # LLM Provider llm: - provider: "" # openai, deepseek, openrouter, ollama, groq, moonshot, anthropic + provider: "" # openai, deepseek, openrouter, ollama, groq, moonshot, anthropic, zhipu base_url: "" # API base URL(留空使用 provider 默认值) api_key: "" # API key(建议使用环境变量) model: "" # 模型名称 - context_window: 0 # 模型上下文窗口;0 表示按模型推断,未知模型默认 128000 + context_window: 0 # 真实 Token 数;0 表示按模型推断,未知模型默认 128000 max_tokens: 0 # 单次最大输出;0 使用默认值 16384 proxy: "" # 访问 LLM API 的 HTTP proxy @@ -161,7 +161,9 @@ misc: | `--timeout <秒>` | 整体超时(默认 3600) | | `-e, --eval` | 目标评估标准 — 独立 LLM 判断任务是否达成 | -`max_tokens` 并非无条件发送:AIScan 会预估消息和工具 schema 的 token 数,并按 `context_window - 当前上下文 - 4096` 自动收紧。上下文接近窗口时会按 Pi 的默认策略自动压缩;服务端返回上下文溢出时会压缩并自动重试一次。 +`context_window` 使用真实整数,例如 128K 窗口填写 `128000`,不是 `128K`。所有正整数都可保存;Web 设置页会对小于 8192 的值显示非阻塞风险提示。 + +`max_tokens` 并非无条件发送:AIScan 会预估消息和工具 schema 的 token 数,并按 `context_window - 当前上下文 - 4096` 自动收紧。若安全预留后没有输出空间,请求会在发送前返回包含窗口、预估输入和预留量的明确错误。上下文接近窗口时会按 Pi 的默认策略自动压缩;服务端返回上下文溢出时会压缩并自动重试一次。 ### Scanner 参数 @@ -202,19 +204,22 @@ misc: | Provider | 默认 Base URL | 默认模型 | API Key 环境变量 | | --- | --- | --- | --- | -| `openai` | `https://api.openai.com/v1` | `gpt-4o` | `OPENAI_API_KEY` | -| `deepseek` | `https://api.deepseek.com/v1` | `deepseek-chat` | `DEEPSEEK_API_KEY` | -| `anthropic` | `https://api.anthropic.com/v1` | — | `ANTHROPIC_API_KEY` | -| `openrouter` | `https://openrouter.ai/api/v1` | — | `OPENROUTER_API_KEY` | -| `groq` | `https://api.groq.com/openai/v1` | — | `GROQ_API_KEY` | -| `moonshot` | `https://api.moonshot.cn/v1` | — | `MOONSHOT_API_KEY` | +| `openai` | `https://api.openai.com/v1` | — | `AISCAN_API_KEY` / `OPENAI_API_KEY` | +| `deepseek` | `https://api.deepseek.com/v1` | — | `AISCAN_API_KEY` / `OPENAI_API_KEY` | +| `anthropic` | `https://api.anthropic.com/v1` | — | `AISCAN_API_KEY` / `ANTHROPIC_API_KEY` | +| `openrouter` | `https://openrouter.ai/api/v1` | — | `AISCAN_API_KEY` / `OPENAI_API_KEY` | +| `groq` | `https://api.groq.com/openai/v1` | — | `AISCAN_API_KEY` / `OPENAI_API_KEY` | +| `moonshot` | `https://api.moonshot.cn/v1` | — | `AISCAN_API_KEY` / `OPENAI_API_KEY` | | `ollama` | `http://localhost:11434/v1` | — | 不需要 | +| `zhipu` | `https://open.bigmodel.cn/api/paas/v4` | — | `AISCAN_API_KEY` / `OPENAI_API_KEY` | -aiscan 可以从 `--base-url` 自动推断 provider(如 URL 包含 `deepseek.com` 自动识别为 `deepseek`)。 +`glm` 和 `bigmodel` 是 `zhipu` 的别名。已知 Provider 在 `base_url` 留空时使用上表地址;显式填写的地址始终优先。只提供 `base_url` 而不提供 Provider 时,Anthropic 官方域名会选择 Anthropic 协议,其他地址默认按 OpenAI 兼容协议处理。 ### 多 Provider 配置 -配置文件可通过 `llm.providers` 保存多个 LLM profile,并用 `llm.active_profile` 明确选择当前项;未指定时使用列表第一项。每个 entry 支持 `id`、`name`、`provider`、`base_url`、`api_key`、`model`、`proxy`、`timeout`、`max_tokens` 和 `context_window`。Web 设置页可以选择当前 profile,REPL 可通过 `/provider` 查看配置,并用 `/provider set` 显式应用新配置。 +配置文件可通过 `llm.providers` 保存多个 LLM profile,并用 `llm.active_profile` 明确选择当前项;未指定时使用列表第一项。每个 entry 支持 `id`、`name`、`provider`、`base_url`、`api_key`、`model`、`proxy`、`timeout`、`max_tokens` 和 `context_window`。`model` 必填,保存配置或激活 Profile 时都会拒绝空模型。Web 设置页可以选择当前 profile,REPL 可通过 `/provider` 查看配置,并用 `/provider set` 显式应用新配置。 + +Web 设置页拉取模型列表时使用当前编辑 Profile 的已保存密钥。若端点不提供 `GET /models`(返回 404),页面会保留手动模型输入,不把它显示为连接故障。 Agent 只会重试当前 provider。重试耗尽后直接返回错误,不会自动切换到其他 profile,也不会把同一 turn 发给另一模型。 @@ -226,7 +231,7 @@ export OPENAI_API_KEY="sk-..." aiscan agent -p "检查目标" -i http://target.example # 指定 provider -aiscan agent --provider deepseek --base-url https://api.deepseek.com --api-key "sk-..." --model deepseek-chat +aiscan agent --provider deepseek --api-key "sk-..." --model deepseek-chat # Ollama 本地模型 aiscan agent --provider ollama --model llama3 --base-url http://localhost:11434/v1 diff --git a/pkg/agent/compact_test.go b/pkg/agent/compact_test.go index 373063f1..2dc33646 100644 --- a/pkg/agent/compact_test.go +++ b/pkg/agent/compact_test.go @@ -219,7 +219,7 @@ func TestEffectiveCompactionLimitsFitSmallContext(t *testing.T) { } func TestRunAutomaticallyCompactsBeforeThresholdRequest(t *testing.T) { - long := strings.Repeat("x", 240) + long := strings.Repeat("x", 9000) llm := &scriptedProvider{responses: []*ChatCompletionResponse{ chatResponse(NewTextMessage("assistant", "history checkpoint")), chatResponse(NewTextMessage("assistant", "turn-prefix checkpoint")), @@ -230,7 +230,7 @@ func TestRunAutomaticallyCompactsBeforeThresholdRequest(t *testing.T) { Tools: commands.NewRegistry(), Model: "custom", MaxTokens: 64, - ContextWindow: 180, + ContextWindow: 8192, Compaction: CompactionSettings{ ReserveTokens: 40, KeepRecentTokens: 20, diff --git a/pkg/agent/probe/llm.go b/pkg/agent/probe/llm.go index 0fc8c725..c1d0ffa0 100644 --- a/pkg/agent/probe/llm.go +++ b/pkg/agent/probe/llm.go @@ -2,6 +2,7 @@ package probe import ( "context" + "errors" "strings" "time" @@ -15,11 +16,12 @@ import ( // to keep it unchanged). Model is only required for TestLLM; ListLLMModels // ignores it. type LLMProbeRequest struct { - Provider string `json:"provider"` - BaseURL string `json:"base_url"` - APIKey string `json:"api_key"` - Model string `json:"model,omitempty"` - Proxy string `json:"proxy"` + ProfileID string `json:"profile_id,omitempty"` + Provider string `json:"provider"` + BaseURL string `json:"base_url"` + APIKey string `json:"api_key"` + Model string `json:"model,omitempty"` + Proxy string `json:"proxy"` } // LLMTestResult reports whether a probe request reached the provider and @@ -40,9 +42,10 @@ const llmProbeTimeout = 30 * time.Second // LLMModelsResult reports the model IDs discovered at the endpoint. ok=false // carries the reason (unsupported provider, auth failure, unreachable, …). type LLMModelsResult struct { - OK bool `json:"ok"` - Models []string `json:"models,omitempty"` - Error string `json:"error,omitempty"` + OK bool `json:"ok"` + Supported bool `json:"supported"` + Models []string `json:"models,omitempty"` + Error string `json:"error,omitempty"` } // modelLister is the optional capability a provider implements when its @@ -89,11 +92,17 @@ func ListLLMModels(ctx context.Context, req LLMProbeRequest, storedAPIKey string models, err := lister.ListModels(probeCtx) if err != nil { + var apiErr *agent.APIError + if errors.As(err, &apiErr) && apiErr.StatusCode == 404 { + result.OK = true + return result, nil + } result.Error = err.Error() return result, nil } result.OK = true + result.Supported = true result.Models = models return result, nil } diff --git a/pkg/agent/provider/provider.go b/pkg/agent/provider/provider.go index 9b5122de..96ddf9b4 100644 --- a/pkg/agent/provider/provider.go +++ b/pkg/agent/provider/provider.go @@ -42,6 +42,28 @@ type ProviderConfig struct { ContextWindow int `yaml:"context_window,omitempty" config:"context_window"` } +type providerPreset struct { + Protocol string + BaseURL string + APIKeyRequired bool +} + +var providerPresets = map[string]providerPreset{ + "openai": {Protocol: "openai", BaseURL: "https://api.openai.com/v1", APIKeyRequired: true}, + "anthropic": {Protocol: "anthropic", BaseURL: "https://api.anthropic.com/v1", APIKeyRequired: true}, + "deepseek": {Protocol: "openai", BaseURL: "https://api.deepseek.com/v1", APIKeyRequired: true}, + "openrouter": {Protocol: "openai", BaseURL: "https://openrouter.ai/api/v1", APIKeyRequired: true}, + "groq": {Protocol: "openai", BaseURL: "https://api.groq.com/openai/v1", APIKeyRequired: true}, + "moonshot": {Protocol: "openai", BaseURL: "https://api.moonshot.cn/v1", APIKeyRequired: true}, + "ollama": {Protocol: "openai", BaseURL: "http://localhost:11434/v1"}, + "zhipu": {Protocol: "openai", BaseURL: "https://open.bigmodel.cn/api/paas/v4", APIKeyRequired: true}, +} + +var providerAliases = map[string]string{ + "bigmodel": "zhipu", + "glm": "zhipu", +} + func NormalizeProvider(name string) string { if strings.EqualFold(name, "anthropic") { return "anthropic" @@ -58,25 +80,33 @@ func Resolve(cfg *ProviderConfig) (*ProviderConfig, error) { return nil, fmt.Errorf("context_window must be zero or positive") } - if resolved.Provider == "" { + providerName := strings.ToLower(strings.TrimSpace(resolved.Provider)) + if alias, ok := providerAliases[providerName]; ok { + providerName = alias + } + + if providerName == "" { if resolved.BaseURL != "" { - resolved.Provider = InferFromBaseURL(resolved.BaseURL) + providerName = InferFromBaseURL(resolved.BaseURL) } else { - resolved.Provider = "openai" + providerName = "openai" } } - resolved.Provider = NormalizeProvider(resolved.Provider) - if resolved.BaseURL == "" { - switch resolved.Provider { - case "anthropic": - resolved.BaseURL = "https://api.anthropic.com/v1" - default: - resolved.BaseURL = "https://api.openai.com/v1" + preset, knownProvider := providerPresets[providerName] + if knownProvider { + if strings.TrimSpace(resolved.BaseURL) == "" { + resolved.BaseURL = preset.BaseURL + } + resolved.Provider = preset.Protocol + } else { + if strings.TrimSpace(resolved.BaseURL) == "" { + return nil, fmt.Errorf("unknown provider %q: set base_url for a custom OpenAI-compatible endpoint", providerName) } + resolved.Provider = NormalizeProvider(providerName) } - if resolved.APIKey == "" { + if strings.TrimSpace(resolved.APIKey) == "" && (!knownProvider || preset.APIKeyRequired) { return nil, fmt.Errorf("no API key: set --api-key, llm.api_key, or AISCAN_API_KEY") } diff --git a/pkg/agent/provider/provider_test.go b/pkg/agent/provider/provider_test.go index 54314bd4..9c0a271f 100644 --- a/pkg/agent/provider/provider_test.go +++ b/pkg/agent/provider/provider_test.go @@ -7,10 +7,65 @@ import ( "fmt" "net/http" "net/http/httptest" + "strings" "testing" "time" ) +func TestResolveProviderPresets(t *testing.T) { + tests := []struct { + name string + provider string + apiKey string + wantProtocol string + wantBaseURL string + }{ + {name: "openai", provider: "openai", apiKey: "key", wantProtocol: "openai", wantBaseURL: "https://api.openai.com/v1"}, + {name: "anthropic", provider: "anthropic", apiKey: "key", wantProtocol: "anthropic", wantBaseURL: "https://api.anthropic.com/v1"}, + {name: "deepseek", provider: "deepseek", apiKey: "key", wantProtocol: "openai", wantBaseURL: "https://api.deepseek.com/v1"}, + {name: "openrouter", provider: "openrouter", apiKey: "key", wantProtocol: "openai", wantBaseURL: "https://openrouter.ai/api/v1"}, + {name: "groq", provider: "groq", apiKey: "key", wantProtocol: "openai", wantBaseURL: "https://api.groq.com/openai/v1"}, + {name: "moonshot", provider: "moonshot", apiKey: "key", wantProtocol: "openai", wantBaseURL: "https://api.moonshot.cn/v1"}, + {name: "ollama", provider: "ollama", wantProtocol: "openai", wantBaseURL: "http://localhost:11434/v1"}, + {name: "zhipu", provider: "zhipu", apiKey: "key", wantProtocol: "openai", wantBaseURL: "https://open.bigmodel.cn/api/paas/v4"}, + {name: "glm alias", provider: "glm", apiKey: "key", wantProtocol: "openai", wantBaseURL: "https://open.bigmodel.cn/api/paas/v4"}, + {name: "bigmodel alias", provider: "bigmodel", apiKey: "key", wantProtocol: "openai", wantBaseURL: "https://open.bigmodel.cn/api/paas/v4"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resolved, err := Resolve(&ProviderConfig{Provider: tt.provider, APIKey: tt.apiKey}) + if err != nil { + t.Fatalf("Resolve() error = %v", err) + } + if resolved.Provider != tt.wantProtocol || resolved.BaseURL != tt.wantBaseURL { + t.Fatalf("Resolve() = provider %q, base_url %q; want %q, %q", resolved.Provider, resolved.BaseURL, tt.wantProtocol, tt.wantBaseURL) + } + }) + } +} + +func TestResolvePreservesExplicitDeepSeekBaseURL(t *testing.T) { + resolved, err := Resolve(&ProviderConfig{ + Provider: "deepseek", + BaseURL: "https://gateway.example/v1", + APIKey: "key", + }) + if err != nil { + t.Fatal(err) + } + if resolved.BaseURL != "https://gateway.example/v1" || resolved.Provider != "openai" { + t.Fatalf("Resolve() = %+v", resolved) + } +} + +func TestResolveUnknownProviderRequiresBaseURL(t *testing.T) { + _, err := Resolve(&ProviderConfig{Provider: "custom", APIKey: "key"}) + if err == nil || !strings.Contains(err.Error(), "base_url") { + t.Fatalf("Resolve() error = %v, want base_url guidance", err) + } +} + func TestResolveUsesBaseURL(t *testing.T) { cfg, err := Resolve(&ProviderConfig{ Provider: "ollama", diff --git a/pkg/agent/retry.go b/pkg/agent/retry.go index e4411e23..d1e2eaf8 100644 --- a/pkg/agent/retry.go +++ b/pkg/agent/retry.go @@ -21,7 +21,10 @@ type imageDisabler interface { DisableImages() } -var errEmptyResponse = errors.New("empty response from LLM") +var ( + errEmptyResponse = errors.New("empty response from LLM") + errContextWindowExhausted = errors.New("context window exhausted") +) const ( baseRetryDelay = 500 * time.Millisecond @@ -214,7 +217,12 @@ func requestAssistantMessageWithUsage(ctx context.Context, cfg Config, em *aopEm CacheRetention: cfg.CacheRetention, SessionID: cfg.SessionID, } - req.MaxTokens = clampMaxTokens(cfg.MaxTokens, cfg.ContextWindow, estimateRequestTokens(messages, tools)) + estimatedInputTokens := estimateRequestTokens(messages, tools) + maxTokens, err := clampMaxTokens(cfg.MaxTokens, cfg.ContextWindow, estimatedInputTokens) + if err != nil { + return ChatMessage{}, nil, fmt.Errorf("cannot create LLM request at turn %d: %w", turn, err) + } + req.MaxTokens = maxTokens em.status(aop.StatusLLMRequest, aop.NSAOP, aop.LLMRequest{Model: req.Model, Messages: len(req.Messages), MaxTokens: req.MaxTokens, Stream: cfg.Stream}) if cfg.Stream { if streaming, ok := cfg.Provider.(StreamingProvider); ok { @@ -238,21 +246,24 @@ func requestAssistantMessageWithUsage(ctx context.Context, cfg Config, em *aopEm return msg, resp.Usage, nil } -func clampMaxTokens(configured, contextWindow, contextTokens int) int { +func clampMaxTokens(configured, contextWindow, contextTokens int) (int, error) { if configured <= 0 { configured = DefaultMaxTokens } if contextWindow <= 0 { - return configured + return configured, nil } available := contextWindow - contextTokens - ContextSafetyTokens if available < 1 { - available = 1 + return 0, fmt.Errorf( + "%w: context_window=%d, estimated_input_tokens=%d, safety_reserve=%d; increase context_window or reduce the conversation history", + errContextWindowExhausted, contextWindow, contextTokens, ContextSafetyTokens, + ) } if configured > available { - return available + return available, nil } - return configured + return configured, nil } func estimateRequestTokens(messages []ChatMessage, tools []ToolDefinition) int { diff --git a/pkg/agent/retry_test.go b/pkg/agent/retry_test.go index 88c8a85f..d8fa2f27 100644 --- a/pkg/agent/retry_test.go +++ b/pkg/agent/retry_test.go @@ -2,6 +2,7 @@ package agent import ( "context" + "errors" "fmt" "net/http" "strings" @@ -50,22 +51,56 @@ func TestClampMaxTokens(t *testing.T) { name string configured, window, used int want int + wantErr bool }{ {name: "configured limit fits", configured: 16384, window: 128000, used: 10000, want: 16384}, {name: "remaining context clamps", configured: 32768, window: 100000, used: 80000, want: 15904}, - {name: "safety margin exhausted", configured: 4096, window: 4096, used: 1, want: 1}, + {name: "safety margin exhausted", configured: 4096, window: 4096, used: 1, wantErr: true}, {name: "default max tokens", configured: 0, window: 128000, used: 10000, want: DefaultMaxTokens}, {name: "unknown window leaves configured", configured: 12345, window: 0, used: 10000, want: 12345}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := clampMaxTokens(tt.configured, tt.window, tt.used); got != tt.want { + got, err := clampMaxTokens(tt.configured, tt.window, tt.used) + if tt.wantErr { + if !errors.Is(err, errContextWindowExhausted) { + t.Fatalf("clampMaxTokens(%d, %d, %d) error = %v, want context window exhausted", tt.configured, tt.window, tt.used, err) + } + return + } + if err != nil { + t.Fatalf("clampMaxTokens(%d, %d, %d) error = %v", tt.configured, tt.window, tt.used, err) + } + if got != tt.want { t.Fatalf("clampMaxTokens(%d, %d, %d) = %d, want %d", tt.configured, tt.window, tt.used, got, tt.want) } }) } } +func TestAgentRejectsExhaustedContextBeforeProviderCall(t *testing.T) { + callCount := 0 + llm := &callbackProvider{ + fn: func(_ context.Context, _ *ChatCompletionRequest) (*ChatCompletionResponse, error) { + callCount++ + return chatResponse(NewTextMessage("assistant", "unexpected")), nil + }, + } + + _, err := NewAgent(Config{ + Provider: llm, + Model: "test", + ContextWindow: ContextSafetyTokens, + MaxRetries: -1, + }).Run(context.Background(), TextInput("hello")) + if !errors.Is(err, errContextWindowExhausted) { + t.Fatalf("Run() error = %v, want context window exhausted", err) + } + if callCount != 0 { + t.Fatalf("provider call count = %d, want 0", callCount) + } +} + func TestConfigInitUsesPiModelLimitDefaults(t *testing.T) { cfg := (Config{Model: "unknown-custom-model"}).init() if cfg.MaxTokens != DefaultMaxTokens || cfg.ContextWindow != DefaultContextWindow { diff --git a/pkg/web/config_profiles_test.go b/pkg/web/config_profiles_test.go index a859e783..7a492acf 100644 --- a/pkg/web/config_profiles_test.go +++ b/pkg/web/config_profiles_test.go @@ -57,7 +57,7 @@ func TestSaveConfigRejectsNegativeModelLimits(t *testing.T) { func(p *webproto.LLMProviderConfig) { p.ContextWindow = -1 }, } { var cfg webproto.DistributeConfig - profile := webproto.LLMProviderConfig{ID: "bad"} + profile := webproto.LLMProviderConfig{ID: "bad", Model: "test-model"} mutate(&profile) cfg.LLM.Providers = []webproto.LLMProviderConfig{profile} if _, err := service.SaveConfig(context.Background(), cfg); err == nil { @@ -68,3 +68,34 @@ func TestSaveConfigRejectsNegativeModelLimits(t *testing.T) { } } } + +func TestSaveConfigRejectsEmptyProfileModel(t *testing.T) { + store := &fakeConfigStore{} + service := NewService(ServiceConfig{ConfigStore: store}) + var cfg webproto.DistributeConfig + cfg.LLM.Providers = []webproto.LLMProviderConfig{{ID: "empty", Name: "Empty", Model: " "}} + + if _, err := service.SaveConfig(context.Background(), cfg); err == nil { + t.Fatal("SaveConfig() accepted an empty profile model") + } + if len(store.cfg.LLM.Providers) != 0 { + t.Fatal("invalid config was persisted") + } +} + +func TestActivateLLMProfileRejectsEmptyModel(t *testing.T) { + store := &fakeConfigStore{} + store.cfg.LLM.ActiveProfile = "primary" + store.cfg.LLM.Providers = []webproto.LLMProviderConfig{ + {ID: "primary", Model: "gpt-primary"}, + {ID: "empty", Model: ""}, + } + service := NewService(ServiceConfig{ConfigStore: store}) + + if _, err := service.ActivateLLMProfile(context.Background(), "empty"); err == nil { + t.Fatal("ActivateLLMProfile() accepted an empty model") + } + if store.cfg.LLM.ActiveProfile != "primary" { + t.Fatalf("active profile = %q, want primary", store.cfg.LLM.ActiveProfile) + } +} diff --git a/pkg/web/llm_probe_test.go b/pkg/web/llm_probe_test.go index 2a95919d..394d5e4a 100644 --- a/pkg/web/llm_probe_test.go +++ b/pkg/web/llm_probe_test.go @@ -200,6 +200,53 @@ func TestListLLMModelsFallsBackToStoredKey(t *testing.T) { } } +func TestListLLMModelsUsesSelectedProfileStoredKey(t *testing.T) { + var gotAuth string + srv := stubModelsServer(t, []string{"m1"}, &gotAuth) + defer srv.Close() + + store := &fakeConfigStore{} + store.cfg.LLM.ActiveProfile = "primary" + store.cfg.LLM.Providers = []webproto.LLMProviderConfig{ + {ID: "primary", Provider: "openai", APIKey: "sk-primary"}, + {ID: "secondary", Provider: "openai", APIKey: "sk-secondary"}, + } + svc := NewService(ServiceConfig{ConfigStore: store}) + + res, err := svc.ListLLMModels(context.Background(), probe.LLMProbeRequest{ + ProfileID: "secondary", + 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-secondary" { + t.Fatalf("expected selected profile key, got %q", gotAuth) + } +} + +func TestListLLMModelsTreatsNotFoundAsUnsupported(t *testing.T) { + srv := httptest.NewServer(http.NotFoundHandler()) + defer srv.Close() + + svc := NewService(ServiceConfig{ConfigStore: &fakeConfigStore{}}) + res, err := svc.ListLLMModels(context.Background(), probe.LLMProbeRequest{ + Provider: "openai", + BaseURL: srv.URL + "/v1", + APIKey: "sk-test", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !res.OK || res.Supported || res.Error != "" { + t.Fatalf("result = %+v, want graceful unsupported response", res) + } +} + func TestListLLMModelsReportsTransportError(t *testing.T) { svc := NewService(ServiceConfig{ConfigStore: &fakeConfigStore{}}) res, err := svc.ListLLMModels(context.Background(), probe.LLMProbeRequest{ diff --git a/pkg/web/probe.go b/pkg/web/probe.go index a016d84e..e8712689 100644 --- a/pkg/web/probe.go +++ b/pkg/web/probe.go @@ -31,23 +31,32 @@ func toProbeConfig(dc webproto.DistributeConfig) probe.ProbeConfig { // TestLLM probes the supplied LLM settings, falling back to the stored API key // when the request leaves it blank, then delegates to pkg/probe. func (s *Service) TestLLM(ctx context.Context, req probe.LLMProbeRequest) (probe.LLMTestResult, error) { - return probe.TestLLM(ctx, req, s.storedLLMAPIKey(ctx)) + return probe.TestLLM(ctx, req, s.storedLLMAPIKey(ctx, req.ProfileID)) } // ListLLMModels enumerates the models the supplied LLM endpoint advertises, // falling back to the stored API key when the request leaves it blank, then // delegates to pkg/probe. func (s *Service) ListLLMModels(ctx context.Context, req probe.LLMProbeRequest) (probe.LLMModelsResult, error) { - return probe.ListLLMModels(ctx, req, s.storedLLMAPIKey(ctx)) + return probe.ListLLMModels(ctx, req, s.storedLLMAPIKey(ctx, req.ProfileID)) } -// storedLLMAPIKey returns the active profile's API key from the persisted -// config, or "" when unavailable. -func (s *Service) storedLLMAPIKey(ctx context.Context) string { +// storedLLMAPIKey returns the requested profile's persisted API key. A blank +// profileID keeps backward compatibility by selecting the active profile. +func (s *Service) storedLLMAPIKey(ctx context.Context, profileID string) string { if s.config == nil { return "" } if dc, err := s.GetDistributeConfig(ctx); err == nil { + profileID = strings.TrimSpace(profileID) + if profileID != "" { + for _, profile := range dc.LLM.Providers { + if profile.ID == profileID { + return strings.TrimSpace(profile.APIKey) + } + } + return "" + } return strings.TrimSpace(dc.LLM.Active().APIKey) } return "" diff --git a/pkg/web/validation.go b/pkg/web/validation.go index f1a1b9e8..250ae8fc 100644 --- a/pkg/web/validation.go +++ b/pkg/web/validation.go @@ -9,10 +9,20 @@ import ( "github.com/chainreactors/aiscan/pkg/webproto" ) -// ValidateLLMConfig accepts zero as "use the model default" and rejects -// negative limits before an invalid configuration can be persisted. +// ValidateLLMConfig accepts zero limits as "use the model default" and rejects +// incomplete profiles before an invalid configuration can be persisted. func ValidateLLMConfig(cfg webproto.LLMConfig) error { - for _, profile := range cfg.Providers { + for i, profile := range cfg.Providers { + if strings.TrimSpace(profile.Model) == "" { + name := strings.TrimSpace(profile.Name) + if name == "" { + name = strings.TrimSpace(profile.ID) + } + if name == "" { + name = fmt.Sprintf("#%d", i+1) + } + return fmt.Errorf("LLM profile %q model is required", name) + } if profile.MaxTokens < 0 { return fmt.Errorf("LLM max_tokens must be zero or positive") } diff --git a/web/frontend/cyber-ui b/web/frontend/cyber-ui index 4f6a18b0..1d1a29e1 160000 --- a/web/frontend/cyber-ui +++ b/web/frontend/cyber-ui @@ -1 +1 @@ -Subproject commit 4f6a18b09280c768667242078516a15a24313ec1 +Subproject commit 1d1a29e17ecd3838880b193660df98c01cb1fc26 diff --git a/web/frontend/e2e/aiscan-web.spec.ts b/web/frontend/e2e/aiscan-web.spec.ts index 84f547ec..58c71cf0 100644 --- a/web/frontend/e2e/aiscan-web.spec.ts +++ b/web/frontend/e2e/aiscan-web.spec.ts @@ -162,7 +162,7 @@ test.describe('Config Panel', () => { await expect(dialog).toBeVisible({ timeout: 5_000 }); await expect(dialog).toContainText('Settings'); // Should have LLM and other tabs - await expect(dialog.locator('button:has-text("LLM")')).toBeVisible(); + await expect(dialog.getByRole('button', { name: 'LLM', exact: true })).toBeVisible(); }); test('closes settings dialog', async ({ page }) => { @@ -181,7 +181,7 @@ test.describe('Config Panel', () => { const dialog = page.locator('[role="dialog"]'); await expect(dialog).toBeVisible(); // Click LLM tab - const llmTab = dialog.locator('button:has-text("LLM")'); + const llmTab = dialog.getByRole('button', { name: 'LLM', exact: true }); if (await llmTab.isVisible()) { await llmTab.click(); } @@ -192,6 +192,61 @@ test.describe('Config Panel', () => { await expect(dialog).toContainText('Maximum output'); await expect(dialog).toContainText('API Key'); }); + + test('keeps dialog geometry stable when switching tabs', async ({ page }) => { + await openAuthenticatedApp(page); + await page.locator('button[aria-label="Open settings"]').click(); + const dialog = page.locator('[role="dialog"]'); + await expect(dialog).toBeVisible(); + await dialog.evaluate((element) => + Promise.all(element.getAnimations().map((animation) => animation.finished)), + ); + + const before = await dialog.boundingBox(); + await dialog.getByRole('button', { name: 'Cyberhub', exact: true }).click(); + const after = await dialog.boundingBox(); + + expect(before).not.toBeNull(); + expect(after).not.toBeNull(); + expect(Math.abs(after!.y - before!.y)).toBeLessThanOrEqual(1); + expect(Math.abs(after!.height - before!.height)).toBeLessThanOrEqual(1); + }); + + test('warns for a small context window and rejects an empty model', async ({ page }) => { + await openAuthenticatedApp(page); + await page.locator('button[aria-label="Open settings"]').click(); + const dialog = page.locator('[role="dialog"]'); + await expect(dialog).toBeVisible(); + + await dialog.getByLabel('Context window (tokens)').fill('4096'); + await expect(dialog).toContainText('Below 8192 tokens'); + + await dialog.getByLabel('Model').fill(''); + await dialog.getByRole('button', { name: 'Save', exact: true }).click(); + await expect(dialog).toBeVisible(); + await expect(dialog).toContainText('requires a model'); + }); + + test('closes after a successful save', async ({ page }) => { + let saved = false; + await page.route('**/api/config', async (route) => { + if (route.request().method() !== 'PUT') { + await route.continue(); + return; + } + saved = true; + await route.fulfill({ status: 200, contentType: 'application/json', body: '{}' }); + }); + + await openAuthenticatedApp(page); + await page.locator('button[aria-label="Open settings"]').click(); + const dialog = page.locator('[role="dialog"]'); + await expect(dialog).toBeVisible(); + await dialog.getByRole('button', { name: 'Save', exact: true }).click(); + + await expect(dialog).not.toBeVisible(); + expect(saved).toBe(true); + }); }); // --------------------------------------------------------------------------- diff --git a/web/frontend/src/api.ts b/web/frontend/src/api.ts index 9f2136a4..ba8f6ac5 100644 --- a/web/frontend/src/api.ts +++ b/web/frontend/src/api.ts @@ -355,6 +355,7 @@ export async function activateLLMProfile(id: string): Promise { // LLMTestRequest — POST /api/config/llm/test body. Leave api_key blank to // reuse the key already stored on the server. export interface LLMTestRequest { + profile_id?: string; provider: string; base_url: string; api_key: string; @@ -385,6 +386,7 @@ export async function testLLM(req: LLMTestRequest): Promise { // 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 { + profile_id?: string; provider: string; base_url: string; api_key: string; @@ -395,6 +397,7 @@ export interface LLMModelsRequest { // GET /models route. ok=false carries the reason in `error`. export interface LLMModelsResult { ok: boolean; + supported: boolean; models?: string[]; error?: string; } diff --git a/web/frontend/src/components/ChatPanel.tsx b/web/frontend/src/components/ChatPanel.tsx index bba50c86..7bdf8e18 100644 --- a/web/frontend/src/components/ChatPanel.tsx +++ b/web/frontend/src/components/ChatPanel.tsx @@ -838,7 +838,14 @@ function systemCode(metadata?: Record): string { function systemParams(metadata: Record): Record { const p = metadata.params - return p && typeof p === 'object' ? (p as Record) : {} + if (p && typeof p === 'object') return p as Record + + const ext = metadata.ext + if (!ext || typeof ext !== 'object') return {} + const webExt = (ext as Record)[webUserAgent] + if (!webExt || typeof webExt !== 'object') return {} + const params = (webExt as Record).params + return params && typeof params === 'object' ? params as Record : {} } function SystemMessageContent({ metadata, fallback }: { metadata: Record; fallback: string }) { diff --git a/web/frontend/src/components/ConfigPanel.tsx b/web/frontend/src/components/ConfigPanel.tsx index d33091cd..87d7c777 100644 --- a/web/frontend/src/components/ConfigPanel.tsx +++ b/web/frontend/src/components/ConfigPanel.tsx @@ -1,6 +1,6 @@ import { useEffect, useState, type FormEvent } from 'react' import { useTranslation } from 'react-i18next' -import { Check, CheckCircle, Plus, Settings, Trash2, Zap } from 'lucide-react' +import { Check, Plus, Settings, Trash2, Zap } from 'lucide-react' import { getConfigStatus, saveConfig, testLLM, testConn, listLLMModels } from '../api' import type { ConfigStatus, ConnCheck, DistributeConfig, LLMProviderProfile, LLMTestResult, ServerStatus } from '../api' import { Button, Input, Select, SelectTrigger, SelectContent, SelectItem, SelectValue, Badge, Spinner, Callout, Field, Switch, Dialog, DialogContent, DialogHeader, DialogFooter, DialogTitle, DialogDescription, ResultLine } from '@cyber/ui' @@ -26,6 +26,17 @@ const TABS: { key: TabKey; label: string }[] = [ { key: 'agent', label: 'Agent' }, ] +const LLM_PROVIDERS = [ + { value: 'deepseek', label: 'DeepSeek' }, + { value: 'openai', label: 'OpenAI' }, + { value: 'openrouter', label: 'OpenRouter' }, + { value: 'ollama', label: 'Ollama' }, + { value: 'groq', label: 'Groq' }, + { value: 'moonshot', label: 'Moonshot' }, + { value: 'anthropic', label: 'Anthropic' }, + { value: 'zhipu', label: 'Zhipu GLM' }, +] + function emptyForm(): DistributeConfig { const profile = blankLLMProfile('default') return { @@ -99,7 +110,8 @@ function sectionStatus( const tag = (name: string, ok: boolean) => ({ key: name, label: `${name} ${ok ? t('configured') : t('notConfigured')}`, ok }) switch (tab) { case 'llm': - return [{ key: 'llm', label: status?.llm_available ? t('llmReady') : t('llmOffline'), ok: !!status?.llm_available }] + const configured = !!(status?.llm_available && status.llm_model?.trim()) + return [{ key: 'llm', label: configured ? t('llmConfigured') : t('llmNotConfigured'), ok: configured }] case 'cyberhub': return [tag('Cyberhub', !!(cs?.cyberhub.url && cs?.cyberhub.key_configured))] case 'recon': @@ -126,15 +138,15 @@ export default function ConfigPanel({ open, status, onClose, onSaved }: ConfigPa const [loading, setLoading] = useState(false) const [saving, setSaving] = useState(false) const [error, setError] = useState('') - const [saved, setSaved] = useState(false) const [activeTab, setActiveTab] = useState('llm') const [selectedLLMProfileID, setSelectedLLMProfileID] = useState('default') + const [invalidModelProfileID, setInvalidModelProfileID] = useState('') useEffect(() => { if (!open) return setLoading(true) setError('') - setSaved(false) + setInvalidModelProfileID('') getConfigStatus() .then((s) => { const next = statusToForm(s) @@ -148,15 +160,22 @@ export default function ConfigPanel({ open, status, onClose, onSaved }: ConfigPa const handleSave = async (event: FormEvent) => { event.preventDefault() + + const invalidProfile = form.llm.providers.find(profile => !profile.model.trim()) + if (invalidProfile) { + setActiveTab('llm') + setSelectedLLMProfileID(invalidProfile.id) + setInvalidModelProfileID(invalidProfile.id) + setError(t('modelRequiredProfile', { name: invalidProfile.name || invalidProfile.id || t('unnamedProfile') })) + return + } + setSaving(true) setError('') - setSaved(false) try { - const next = await saveConfig(form) - setCs(next) - setForm(statusToForm(next)) - setSaved(true) + await saveConfig(form) onSaved() + onClose() } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err) setError(message || t('failedSave')) @@ -169,10 +188,10 @@ export default function ConfigPanel({ open, status, onClose, onSaved }: ConfigPa { if (!next) onClose() }}> e.preventDefault()} - className="block max-h-[85vh] w-full max-w-3xl gap-0 overflow-y-auto overflow-x-hidden rounded-2xl border-border/70 bg-card p-0 sm:rounded-2xl" + className="flex h-[85dvh] max-h-[42rem] w-full max-w-3xl gap-0 overflow-hidden rounded-lg border-border/70 bg-card p-0" > -
- + +
@@ -182,7 +201,7 @@ export default function ConfigPanel({ open, status, onClose, onSaved }: ConfigPa
-
+
{TABS.map((tab) => ( @@ -248,13 +277,23 @@ function LLMTab({ cs, selectedProfileID, onSelectProfile, -}: TabProps & { selectedProfileID: string; onSelectProfile: (id: string) => void }) { + invalidModelProfileID, + onInvalidModel, + onModelChange, +}: TabProps & { + selectedProfileID: string + onSelectProfile: (id: string) => void + invalidModelProfileID: string + onInvalidModel: (profile: LLMProviderProfile) => void + onModelChange: (profileID: string) => void +}) { const { t } = useTranslation('config') const [testing, setTesting] = useState(false) const [result, setResult] = useState(null) const [models, setModels] = useState([]) const [fetchingModels, setFetchingModels] = useState(false) const [modelsError, setModelsError] = useState(null) + const [modelsNotice, setModelsNotice] = useState(null) const profiles = form.llm.providers const profile = profiles.find(item => item.id === selectedProfileID) || profiles[0] @@ -276,6 +315,8 @@ function LLMTab({ setForm(current => ({ ...current, llm: { ...current.llm, providers: [...current.llm.providers, next] } })) onSelectProfile(next.id) setModels([]) + setModelsError(null) + setModelsNotice(null) setResult(null) } @@ -289,11 +330,17 @@ function LLMTab({ })) onSelectProfile(remaining[0].id) setModels([]) + setModelsError(null) + setModelsNotice(null) setResult(null) } const setActiveProfile = () => { if (!profile) return + if (!profile.model.trim()) { + onInvalidModel(profile) + return + } setForm(current => ({ ...current, llm: { ...current.llm, active_profile: profile.id } })) } @@ -301,8 +348,10 @@ function LLMTab({ if (!profile) return setFetchingModels(true) setModelsError(null) + setModelsNotice(null) try { const res = await listLLMModels({ + profile_id: profile.id, provider: profile.provider, base_url: profile.base_url, api_key: profile.api_key, @@ -310,7 +359,8 @@ function LLMTab({ }) if (res.ok) { setModels(res.models ?? []) - if ((res.models ?? []).length === 0) setModelsError(t('modelsEmpty')) + if (!res.supported) setModelsNotice(t('modelsUnsupported')) + else if ((res.models ?? []).length === 0) setModelsNotice(t('modelsEmpty')) } else { setModelsError(res.error || t('modelsFailed')) } @@ -328,6 +378,7 @@ function LLMTab({ setResult(null) try { const res = await testLLM({ + profile_id: profile.id, provider: profile.provider, base_url: profile.base_url, api_key: profile.api_key, @@ -345,6 +396,9 @@ function LLMTab({ if (!profile) return null + const modelRequired = invalidModelProfileID === profile.id && !profile.model.trim() + const lowContextWindow = profile.context_window !== undefined && profile.context_window < 8192 + return (
@@ -352,7 +406,7 @@ function LLMTab({ const active = item.id === form.llm.active_profile const selected = item.id === profile.id return ( -
- diff --git a/web/frontend/src/i18n/locales/en/config.ts b/web/frontend/src/i18n/locales/en/config.ts index 8060516f..b144b1a9 100644 --- a/web/frontend/src/i18n/locales/en/config.ts +++ b/web/frontend/src/i18n/locales/en/config.ts @@ -1,7 +1,7 @@ export default { settings: 'Settings', - llmReady: 'LLM Ready', - llmOffline: 'LLM Offline', + llmConfigured: 'LLM configured', + llmNotConfigured: 'LLM not configured', configLoaded: 'Config Loaded', configMissing: 'Config Missing', configured: 'Configured', @@ -25,6 +25,7 @@ export default { // model list auto-fetch fetchModels: 'Fetch model list', modelsEmpty: 'Endpoint returned no models — enter one manually', + modelsUnsupported: 'This endpoint does not expose a model list; enter the model manually', modelsFailed: 'Failed to fetch model list', modelsCount: '{{count}} models', modelsLoading: 'Fetching models…', @@ -32,7 +33,9 @@ export default { modelSearchNoMatch: 'No model matches “{{query}}”', modelUseCustom: 'Press Enter to use “{{query}}”', baseUrl: 'Base URL', - contextWindow: 'Context window', + contextWindow: 'Context window (tokens)', + contextWindowLow: 'Below 8192 tokens, requests may not have enough room for both input and output', + contextWindowAuto: 'Leave empty to infer from the model', maxTokens: 'Maximum output', proxy: 'Proxy', apiKey: 'API Key', @@ -57,6 +60,8 @@ export default { // placeholder hints configuredKeep: 'configured; leave blank to keep', requiredUnlessOllama: 'required unless ollama', + modelRequired: 'Model is required', + modelRequiredProfile: 'Profile “{{name}}” requires a model', providerDefault: 'leave empty for provider default', modelDefault: 'leave empty for model default', cyberhubApiKey: 'cyberhub API key', diff --git a/web/frontend/src/i18n/locales/zh/config.ts b/web/frontend/src/i18n/locales/zh/config.ts index 311ddc55..5de3a2cd 100644 --- a/web/frontend/src/i18n/locales/zh/config.ts +++ b/web/frontend/src/i18n/locales/zh/config.ts @@ -1,7 +1,7 @@ export default { settings: '设置', - llmReady: 'LLM 就绪', - llmOffline: 'LLM 离线', + llmConfigured: 'LLM 已配置', + llmNotConfigured: 'LLM 未配置', configLoaded: '配置已加载', configMissing: '配置缺失', configured: '已配置', @@ -25,6 +25,7 @@ export default { // model list auto-fetch fetchModels: '拉取模型列表', modelsEmpty: '该端点未返回任何模型,请手动填写', + modelsUnsupported: '该端点不提供模型列表,请手动填写模型', modelsFailed: '拉取模型列表失败', modelsCount: '{{count}} 个模型', modelsLoading: '正在拉取模型…', @@ -32,7 +33,9 @@ export default { modelSearchNoMatch: '没有匹配 “{{query}}” 的模型', modelUseCustom: '按回车使用 “{{query}}”', baseUrl: 'Base URL', - contextWindow: '上下文窗口', + contextWindow: '上下文窗口(Token)', + contextWindowLow: '低于 8192 Token,实际请求可能没有足够的输入和输出空间', + contextWindowAuto: '留空则根据模型自动推断', maxTokens: '最大输出', proxy: '代理', apiKey: 'API Key', @@ -57,6 +60,8 @@ export default { // placeholder hints configuredKeep: '已配置;留空则保持不变', requiredUnlessOllama: '必填(ollama 除外)', + modelRequired: '模型不能为空', + modelRequiredProfile: '配置 “{{name}}” 的模型不能为空', providerDefault: '留空则使用 Provider 默认值', modelDefault: '留空则使用模型默认值', cyberhubApiKey: 'Cyberhub API Key',