diff --git a/CHANGELOG.md b/CHANGELOG.md index a03e902..3a09f99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## Unreleased + +- Nothing yet. + +## v1.4.3 - 2026-04-30 + +- Added remote API timeout fallback with a configurable model order. The default order is Kimi-K2.6, MiniMax-M2.7, Qwen3-Coder, Qwen3.6-Plus, Qwen3-Max, and Qwen3-Thinking. +- Fallback only runs before any streaming bytes are sent and only uses models returned by the active `/v1/models` response. +- Changed the default request timeout from 120 seconds to 300 seconds. +- Added a desktop Settings switch and fallback model list editor. +- Added persistent desktop app state for request history, app logs, and cumulative token usage. +- Added a Dashboard token usage card and model-list specification chips for context window and capability summaries. +- Added model display to the desktop request stream table and model-aware request search. +- Fixed Dashboard "recent model" tracking so health/model-list requests no longer override the last real chat model. +- Updated architecture documentation to cover the IPC and Remote API dual-backend design. +- Disabled desktop Inspector and default context menu in production builds; local development can opt in with `LINGMA_DESKTOP_DEBUG=1`. + ## v1.4.2 - 2026-04-30 - Default backend changed to remote API mode for new CLI and desktop configurations. diff --git a/README.md b/README.md index b17759e..294091e 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ The proxy now supports two backend modes: ## Current Version -The current desktop line is `v1.4.2`. +The current desktop line is `v1.4.3`. See [CHANGELOG.md](./CHANGELOG.md) for release history. @@ -326,6 +326,10 @@ The proxy only reports models actually exposed by your Lingma plugin. The table Default model when the client omits `model`: `kmodel` (`Kimi-K2.6` in the remote model list). +Remote mode enables timeout fallback by default. On timeout, upstream 5xx/429, or network interruption, the proxy only switches models if no streaming bytes have been sent to the client yet. Fallback candidates are filtered against the actual `/v1/models` response, so unavailable models are skipped. Default order: + +`Kimi-K2.6 -> MiniMax-M2.7 -> Qwen3-Coder -> Qwen3.6-Plus -> Qwen3-Max -> Qwen3-Thinking` + ## Configuration Default config file: @@ -348,7 +352,16 @@ Example: "mode": "agent", "shell_type": "zsh", "session_mode": "auto", - "timeout": 120, + "timeout": 300, + "remote_fallback_enabled": true, + "remote_fallback_models": [ + "kmodel", + "mmodel", + "dashscope_qwen3_coder", + "dashscope_qmodel", + "dashscope_qwen_max_latest", + "dashscope_qwen_plus_20250428_thinking" + ], "cwd": "/Users/you/project", "current_file_path": "" } diff --git a/README.zh-CN.md b/README.zh-CN.md index 288d1e5..9e808f3 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -16,7 +16,7 @@ ## 当前版本 -当前桌面端版本线:`v1.4.2` +当前桌面端版本线:`v1.4.3` 版本更新记录见 [CHANGELOG.md](./CHANGELOG.md)。 @@ -408,6 +408,10 @@ export ANTHROPIC_API_KEY="any" 当客户端请求没有携带 `model` 字段时,代理默认使用:`kmodel`(远端模型列表里的 Kimi-K2.6)。 +远端模式默认开启超时兜底。遇到请求超时、上游 5xx/429 或网络中断时,代理只会在尚未向客户端输出任何流式内容的情况下切换模型。兜底候选会先和实际 `/v1/models` 返回结果求交集,不存在或当前账号不可用的模型会自动跳过。默认顺序: + +`Kimi-K2.6 -> MiniMax-M2.7 -> Qwen3-Coder -> Qwen3.6-Plus -> Qwen3-Max -> Qwen3-Thinking` + ## 配置文件 默认读取: @@ -430,7 +434,16 @@ export ANTHROPIC_API_KEY="any" "mode": "agent", "shell_type": "zsh", "session_mode": "auto", - "timeout": 120, + "timeout": 300, + "remote_fallback_enabled": true, + "remote_fallback_models": [ + "kmodel", + "mmodel", + "dashscope_qwen3_coder", + "dashscope_qmodel", + "dashscope_qwen_max_latest", + "dashscope_qwen_plus_20250428_thinking" + ], "cwd": "/Users/tiancheng/project", "current_file_path": "" } diff --git a/cmd/lingma-ipc-proxy/main.go b/cmd/lingma-ipc-proxy/main.go index 622eece..33c1913 100644 --- a/cmd/lingma-ipc-proxy/main.go +++ b/cmd/lingma-ipc-proxy/main.go @@ -22,22 +22,24 @@ import ( ) type fileConfig struct { - Host string `json:"host"` - Port int `json:"port"` - Backend string `json:"backend"` - Transport string `json:"transport"` - Pipe string `json:"pipe"` - WebSocketURL string `json:"websocket_url"` - RemoteBaseURL string `json:"remote_base_url"` - RemoteAuthFile string `json:"remote_auth_file"` - RemoteVersion string `json:"remote_version"` - Cwd string `json:"cwd"` - CurrentFilePath string `json:"current_file_path"` - Mode string `json:"mode"` - Model string `json:"model"` - ShellType string `json:"shell_type"` - SessionMode string `json:"session_mode"` - TimeoutSeconds int `json:"timeout"` + Host string `json:"host"` + Port int `json:"port"` + Backend string `json:"backend"` + Transport string `json:"transport"` + Pipe string `json:"pipe"` + WebSocketURL string `json:"websocket_url"` + RemoteBaseURL string `json:"remote_base_url"` + RemoteAuthFile string `json:"remote_auth_file"` + RemoteVersion string `json:"remote_version"` + Cwd string `json:"cwd"` + CurrentFilePath string `json:"current_file_path"` + Mode string `json:"mode"` + Model string `json:"model"` + ShellType string `json:"shell_type"` + SessionMode string `json:"session_mode"` + TimeoutSeconds int `json:"timeout"` + RemoteFallbackEnabled *bool `json:"remote_fallback_enabled"` + RemoteFallbackModels []string `json:"remote_fallback_models"` } func main() { @@ -89,16 +91,18 @@ func main() { func loadConfig() (service.Config, string) { cfg := service.Config{ - Host: "127.0.0.1", - Port: 8095, - Backend: service.BackendRemote, - Transport: lingmaipc.TransportAuto, - Cwd: currentDir(), - Mode: "agent", - Model: "kmodel", - ShellType: defaultShellType(), - SessionMode: service.SessionModeAuto, - Timeout: 120 * time.Second, + Host: "127.0.0.1", + Port: 8095, + Backend: service.BackendRemote, + Transport: lingmaipc.TransportAuto, + Cwd: currentDir(), + Mode: "agent", + Model: "kmodel", + ShellType: defaultShellType(), + SessionMode: service.SessionModeAuto, + Timeout: 300 * time.Second, + RemoteFallbackEnabled: true, + RemoteFallbackModels: service.DefaultRemoteFallbackModels(), } configPath, configLoaded := resolveConfigPath() @@ -127,6 +131,8 @@ func loadConfig() (service.Config, string) { model := flag.String("model", cfg.Model, "Default Lingma model when API request omits model") shellType := flag.String("shell-type", cfg.ShellType, "Shell type sent through ACP meta") timeoutSeconds := flag.Int("timeout", int(cfg.Timeout/time.Second), "Per-request timeout in seconds") + remoteFallbackEnabled := flag.Bool("remote-fallback", cfg.RemoteFallbackEnabled, "Enable remote timeout/5xx fallback to the next available model") + remoteFallbackModels := flag.String("remote-fallback-models", strings.Join(cfg.RemoteFallbackModels, ","), "Comma-separated remote fallback model IDs") sessionMode := flag.String("session-mode", string(cfg.SessionMode), "Session mode: auto, fresh, reuse") config := flag.String("config", valueOr(configPath, filepath.Join(currentDir(), "lingma-ipc-proxy.json")), "Path to JSON config file") flag.Parse() @@ -151,6 +157,8 @@ func loadConfig() (service.Config, string) { cfg.ShellType = strings.TrimSpace(*shellType) cfg.SessionMode = parsedSessionMode cfg.Timeout = time.Duration(*timeoutSeconds) * time.Second + cfg.RemoteFallbackEnabled = *remoteFallbackEnabled + cfg.RemoteFallbackModels = splitCSV(*remoteFallbackModels) if configLoaded { configPath = finalConfigPath @@ -236,6 +244,12 @@ func overlayFileConfig(dst *service.Config, src fileConfig) { if src.TimeoutSeconds > 0 { dst.Timeout = time.Duration(src.TimeoutSeconds) * time.Second } + if src.RemoteFallbackEnabled != nil { + dst.RemoteFallbackEnabled = *src.RemoteFallbackEnabled + } + if len(src.RemoteFallbackModels) > 0 { + dst.RemoteFallbackModels = cleanStringSlice(src.RemoteFallbackModels) + } } func overlayEnvConfig(dst *service.Config) { @@ -287,6 +301,12 @@ func overlayEnvConfig(dst *service.Config) { if value := envInt("LINGMA_PROXY_TIMEOUT_SECONDS", 0); value > 0 { dst.Timeout = time.Duration(value) * time.Second } + if value, ok := envBool("LINGMA_REMOTE_FALLBACK_ENABLED"); ok { + dst.RemoteFallbackEnabled = value + } + if value := strings.TrimSpace(os.Getenv("LINGMA_REMOTE_FALLBACK_MODELS")); value != "" { + dst.RemoteFallbackModels = splitCSV(value) + } } func parseSessionMode(value string) service.SessionMode { @@ -349,6 +369,36 @@ func envInt(key string, fallback int) int { return fallback } +func envBool(key string) (bool, bool) { + value := strings.ToLower(strings.TrimSpace(os.Getenv(key))) + switch value { + case "1", "true", "yes", "on": + return true, true + case "0", "false", "no", "off": + return false, true + default: + return false, false + } +} + +func splitCSV(value string) []string { + return cleanStringSlice(strings.Split(value, ",")) +} + +func cleanStringSlice(values []string) []string { + out := make([]string, 0, len(values)) + seen := map[string]bool{} + for _, value := range values { + item := strings.TrimSpace(value) + if item == "" || seen[item] { + continue + } + seen[item] = true + out = append(out, item) + } + return out +} + func currentDir() string { if wd, err := os.Getwd(); err == nil { return wd diff --git a/config.example.json b/config.example.json index 8da5745..eae2963 100644 --- a/config.example.json +++ b/config.example.json @@ -6,7 +6,16 @@ "mode": "chat", "model": "kmodel", "session_mode": "auto", - "timeout": 120, + "timeout": 300, + "remote_fallback_enabled": true, + "remote_fallback_models": [ + "kmodel", + "mmodel", + "dashscope_qwen3_coder", + "dashscope_qmodel", + "dashscope_qwen_max_latest", + "dashscope_qwen_plus_20250428_thinking" + ], "cwd": "C:/Workspace/Personal/lingma-ipc-proxy", "shell_type": "powershell", "current_file_path": "", diff --git a/desktop/app.go b/desktop/app.go index d7286e7..15dc3de 100644 --- a/desktop/app.go +++ b/desktop/app.go @@ -25,15 +25,35 @@ import ( // App struct // RequestRecord stores a single HTTP request summary type RequestRecord struct { - Time string `json:"time"` - Method string `json:"method"` - Path string `json:"path"` - Model string `json:"model,omitempty"` - StatusCode int `json:"statusCode"` - Duration string `json:"duration"` - Size string `json:"size,omitempty"` - ReqBody string `json:"reqBody,omitempty"` - RespBody string `json:"respBody,omitempty"` + Time string `json:"time"` + Method string `json:"method"` + Path string `json:"path"` + Model string `json:"model,omitempty"` + StatusCode int `json:"statusCode"` + Duration string `json:"duration"` + Size string `json:"size,omitempty"` + InputTokens int `json:"inputTokens,omitempty"` + OutputTokens int `json:"outputTokens,omitempty"` + TotalTokens int `json:"totalTokens,omitempty"` + ReqBody string `json:"reqBody,omitempty"` + RespBody string `json:"respBody,omitempty"` +} + +type AppLog struct { + Time string `json:"time"` + Level string `json:"level"` + Message string `json:"message"` +} + +type TokenStats struct { + TotalRequests int `json:"totalRequests"` + SuccessRequests int `json:"successRequests"` + InputTokens int `json:"inputTokens"` + OutputTokens int `json:"outputTokens"` + TotalTokens int `json:"totalTokens"` + ByModel map[string]int `json:"byModel,omitempty"` + LastModel string `json:"lastModel,omitempty"` + LastUpdated string `json:"lastUpdated,omitempty"` } type App struct { @@ -49,6 +69,8 @@ type App struct { quitHint time.Time models []ModelInfo requests []RequestRecord + logs []AppLog + stats TokenStats } // ModelInfo represents a model returned by /v1/models @@ -96,6 +118,9 @@ func NewApp() *App { func (a *App) startup(ctx context.Context) { a.ctx = ctx a.cfg = defaultConfig() + if err := a.loadAppState(); err != nil { + runtime.LogWarningf(a.ctx, "failed to load app state: %v", err) + } // Auto-save default config on first run so users can find/edit it later if err := a.saveConfig(a.cfg); err != nil { @@ -208,10 +233,19 @@ func (a *App) forceQuit() { } func (a *App) emitLog(level string, message string) { - runtime.EventsEmit(a.ctx, "log", map[string]string{ - "level": level, - "message": message, - }) + entry := AppLog{ + Time: time.Now().Format("15:04:05"), + Level: level, + Message: message, + } + a.mu.Lock() + a.logs = append(a.logs, entry) + if len(a.logs) > 2000 { + a.logs = a.logs[len(a.logs)-2000:] + } + a.saveAppStateLocked() + a.mu.Unlock() + runtime.EventsEmit(a.ctx, "log", entry) } // GetStatus returns the current proxy status @@ -331,22 +365,24 @@ func (a *App) saveConfig(cfg service.Config) error { timeoutSec := int(cfg.Timeout.Seconds()) fileCfg := map[string]any{ - "host": cfg.Host, - "port": cfg.Port, - "backend": string(cfg.Backend), - "transport": string(cfg.Transport), - "pipe": cfg.Pipe, - "websocket_url": cfg.WebSocketURL, - "remote_base_url": cfg.RemoteBaseURL, - "remote_auth_file": cfg.RemoteAuthFile, - "remote_version": cfg.RemoteVersion, - "cwd": cfg.Cwd, - "current_file_path": cfg.CurrentFilePath, - "mode": cfg.Mode, - "model": cfg.Model, - "shell_type": cfg.ShellType, - "session_mode": string(cfg.SessionMode), - "timeout": timeoutSec, + "host": cfg.Host, + "port": cfg.Port, + "backend": string(cfg.Backend), + "transport": string(cfg.Transport), + "pipe": cfg.Pipe, + "websocket_url": cfg.WebSocketURL, + "remote_base_url": cfg.RemoteBaseURL, + "remote_auth_file": cfg.RemoteAuthFile, + "remote_version": cfg.RemoteVersion, + "cwd": cfg.Cwd, + "current_file_path": cfg.CurrentFilePath, + "mode": cfg.Mode, + "model": cfg.Model, + "shell_type": cfg.ShellType, + "session_mode": string(cfg.SessionMode), + "timeout": timeoutSec, + "remote_fallback_enabled": cfg.RemoteFallbackEnabled, + "remote_fallback_models": cfg.RemoteFallbackModels, } data, err := json.MarshalIndent(fileCfg, "", " ") @@ -361,14 +397,16 @@ func (a *App) saveConfig(cfg service.Config) error { // StartProxy starts the lingma-ipc-proxy HTTP server func (a *App) StartProxy() error { a.mu.Lock() - defer a.mu.Unlock() - if a.running { + a.mu.Unlock() return fmt.Errorf("proxy already running") } addr := fmt.Sprintf("%s:%d", a.cfg.Host, a.cfg.Port) - svc := service.New(a.cfg) + cfg := a.cfg + a.mu.Unlock() + + svc := service.New(cfg) warmupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) if err := svc.Warmup(warmupCtx); err != nil { @@ -382,23 +420,32 @@ func (a *App) StartProxy() error { server := httpapi.NewServer(addr, svc) server.OnRequest = func(method, path string, statusCode int, duration time.Duration, reqBody, respBody string) { - a.mu.Lock() - a.requests = append(a.requests, RequestRecord{ - Time: time.Now().Format("15:04:05"), - Method: method, - Path: path, - Model: extractRequestModel(reqBody), - StatusCode: statusCode, - Duration: duration.Round(time.Millisecond).String(), - Size: formatPayloadSize(len(reqBody) + len(respBody)), - ReqBody: reqBody, - RespBody: respBody, - }) - if len(a.requests) > 100 { - a.requests = a.requests[len(a.requests)-100:] + inputTokens, outputTokens := extractTokenUsage(respBody) + model := extractRequestModel(reqBody) + record := RequestRecord{ + Time: time.Now().Format("15:04:05"), + Method: method, + Path: path, + Model: model, + StatusCode: statusCode, + Duration: duration.Round(time.Millisecond).String(), + Size: formatPayloadSize(len(reqBody) + len(respBody)), + InputTokens: inputTokens, + OutputTokens: outputTokens, + TotalTokens: inputTokens + outputTokens, + ReqBody: reqBody, + RespBody: respBody, } + a.mu.Lock() + a.requests = append(a.requests, record) + if len(a.requests) > 2000 { + a.requests = a.requests[len(a.requests)-2000:] + } + a.accumulateTokenStatsLocked(record) + a.saveAppStateLocked() a.mu.Unlock() runtime.EventsEmit(a.ctx, "requests:updated", a.GetRequests()) + runtime.EventsEmit(a.ctx, "usage:updated", a.GetTokenStats()) } // Check if the port is available before claiming we're running @@ -420,10 +467,16 @@ func (a *App) StartProxy() error { } }() + a.mu.Lock() + if a.running { + a.mu.Unlock() + return fmt.Errorf("proxy already running") + } a.server = server a.addr = addr a.running = true a.startedAt = time.Now() + a.mu.Unlock() msg := fmt.Sprintf("Proxy started on http://%s", addr) runtime.LogInfof(a.ctx, msg) @@ -435,8 +488,24 @@ func (a *App) StartProxy() error { return nil } -// ClearLogs is a no-op backend helper (logs are kept in frontend memory) -func (a *App) ClearLogs() {} +func (a *App) GetLogs() []AppLog { + a.mu.RLock() + defer a.mu.RUnlock() + out := make([]AppLog, len(a.logs)) + copy(out, a.logs) + for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 { + out[i], out[j] = out[j], out[i] + } + return out +} + +func (a *App) ClearLogs() { + a.mu.Lock() + a.logs = nil + a.saveAppStateLocked() + a.mu.Unlock() + runtime.EventsEmit(a.ctx, "logs:updated", a.GetLogs()) +} // StopProxy stops the proxy server func (a *App) StopProxy() error { @@ -493,10 +562,21 @@ func (a *App) GetRequests() []RequestRecord { func (a *App) ClearRequests() { a.mu.Lock() a.requests = nil + a.saveAppStateLocked() a.mu.Unlock() a.emitLog("info", "Request history cleared") } +func (a *App) GetTokenStats() TokenStats { + a.mu.RLock() + defer a.mu.RUnlock() + stats := a.stats + if stats.ByModel != nil { + stats.ByModel = cloneIntMap(stats.ByModel) + } + return stats +} + // RefreshModels probes the running proxy for the latest model list. func (a *App) RefreshModels() ([]ModelInfo, error) { a.mu.RLock() @@ -614,18 +694,221 @@ func formatPayloadSize(bytes int) string { return fmt.Sprintf("%d B", bytes) } +type appStateFile struct { + Requests []RequestRecord `json:"requests"` + Logs []AppLog `json:"logs"` + Stats TokenStats `json:"stats"` +} + +func (a *App) loadAppState() error { + path, err := appStatePath() + if err != nil { + return err + } + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + var state appStateFile + if err := json.Unmarshal(data, &state); err != nil { + return err + } + a.mu.Lock() + defer a.mu.Unlock() + a.requests = state.Requests + a.logs = state.Logs + a.stats = state.Stats + if a.stats.ByModel == nil { + a.stats.ByModel = map[string]int{} + } + a.reconcileTokenStatsLocked() + return nil +} + +func (a *App) saveAppStateLocked() { + path, err := appStatePath() + if err != nil { + runtime.LogWarningf(a.ctx, "resolve app state path failed: %v", err) + return + } + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + runtime.LogWarningf(a.ctx, "create app state dir failed: %v", err) + return + } + state := appStateFile{ + Requests: a.requests, + Logs: a.logs, + Stats: a.stats, + } + data, err := json.MarshalIndent(state, "", " ") + if err != nil { + runtime.LogWarningf(a.ctx, "marshal app state failed: %v", err) + return + } + if err := os.WriteFile(path, data, 0644); err != nil { + runtime.LogWarningf(a.ctx, "write app state failed: %v", err) + } +} + +func appStatePath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".config", "lingma-ipc-proxy", "app-state.json"), nil +} + +func (a *App) accumulateTokenStatsLocked(record RequestRecord) { + a.stats.TotalRequests++ + if record.StatusCode >= 200 && record.StatusCode < 300 { + a.stats.SuccessRequests++ + } + a.stats.InputTokens += record.InputTokens + a.stats.OutputTokens += record.OutputTokens + a.stats.TotalTokens += record.TotalTokens + if a.stats.ByModel == nil { + a.stats.ByModel = map[string]int{} + } + model := strings.TrimSpace(record.Model) + if model == "" { + model = "-" + } + if record.TotalTokens > 0 { + a.stats.ByModel[model] += record.TotalTokens + if isUsageBearingRequest(record.Path) && model != "-" { + a.stats.LastModel = model + } + } + a.stats.LastUpdated = time.Now().Format(time.RFC3339) +} + +func (a *App) reconcileTokenStatsLocked() { + if a.stats.ByModel == nil { + a.stats.ByModel = map[string]int{} + } + a.stats.LastModel = "" + for i := len(a.requests) - 1; i >= 0; i-- { + record := a.requests[i] + model := strings.TrimSpace(record.Model) + if model == "" || record.TotalTokens <= 0 || !isUsageBearingRequest(record.Path) { + continue + } + a.stats.LastModel = model + break + } +} + +func isUsageBearingRequest(path string) bool { + switch strings.TrimSpace(path) { + case "/v1/messages", "/v1/chat/completions", "/v1/completions": + return true + default: + return false + } +} + +func cloneIntMap(src map[string]int) map[string]int { + out := make(map[string]int, len(src)) + for k, v := range src { + out[k] = v + } + return out +} + +func extractTokenUsage(respBody string) (int, int) { + if strings.TrimSpace(respBody) == "" { + return 0, 0 + } + input, output := extractUsageFromJSON(respBody) + if input != 0 || output != 0 { + return input, output + } + for _, line := range strings.Split(respBody, "\n") { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "data:") { + continue + } + payload := strings.TrimSpace(strings.TrimPrefix(line, "data:")) + if payload == "" || payload == "[DONE]" { + continue + } + in, out := extractUsageFromJSON(payload) + if in > 0 { + input = in + } + if out > 0 { + output = out + } + } + return input, output +} + +func extractUsageFromJSON(raw string) (int, int) { + var payload any + if err := json.Unmarshal([]byte(raw), &payload); err != nil { + return 0, 0 + } + usage, ok := findUsageMap(payload) + if !ok { + return 0, 0 + } + input := intFromAny(usage["input_tokens"]) + intFromAny(usage["prompt_tokens"]) + output := intFromAny(usage["output_tokens"]) + intFromAny(usage["completion_tokens"]) + return input, output +} + +func findUsageMap(value any) (map[string]any, bool) { + switch typed := value.(type) { + case map[string]any: + if usage, ok := typed["usage"].(map[string]any); ok { + return usage, true + } + for _, child := range typed { + if usage, ok := findUsageMap(child); ok { + return usage, true + } + } + case []any: + for _, child := range typed { + if usage, ok := findUsageMap(child); ok { + return usage, true + } + } + } + return nil, false +} + +func intFromAny(value any) int { + switch typed := value.(type) { + case float64: + return int(typed) + case int: + return typed + case json.Number: + n, _ := typed.Int64() + return int(n) + default: + return 0 + } +} + func defaultConfig() service.Config { cfg := service.Config{ - Host: "127.0.0.1", - Port: 8095, - Backend: service.BackendRemote, - Transport: lingmaipc.TransportAuto, - Cwd: defaultCwd(), - Mode: "agent", - Model: "kmodel", - ShellType: defaultShellType(), - SessionMode: service.SessionModeAuto, - Timeout: 120 * time.Second, + Host: "127.0.0.1", + Port: 8095, + Backend: service.BackendRemote, + Transport: lingmaipc.TransportAuto, + Cwd: defaultCwd(), + Mode: "agent", + Model: "kmodel", + ShellType: defaultShellType(), + SessionMode: service.SessionModeAuto, + Timeout: 300 * time.Second, + RemoteFallbackEnabled: true, + RemoteFallbackModels: service.DefaultRemoteFallbackModels(), } // Try to load config file from multiple locations @@ -634,22 +917,24 @@ func defaultConfig() service.Config { if info, err := os.Stat(configPath); err == nil && !info.IsDir() { if data, err := os.ReadFile(configPath); err == nil { var fileCfg struct { - Host string `json:"host"` - Port int `json:"port"` - Backend string `json:"backend"` - Transport string `json:"transport"` - Pipe string `json:"pipe"` - WebSocketURL string `json:"websocket_url"` - RemoteBaseURL string `json:"remote_base_url"` - RemoteAuthFile string `json:"remote_auth_file"` - RemoteVersion string `json:"remote_version"` - Cwd string `json:"cwd"` - CurrentFilePath string `json:"current_file_path"` - Mode string `json:"mode"` - Model string `json:"model"` - ShellType string `json:"shell_type"` - SessionMode string `json:"session_mode"` - TimeoutSeconds int `json:"timeout"` + Host string `json:"host"` + Port int `json:"port"` + Backend string `json:"backend"` + Transport string `json:"transport"` + Pipe string `json:"pipe"` + WebSocketURL string `json:"websocket_url"` + RemoteBaseURL string `json:"remote_base_url"` + RemoteAuthFile string `json:"remote_auth_file"` + RemoteVersion string `json:"remote_version"` + Cwd string `json:"cwd"` + CurrentFilePath string `json:"current_file_path"` + Mode string `json:"mode"` + Model string `json:"model"` + ShellType string `json:"shell_type"` + SessionMode string `json:"session_mode"` + TimeoutSeconds int `json:"timeout"` + RemoteFallbackEnabled *bool `json:"remote_fallback_enabled"` + RemoteFallbackModels []string `json:"remote_fallback_models"` } if err := json.Unmarshal(data, &fileCfg); err == nil { if fileCfg.Host != "" { @@ -702,6 +987,12 @@ func defaultConfig() service.Config { if fileCfg.TimeoutSeconds > 0 { cfg.Timeout = time.Duration(fileCfg.TimeoutSeconds) * time.Second } + if fileCfg.RemoteFallbackEnabled != nil { + cfg.RemoteFallbackEnabled = *fileCfg.RemoteFallbackEnabled + } + if len(fileCfg.RemoteFallbackModels) > 0 { + cfg.RemoteFallbackModels = cleanConfigStrings(fileCfg.RemoteFallbackModels) + } } break // loaded successfully } @@ -732,6 +1023,20 @@ func maskIdentifier(value string) string { return string(runes[:4]) + "..." + string(runes[len(runes)-4:]) } +func cleanConfigStrings(values []string) []string { + out := make([]string, 0, len(values)) + seen := map[string]bool{} + for _, value := range values { + item := strings.TrimSpace(value) + if item == "" || seen[item] { + continue + } + seen[item] = true + out = append(out, item) + } + return out +} + func configSearchPaths() []string { var paths []string // 1. Executable directory (for dev / portable mode) diff --git a/desktop/frontend/src/App.vue b/desktop/frontend/src/App.vue index c999252..21f6e29 100644 --- a/desktop/frontend/src/App.vue +++ b/desktop/frontend/src/App.vue @@ -6,7 +6,7 @@ import Models from './views/Models.vue' import Requests from './views/Requests.vue' import Settings from './views/Settings.vue' import { EventsOff, EventsOn } from '../wailsjs/runtime' -import { GetStatus, HideWindow, MinimizeWindow } from '../wailsjs/go/main/App.js' +import { ClearLogs, GetLogs, GetStatus, HideWindow, MinimizeWindow } from '../wailsjs/go/main/App.js' import lingmaIcon from './assets/images/lingma-icon.png' const currentTab = ref('dashboard') @@ -42,8 +42,13 @@ function showToast(message) { }, 2200) } -function clearLocalLogs() { - logs.value = [] +async function clearLocalLogs() { + try { + await ClearLogs() + logs.value = [] + } catch (e) { + logs.value = [] + } } function setStatus(nextStatus) { @@ -158,14 +163,25 @@ onMounted(() => { systemThemeQuery?.addEventListener?.('change', applyTheme) applyTheme() refreshStatus() + GetLogs().then((items) => { + logs.value = Array.isArray(items) ? items : [] + }).catch(() => {}) safeEventsOn('models:updated', (data) => { status.value.models = Array.isArray(data) ? data.length : status.value.models addLog('info', `模型列表已更新:${status.value.models} 个模型`) }) safeEventsOn('log', (data) => { - addLog(data.level || 'info', data.message || '') + if (data.time && data.message !== undefined) { + logs.value.unshift(data) + if (logs.value.length > 500) logs.value = logs.value.slice(0, 500) + } else { + addLog(data.level || 'info', data.message || '') + } refreshStatus() }) + safeEventsOn('logs:updated', (data) => { + logs.value = Array.isArray(data) ? data : [] + }) safeEventsOn('quit:confirm', (message) => { showToast(message || '再按一次退出快捷键将停止代理并退出应用') }) @@ -183,6 +199,7 @@ onUnmounted(() => { systemThemeQuery?.removeEventListener?.('change', applyTheme) safeEventsOff('models:updated') safeEventsOff('log') + safeEventsOff('logs:updated') safeEventsOff('quit:confirm') safeEventsOff('status:updated') safeEventsOff('requests:updated') @@ -222,7 +239,7 @@ onUnmounted(() => {