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(() => {
{{ status.running ? 'Proxy Running' : 'Proxy Stopped' }} - v1.4.2 + v1.4.3
diff --git a/desktop/frontend/src/components/HelloWorld.vue b/desktop/frontend/src/components/HelloWorld.vue deleted file mode 100644 index 3ab3df7..0000000 --- a/desktop/frontend/src/components/HelloWorld.vue +++ /dev/null @@ -1,71 +0,0 @@ - - - - - diff --git a/desktop/frontend/src/style.css b/desktop/frontend/src/style.css index 7673c2a..c3ac320 100644 --- a/desktop/frontend/src/style.css +++ b/desktop/frontend/src/style.css @@ -1,5 +1,12 @@ :root { - font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", sans-serif; + font-family: + Inter, + ui-sans-serif, + -apple-system, + BlinkMacSystemFont, + 'SF Pro Text', + 'Segoe UI', + sans-serif; color: #172033; background: #eef2f6; font-synthesis: none; @@ -26,7 +33,7 @@ --radius: 8px; } -:root[data-theme="dark"] { +:root[data-theme='dark'] { color: #edf3ff; background: #111827; --bg: #111827; @@ -68,7 +75,7 @@ body { background: var(--bg); } -:root[data-theme="dark"] body { +:root[data-theme='dark'] body { background: var(--bg); } @@ -106,7 +113,7 @@ button { box-shadow: none; } -:root[data-theme="dark"] .app-shell { +:root[data-theme='dark'] .app-shell { border-color: rgba(148, 163, 184, 0.22); background: rgba(16, 24, 36, 0.78); } @@ -123,7 +130,7 @@ button { box-shadow: inset -1px 0 0 rgba(125, 139, 158, 0.16); } -:root[data-theme="dark"] .sidebar { +:root[data-theme='dark'] .sidebar { border-right-color: rgba(148, 163, 184, 0.14); background: linear-gradient(180deg, rgba(28, 39, 56, 0.7), rgba(18, 27, 40, 0.66)); box-shadow: inset -1px 0 0 rgba(148, 163, 184, 0.12); @@ -145,13 +152,13 @@ button { background: rgba(255, 255, 255, 0.58); } -:root[data-theme="dark"] .brand:hover, -:root[data-theme="dark"] .nav-item:hover, -:root[data-theme="dark"] .sidebar-status { +:root[data-theme='dark'] .brand:hover, +:root[data-theme='dark'] .nav-item:hover, +:root[data-theme='dark'] .sidebar-status { background: rgba(255, 255, 255, 0.08); } -:root[data-theme="dark"] .nav-item { +:root[data-theme='dark'] .nav-item { color: #aebbd0; } @@ -224,7 +231,7 @@ button { box-shadow: inset 0 0 0 1px rgba(37, 99, 235, 0.12); } -:root[data-theme="dark"] .nav-item.active { +:root[data-theme='dark'] .nav-item.active { color: #d8e6ff; background: rgba(67, 111, 190, 0.24); box-shadow: inset 0 0 0 1px rgba(105, 161, 255, 0.18); @@ -285,13 +292,13 @@ button { min-height: 46px; padding: 0 16px; border-bottom: 1px solid rgba(112, 128, 148, 0.18); - background: rgba(255, 255, 255, 0.58); - backdrop-filter: blur(20px) saturate(1.08); + background: #f6f9fd; + backdrop-filter: none; } -:root[data-theme="dark"] .topbar { +:root[data-theme='dark'] .topbar { border-bottom-color: rgba(148, 163, 184, 0.14); - background: rgba(20, 30, 45, 0.66); + background: #162131; } .topbar-spacer { @@ -372,10 +379,10 @@ button { backdrop-filter: blur(18px) saturate(1.12); } -:root[data-theme="dark"] .glass-panel, -:root[data-theme="dark"] .metric, -:root[data-theme="dark"] .table-panel, -:root[data-theme="dark"] .config-panel { +:root[data-theme='dark'] .glass-panel, +:root[data-theme='dark'] .metric, +:root[data-theme='dark'] .table-panel, +:root[data-theme='dark'] .config-panel { border-color: rgba(148, 163, 184, 0.14); background: var(--surface); } @@ -583,6 +590,10 @@ button { gap: 18px; } +.settings-grid { + align-items: start; +} + .grid-3 { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -591,10 +602,11 @@ button { .dashboard-grid { display: grid; + align-items: stretch; grid-template-columns: minmax(0, 1fr) minmax(0, 0.95fr) minmax(300px, 0.95fr); grid-template-areas: - "health models config" - "requests requests config"; + 'health models config' + 'requests requests usage'; gap: 12px; } @@ -604,16 +616,109 @@ button { .area-models { grid-area: models; + min-height: 0; } .area-config { grid-area: config; } +.compact-header { + margin-bottom: 10px; +} + +.compact-header p { + margin-top: 4px; +} + +.area-usage { + grid-area: usage; +} + .area-requests { grid-area: requests; } +.usage-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.usage-grid div { + min-width: 0; + padding: 12px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface-soft); +} + +.usage-grid label { + display: block; + margin-bottom: 6px; + color: var(--muted); + font-size: 11px; + font-weight: 680; +} + +.usage-grid strong { + display: block; + overflow: hidden; + color: var(--text); + font-size: 20px; + line-height: 1.15; + text-overflow: ellipsis; +} + +.usage-foot { + display: flex; + flex-wrap: wrap; + gap: 8px 14px; + margin-top: 12px; + color: var(--muted); + font-size: 12px; +} + +.config-summary { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.config-summary-item { + min-width: 0; + padding: 12px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface-soft); +} + +.config-summary-item label { + display: block; + margin-bottom: 6px; + color: var(--muted); + font-size: 11px; + font-weight: 680; +} + +.config-summary-item strong { + display: block; + overflow: hidden; + color: var(--text); + font-size: 13px; + line-height: 1.3; + text-overflow: ellipsis; + white-space: nowrap; +} + +.config-summary-item.span-2 { + grid-column: 1 / -1; +} + +.compact-link { + margin-top: 10px; +} + .activity-chart { display: grid; grid-template-columns: repeat(36, minmax(3px, 1fr)); @@ -637,13 +742,13 @@ button { white-space: nowrap; } -:root[data-theme="dark"] .activity-chart, -:root[data-theme="dark"] .data-table th, -:root[data-theme="dark"] .field input, -:root[data-theme="dark"] .field textarea, -:root[data-theme="dark"] .search-input, -:root[data-theme="dark"] .detail-panel pre, -:root[data-theme="dark"] .code-block { +:root[data-theme='dark'] .activity-chart, +:root[data-theme='dark'] .data-table th, +:root[data-theme='dark'] .field input, +:root[data-theme='dark'] .field textarea, +:root[data-theme='dark'] .search-input, +:root[data-theme='dark'] .detail-panel pre, +:root[data-theme='dark'] .code-block { color: var(--text); border-color: var(--line); background: rgba(15, 23, 42, 0.74); @@ -719,8 +824,8 @@ button { box-shadow: inset 0 0 0 1px rgba(37, 99, 235, 0.12); } -:root[data-theme="dark"] .model-choice:hover, -:root[data-theme="dark"] .model-choice:focus-visible { +:root[data-theme='dark'] .model-choice:hover, +:root[data-theme='dark'] .model-choice:focus-visible { color: #f3f7ff; border-color: rgba(105, 161, 255, 0.38); background: rgba(72, 118, 214, 0.34); @@ -728,7 +833,48 @@ button { .models-list .model-row, .model-list-row { - grid-template-columns: 22px minmax(220px, 1fr) auto; + grid-template-columns: 22px minmax(220px, 1fr) minmax(260px, auto); +} + +.model-specs { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 6px; +} + +.spec-chip { + display: inline-flex; + min-height: 22px; + align-items: center; + padding: 0 8px; + border: 1px solid var(--line); + border-radius: 7px; + color: var(--muted); + background: var(--surface-soft); + font-size: 11px; + font-weight: 680; + white-space: nowrap; +} + +.spec-chip.strong { + color: #0d6a41; + border-color: rgba(24, 160, 88, 0.18); + background: var(--green-soft); +} + +.spec-chip.muted-chip { + color: #8a5a08; + border-color: rgba(217, 119, 6, 0.16); + background: var(--warn-soft); +} + +:root[data-theme='dark'] .spec-chip.strong { + color: #7ee0aa; +} + +:root[data-theme='dark'] .spec-chip.muted-chip { + color: #ffd27a; } .model-brand-icon { @@ -744,6 +890,8 @@ button { display: flex; min-height: 0; flex-direction: column; + overflow: hidden; + height: 295px; } .model-card-list, @@ -754,7 +902,14 @@ button { } .model-card-list { - max-height: 248px; + flex: 1 1 auto; + max-height: none; + scrollbar-width: none; +} + +.model-card-list::-webkit-scrollbar { + width: 0; + height: 0; } .model-page-list { @@ -870,6 +1025,66 @@ button { border-bottom: 1px solid var(--line); } +.toolbar-header { + margin: 0; + display: flex; + align-items: baseline; + gap: 8px; +} + +.toolbar-count { + font-size: 12px; + font-weight: normal; + white-space: nowrap; +} + +.toolbar-search-wrap { + display: flex; + align-items: center; + gap: 12px; + flex: 1; +} + +.toolbar-search-input { + max-width: 300px; + width: 100%; +} + +.btn-sm-outline { + padding: 4px 10px; + font-size: 12px; + background: transparent; + border: 1px solid var(--line); + border-radius: 6px; + cursor: pointer; + color: var(--text); + display: inline-flex; + align-items: center; + justify-content: center; + transition: all 0.16s ease; +} + +.btn-sm-outline:hover { + background: rgba(0, 0, 0, 0.05); +} + +.btn-sm-outline:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.btn-sm-outline i { + margin-left: 2px; +} + +:root[data-theme='dark'] .btn-sm-outline { + color: #dce8fb; +} + +:root[data-theme='dark'] .btn-sm-outline:hover { + background: rgba(255, 255, 255, 0.1); +} + .table-scroll { flex: 0 0 auto; max-height: none; @@ -893,7 +1108,7 @@ button { .area-requests .table-scroll { min-height: 0; - max-height: 260px; + max-height: 211px; overflow: auto; } @@ -932,10 +1147,12 @@ button { } .data-table tbody tr { - height: var(--request-row-height, 64px); + height: var(--request-row-height, 42px); cursor: pointer; background: rgba(255, 255, 255, 0.34); - transition: background-color 140ms ease, box-shadow 140ms ease; + transition: + background-color 140ms ease, + box-shadow 140ms ease; } .data-table tbody tr:hover { @@ -947,23 +1164,23 @@ button { box-shadow: inset 3px 0 0 var(--blue); } -:root[data-theme="dark"] .data-table { +:root[data-theme='dark'] .data-table { background: rgba(15, 23, 42, 0.8); } -:root[data-theme="dark"] .data-table th { +:root[data-theme='dark'] .data-table th { background: rgba(15, 23, 42, 0.96); } -:root[data-theme="dark"] .data-table tbody tr { +:root[data-theme='dark'] .data-table tbody tr { background: rgba(20, 31, 48, 0.7); } -:root[data-theme="dark"] .data-table tbody tr:hover { +:root[data-theme='dark'] .data-table tbody tr:hover { background: rgba(45, 65, 96, 0.9); } -:root[data-theme="dark"] .data-table tbody tr.selected { +:root[data-theme='dark'] .data-table tbody tr.selected { background: rgba(38, 65, 112, 0.96); box-shadow: inset 3px 0 0 #67a1ff; } @@ -1001,8 +1218,7 @@ button { background: var(--red-soft); } -.link-row, -.table-footer button { +.link-row { display: flex; align-items: center; justify-content: space-between; @@ -1014,24 +1230,10 @@ button { cursor: pointer; } -:root[data-theme="dark"] .link-row, -:root[data-theme="dark"] .table-footer button { +:root[data-theme='dark'] .link-row { color: #dce8fb; } -.table-footer { - display: flex; - align-items: center; - justify-content: space-between; - padding: 10px 14px; - color: var(--muted); - font-size: 12px; -} - -.table-footer button { - width: auto; - gap: 8px; -} .method-chip { color: #334155; @@ -1047,7 +1249,10 @@ button { min-height: 32px; border-radius: 8px; cursor: pointer; - transition: transform 0.16s ease, background 0.16s ease, box-shadow 0.16s ease; + transition: + transform 0.16s ease, + background 0.16s ease, + box-shadow 0.16s ease; } .primary-button { @@ -1132,12 +1337,92 @@ button:disabled { gap: 6px; } +.settings-fieldset { + min-width: 0; + padding: 0; + margin: 0; + border: 0; +} + +.settings-fieldset:disabled { + opacity: 0.56; +} + +.compact-hint { + margin-bottom: 14px; +} + +.compact-form-grid { + row-gap: 14px; +} + .field label { color: var(--muted); font-size: 12px; font-weight: 680; } +.switch-field { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; +} + +.switch-field p { + margin: 4px 0 0; + color: var(--muted); + font-size: 12px; + line-height: 1.45; +} + +.switch { + position: relative; + flex: 0 0 auto; + display: inline-flex; + width: 44px; + height: 26px; +} + +.switch input { + position: absolute; + inset: 0; + opacity: 0; +} + +.switch span { + position: absolute; + inset: 0; + border: 1px solid var(--line-strong); + border-radius: 999px; + background: rgba(148, 163, 184, 0.28); + transition: + background 0.16s ease, + border-color 0.16s ease; +} + +.switch span::after { + content: ''; + position: absolute; + top: 3px; + left: 3px; + width: 18px; + height: 18px; + border-radius: 999px; + background: white; + box-shadow: 0 2px 8px rgba(15, 23, 42, 0.2); + transition: transform 0.16s ease; +} + +.switch input:checked + span { + border-color: rgba(37, 99, 235, 0.72); + background: #2563eb; +} + +.switch input:checked + span::after { + transform: translateX(18px); +} + .field input, .field textarea, .search-input { @@ -1151,6 +1436,14 @@ button:disabled { outline: none; } +.field .switch input { + width: auto; + min-height: 0; + padding: 0; + border: 0; + background: transparent; +} + .field textarea { min-height: 78px; padding-top: 9px; @@ -1230,14 +1523,14 @@ button:disabled { background: var(--blue-soft); } -:root[data-theme="dark"] .custom-select > button { +:root[data-theme='dark'] .custom-select > button { color: var(--text); border-color: var(--line); background: rgba(15, 23, 42, 0.74); } -:root[data-theme="dark"] .select-menu button:hover, -:root[data-theme="dark"] .select-menu button.selected { +:root[data-theme='dark'] .select-menu button:hover, +:root[data-theme='dark'] .select-menu button.selected { color: #dce9ff; background: rgba(72, 118, 214, 0.32); } @@ -1262,7 +1555,7 @@ button:disabled { .hint-box code { color: var(--text); - font-family: "SF Mono", ui-monospace, Menlo, Consolas, monospace; + font-family: 'SF Mono', ui-monospace, Menlo, Consolas, monospace; font-size: 12px; } @@ -1276,7 +1569,7 @@ button:disabled { background: rgba(255, 255, 255, 0.54); } -:root[data-theme="dark"] .detect-card { +:root[data-theme='dark'] .detect-card { background: rgba(15, 23, 42, 0.52); } @@ -1326,7 +1619,7 @@ button:disabled { margin: 0; color: var(--text); overflow-wrap: anywhere; - font-family: "SF Mono", ui-monospace, Menlo, Consolas, monospace; + font-family: 'SF Mono', ui-monospace, Menlo, Consolas, monospace; font-size: 12px; line-height: 1.45; } @@ -1377,7 +1670,7 @@ button:disabled { user-select: text; } -:root[data-theme="dark"] .detail-panel { +:root[data-theme='dark'] .detail-panel { background: rgba(12, 18, 30, 0.96); } @@ -1423,7 +1716,7 @@ button:disabled { -webkit-user-select: text; user-select: text; background: rgba(255, 255, 255, 0.82); - font-family: "SF Mono", ui-monospace, Menlo, Consolas, monospace; + font-family: 'SF Mono', ui-monospace, Menlo, Consolas, monospace; font-size: 12px; line-height: 1.55; overflow-wrap: anywhere; @@ -1525,28 +1818,28 @@ button:disabled { border-color: rgba(44, 111, 231, 0.38); } -:root[data-theme="dark"] .json-key { +:root[data-theme='dark'] .json-key { color: #c4b5fd; } -:root[data-theme="dark"] .json-string { +:root[data-theme='dark'] .json-string { color: #86efac; } -:root[data-theme="dark"] .json-number { +:root[data-theme='dark'] .json-number { color: #93c5fd; } -:root[data-theme="dark"] .json-boolean { +:root[data-theme='dark'] .json-boolean { color: #fca5a5; } -:root[data-theme="dark"] .json-null, -:root[data-theme="dark"] .json-punctuation { +:root[data-theme='dark'] .json-null, +:root[data-theme='dark'] .json-punctuation { color: #9aa8bd; } -:root[data-theme="dark"] .json-summary { +:root[data-theme='dark'] .json-summary { color: #b7c3d6; border-color: rgba(148, 163, 184, 0.24); background: rgba(30, 41, 59, 0.78); @@ -1563,19 +1856,17 @@ button:disabled { height: 0; } -:root[data-theme="dark"] .detail-panel pre, -:root[data-theme="dark"] .code-block, -:root[data-theme="dark"] .json-viewer { +:root[data-theme='dark'] .detail-panel pre, +:root[data-theme='dark'] .code-block, +:root[data-theme='dark'] .json-viewer { color: var(--text); border-color: var(--line); background: rgba(17, 24, 39, 0.94); } - - .log-row { grid-template-columns: 82px 58px minmax(0, 1fr); - font-family: "SF Mono", ui-monospace, Menlo, Consolas, monospace; + font-family: 'SF Mono', ui-monospace, Menlo, Consolas, monospace; font-size: 12px; -webkit-user-select: text; user-select: text; @@ -1636,9 +1927,10 @@ button:disabled { .dashboard-grid { grid-template-areas: - "health models" - "config config" - "requests requests"; + 'health models' + 'config config' + 'usage usage' + 'requests requests'; } .status-strip { @@ -1651,37 +1943,41 @@ button:disabled { border-left: 0; } -.strip-actions { + .strip-actions { grid-column: span 2; } + + .config-summary { + grid-template-columns: 1fr 1fr; + } } -:root[data-theme="dark"] .strip-actions, -:root[data-theme="dark"] .secondary-button, -:root[data-theme="dark"] .ghost-button, -:root[data-theme="dark"] .icon-button, -:root[data-theme="dark"] .segmented, -:root[data-theme="dark"] .segmented button { +:root[data-theme='dark'] .strip-actions, +:root[data-theme='dark'] .secondary-button, +:root[data-theme='dark'] .ghost-button, +:root[data-theme='dark'] .icon-button, +:root[data-theme='dark'] .segmented, +:root[data-theme='dark'] .segmented button { color: var(--text); border-color: var(--line); background: rgba(30, 41, 59, 0.66); } -:root[data-theme="dark"] .strip-actions { +:root[data-theme='dark'] .strip-actions { background: rgba(15, 23, 42, 0.78); } -:root[data-theme="dark"] .strip-actions button { +:root[data-theme='dark'] .strip-actions button { color: #e6eefc; } -:root[data-theme="dark"] .strip-actions button:disabled { +:root[data-theme='dark'] .strip-actions button:disabled { color: #a9b7cc; background: rgba(15, 23, 42, 0.52); opacity: 0.86; } -:root[data-theme="dark"] .segmented button.active { +:root[data-theme='dark'] .segmented button.active { color: #f8fbff; background: rgba(72, 118, 214, 0.42); } @@ -1759,10 +2055,11 @@ button:disabled { .dashboard-grid { grid-template-areas: - "health" - "models" - "config" - "requests"; + 'health' + 'models' + 'config' + 'usage' + 'requests'; } .status-strip { @@ -1775,6 +2072,14 @@ button:disabled { width: 100%; } + .config-summary { + grid-template-columns: 1fr; + } + + .config-summary-item.span-2 { + grid-column: auto; + } + .span-2 { grid-column: auto; } diff --git a/desktop/frontend/src/views/Dashboard.vue b/desktop/frontend/src/views/Dashboard.vue index fcc48db..f4c01b5 100644 --- a/desktop/frontend/src/views/Dashboard.vue +++ b/desktop/frontend/src/views/Dashboard.vue @@ -1,23 +1,14 @@