Release v1.4.3

This commit is contained in:
lutc5
2026-04-30 18:20:04 +08:00
parent a2f777a1a8
commit a02fd51c19
24 changed files with 1909 additions and 1176 deletions

View File

@@ -1,5 +1,22 @@
# Changelog # 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 ## v1.4.2 - 2026-04-30
- Default backend changed to remote API mode for new CLI and desktop configurations. - Default backend changed to remote API mode for new CLI and desktop configurations.

View File

@@ -13,7 +13,7 @@ The proxy now supports two backend modes:
## Current Version ## 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. 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). 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 ## Configuration
Default config file: Default config file:
@@ -348,7 +352,16 @@ Example:
"mode": "agent", "mode": "agent",
"shell_type": "zsh", "shell_type": "zsh",
"session_mode": "auto", "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", "cwd": "/Users/you/project",
"current_file_path": "" "current_file_path": ""
} }

View File

@@ -16,7 +16,7 @@
## 当前版本 ## 当前版本
当前桌面端版本线:`v1.4.2` 当前桌面端版本线:`v1.4.3`
版本更新记录见 [CHANGELOG.md](./CHANGELOG.md)。 版本更新记录见 [CHANGELOG.md](./CHANGELOG.md)。
@@ -408,6 +408,10 @@ export ANTHROPIC_API_KEY="any"
当客户端请求没有携带 `model` 字段时,代理默认使用:`kmodel`(远端模型列表里的 Kimi-K2.6)。 当客户端请求没有携带 `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", "mode": "agent",
"shell_type": "zsh", "shell_type": "zsh",
"session_mode": "auto", "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", "cwd": "/Users/tiancheng/project",
"current_file_path": "" "current_file_path": ""
} }

View File

@@ -22,22 +22,24 @@ import (
) )
type fileConfig struct { type fileConfig struct {
Host string `json:"host"` Host string `json:"host"`
Port int `json:"port"` Port int `json:"port"`
Backend string `json:"backend"` Backend string `json:"backend"`
Transport string `json:"transport"` Transport string `json:"transport"`
Pipe string `json:"pipe"` Pipe string `json:"pipe"`
WebSocketURL string `json:"websocket_url"` WebSocketURL string `json:"websocket_url"`
RemoteBaseURL string `json:"remote_base_url"` RemoteBaseURL string `json:"remote_base_url"`
RemoteAuthFile string `json:"remote_auth_file"` RemoteAuthFile string `json:"remote_auth_file"`
RemoteVersion string `json:"remote_version"` RemoteVersion string `json:"remote_version"`
Cwd string `json:"cwd"` Cwd string `json:"cwd"`
CurrentFilePath string `json:"current_file_path"` CurrentFilePath string `json:"current_file_path"`
Mode string `json:"mode"` Mode string `json:"mode"`
Model string `json:"model"` Model string `json:"model"`
ShellType string `json:"shell_type"` ShellType string `json:"shell_type"`
SessionMode string `json:"session_mode"` SessionMode string `json:"session_mode"`
TimeoutSeconds int `json:"timeout"` TimeoutSeconds int `json:"timeout"`
RemoteFallbackEnabled *bool `json:"remote_fallback_enabled"`
RemoteFallbackModels []string `json:"remote_fallback_models"`
} }
func main() { func main() {
@@ -89,16 +91,18 @@ func main() {
func loadConfig() (service.Config, string) { func loadConfig() (service.Config, string) {
cfg := service.Config{ cfg := service.Config{
Host: "127.0.0.1", Host: "127.0.0.1",
Port: 8095, Port: 8095,
Backend: service.BackendRemote, Backend: service.BackendRemote,
Transport: lingmaipc.TransportAuto, Transport: lingmaipc.TransportAuto,
Cwd: currentDir(), Cwd: currentDir(),
Mode: "agent", Mode: "agent",
Model: "kmodel", Model: "kmodel",
ShellType: defaultShellType(), ShellType: defaultShellType(),
SessionMode: service.SessionModeAuto, SessionMode: service.SessionModeAuto,
Timeout: 120 * time.Second, Timeout: 300 * time.Second,
RemoteFallbackEnabled: true,
RemoteFallbackModels: service.DefaultRemoteFallbackModels(),
} }
configPath, configLoaded := resolveConfigPath() 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") 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") 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") 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") 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") config := flag.String("config", valueOr(configPath, filepath.Join(currentDir(), "lingma-ipc-proxy.json")), "Path to JSON config file")
flag.Parse() flag.Parse()
@@ -151,6 +157,8 @@ func loadConfig() (service.Config, string) {
cfg.ShellType = strings.TrimSpace(*shellType) cfg.ShellType = strings.TrimSpace(*shellType)
cfg.SessionMode = parsedSessionMode cfg.SessionMode = parsedSessionMode
cfg.Timeout = time.Duration(*timeoutSeconds) * time.Second cfg.Timeout = time.Duration(*timeoutSeconds) * time.Second
cfg.RemoteFallbackEnabled = *remoteFallbackEnabled
cfg.RemoteFallbackModels = splitCSV(*remoteFallbackModels)
if configLoaded { if configLoaded {
configPath = finalConfigPath configPath = finalConfigPath
@@ -236,6 +244,12 @@ func overlayFileConfig(dst *service.Config, src fileConfig) {
if src.TimeoutSeconds > 0 { if src.TimeoutSeconds > 0 {
dst.Timeout = time.Duration(src.TimeoutSeconds) * time.Second 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) { func overlayEnvConfig(dst *service.Config) {
@@ -287,6 +301,12 @@ func overlayEnvConfig(dst *service.Config) {
if value := envInt("LINGMA_PROXY_TIMEOUT_SECONDS", 0); value > 0 { if value := envInt("LINGMA_PROXY_TIMEOUT_SECONDS", 0); value > 0 {
dst.Timeout = time.Duration(value) * time.Second 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 { func parseSessionMode(value string) service.SessionMode {
@@ -349,6 +369,36 @@ func envInt(key string, fallback int) int {
return fallback 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 { func currentDir() string {
if wd, err := os.Getwd(); err == nil { if wd, err := os.Getwd(); err == nil {
return wd return wd

View File

@@ -6,7 +6,16 @@
"mode": "chat", "mode": "chat",
"model": "kmodel", "model": "kmodel",
"session_mode": "auto", "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", "cwd": "C:/Workspace/Personal/lingma-ipc-proxy",
"shell_type": "powershell", "shell_type": "powershell",
"current_file_path": "", "current_file_path": "",

View File

@@ -25,15 +25,35 @@ import (
// App struct // App struct
// RequestRecord stores a single HTTP request summary // RequestRecord stores a single HTTP request summary
type RequestRecord struct { type RequestRecord struct {
Time string `json:"time"` Time string `json:"time"`
Method string `json:"method"` Method string `json:"method"`
Path string `json:"path"` Path string `json:"path"`
Model string `json:"model,omitempty"` Model string `json:"model,omitempty"`
StatusCode int `json:"statusCode"` StatusCode int `json:"statusCode"`
Duration string `json:"duration"` Duration string `json:"duration"`
Size string `json:"size,omitempty"` Size string `json:"size,omitempty"`
ReqBody string `json:"reqBody,omitempty"` InputTokens int `json:"inputTokens,omitempty"`
RespBody string `json:"respBody,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 { type App struct {
@@ -49,6 +69,8 @@ type App struct {
quitHint time.Time quitHint time.Time
models []ModelInfo models []ModelInfo
requests []RequestRecord requests []RequestRecord
logs []AppLog
stats TokenStats
} }
// ModelInfo represents a model returned by /v1/models // ModelInfo represents a model returned by /v1/models
@@ -96,6 +118,9 @@ func NewApp() *App {
func (a *App) startup(ctx context.Context) { func (a *App) startup(ctx context.Context) {
a.ctx = ctx a.ctx = ctx
a.cfg = defaultConfig() 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 // Auto-save default config on first run so users can find/edit it later
if err := a.saveConfig(a.cfg); err != nil { if err := a.saveConfig(a.cfg); err != nil {
@@ -208,10 +233,19 @@ func (a *App) forceQuit() {
} }
func (a *App) emitLog(level string, message string) { func (a *App) emitLog(level string, message string) {
runtime.EventsEmit(a.ctx, "log", map[string]string{ entry := AppLog{
"level": level, Time: time.Now().Format("15:04:05"),
"message": message, 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 // GetStatus returns the current proxy status
@@ -331,22 +365,24 @@ func (a *App) saveConfig(cfg service.Config) error {
timeoutSec := int(cfg.Timeout.Seconds()) timeoutSec := int(cfg.Timeout.Seconds())
fileCfg := map[string]any{ fileCfg := map[string]any{
"host": cfg.Host, "host": cfg.Host,
"port": cfg.Port, "port": cfg.Port,
"backend": string(cfg.Backend), "backend": string(cfg.Backend),
"transport": string(cfg.Transport), "transport": string(cfg.Transport),
"pipe": cfg.Pipe, "pipe": cfg.Pipe,
"websocket_url": cfg.WebSocketURL, "websocket_url": cfg.WebSocketURL,
"remote_base_url": cfg.RemoteBaseURL, "remote_base_url": cfg.RemoteBaseURL,
"remote_auth_file": cfg.RemoteAuthFile, "remote_auth_file": cfg.RemoteAuthFile,
"remote_version": cfg.RemoteVersion, "remote_version": cfg.RemoteVersion,
"cwd": cfg.Cwd, "cwd": cfg.Cwd,
"current_file_path": cfg.CurrentFilePath, "current_file_path": cfg.CurrentFilePath,
"mode": cfg.Mode, "mode": cfg.Mode,
"model": cfg.Model, "model": cfg.Model,
"shell_type": cfg.ShellType, "shell_type": cfg.ShellType,
"session_mode": string(cfg.SessionMode), "session_mode": string(cfg.SessionMode),
"timeout": timeoutSec, "timeout": timeoutSec,
"remote_fallback_enabled": cfg.RemoteFallbackEnabled,
"remote_fallback_models": cfg.RemoteFallbackModels,
} }
data, err := json.MarshalIndent(fileCfg, "", " ") 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 // StartProxy starts the lingma-ipc-proxy HTTP server
func (a *App) StartProxy() error { func (a *App) StartProxy() error {
a.mu.Lock() a.mu.Lock()
defer a.mu.Unlock()
if a.running { if a.running {
a.mu.Unlock()
return fmt.Errorf("proxy already running") return fmt.Errorf("proxy already running")
} }
addr := fmt.Sprintf("%s:%d", a.cfg.Host, a.cfg.Port) 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) warmupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
if err := svc.Warmup(warmupCtx); err != nil { if err := svc.Warmup(warmupCtx); err != nil {
@@ -382,23 +420,32 @@ func (a *App) StartProxy() error {
server := httpapi.NewServer(addr, svc) server := httpapi.NewServer(addr, svc)
server.OnRequest = func(method, path string, statusCode int, duration time.Duration, reqBody, respBody string) { server.OnRequest = func(method, path string, statusCode int, duration time.Duration, reqBody, respBody string) {
a.mu.Lock() inputTokens, outputTokens := extractTokenUsage(respBody)
a.requests = append(a.requests, RequestRecord{ model := extractRequestModel(reqBody)
Time: time.Now().Format("15:04:05"), record := RequestRecord{
Method: method, Time: time.Now().Format("15:04:05"),
Path: path, Method: method,
Model: extractRequestModel(reqBody), Path: path,
StatusCode: statusCode, Model: model,
Duration: duration.Round(time.Millisecond).String(), StatusCode: statusCode,
Size: formatPayloadSize(len(reqBody) + len(respBody)), Duration: duration.Round(time.Millisecond).String(),
ReqBody: reqBody, Size: formatPayloadSize(len(reqBody) + len(respBody)),
RespBody: respBody, InputTokens: inputTokens,
}) OutputTokens: outputTokens,
if len(a.requests) > 100 { TotalTokens: inputTokens + outputTokens,
a.requests = a.requests[len(a.requests)-100:] 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() a.mu.Unlock()
runtime.EventsEmit(a.ctx, "requests:updated", a.GetRequests()) 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 // 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.server = server
a.addr = addr a.addr = addr
a.running = true a.running = true
a.startedAt = time.Now() a.startedAt = time.Now()
a.mu.Unlock()
msg := fmt.Sprintf("Proxy started on http://%s", addr) msg := fmt.Sprintf("Proxy started on http://%s", addr)
runtime.LogInfof(a.ctx, msg) runtime.LogInfof(a.ctx, msg)
@@ -435,8 +488,24 @@ func (a *App) StartProxy() error {
return nil return nil
} }
// ClearLogs is a no-op backend helper (logs are kept in frontend memory) func (a *App) GetLogs() []AppLog {
func (a *App) ClearLogs() {} 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 // StopProxy stops the proxy server
func (a *App) StopProxy() error { func (a *App) StopProxy() error {
@@ -493,10 +562,21 @@ func (a *App) GetRequests() []RequestRecord {
func (a *App) ClearRequests() { func (a *App) ClearRequests() {
a.mu.Lock() a.mu.Lock()
a.requests = nil a.requests = nil
a.saveAppStateLocked()
a.mu.Unlock() a.mu.Unlock()
a.emitLog("info", "Request history cleared") 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. // RefreshModels probes the running proxy for the latest model list.
func (a *App) RefreshModels() ([]ModelInfo, error) { func (a *App) RefreshModels() ([]ModelInfo, error) {
a.mu.RLock() a.mu.RLock()
@@ -614,18 +694,221 @@ func formatPayloadSize(bytes int) string {
return fmt.Sprintf("%d B", bytes) 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 { func defaultConfig() service.Config {
cfg := service.Config{ cfg := service.Config{
Host: "127.0.0.1", Host: "127.0.0.1",
Port: 8095, Port: 8095,
Backend: service.BackendRemote, Backend: service.BackendRemote,
Transport: lingmaipc.TransportAuto, Transport: lingmaipc.TransportAuto,
Cwd: defaultCwd(), Cwd: defaultCwd(),
Mode: "agent", Mode: "agent",
Model: "kmodel", Model: "kmodel",
ShellType: defaultShellType(), ShellType: defaultShellType(),
SessionMode: service.SessionModeAuto, SessionMode: service.SessionModeAuto,
Timeout: 120 * time.Second, Timeout: 300 * time.Second,
RemoteFallbackEnabled: true,
RemoteFallbackModels: service.DefaultRemoteFallbackModels(),
} }
// Try to load config file from multiple locations // 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 info, err := os.Stat(configPath); err == nil && !info.IsDir() {
if data, err := os.ReadFile(configPath); err == nil { if data, err := os.ReadFile(configPath); err == nil {
var fileCfg struct { var fileCfg struct {
Host string `json:"host"` Host string `json:"host"`
Port int `json:"port"` Port int `json:"port"`
Backend string `json:"backend"` Backend string `json:"backend"`
Transport string `json:"transport"` Transport string `json:"transport"`
Pipe string `json:"pipe"` Pipe string `json:"pipe"`
WebSocketURL string `json:"websocket_url"` WebSocketURL string `json:"websocket_url"`
RemoteBaseURL string `json:"remote_base_url"` RemoteBaseURL string `json:"remote_base_url"`
RemoteAuthFile string `json:"remote_auth_file"` RemoteAuthFile string `json:"remote_auth_file"`
RemoteVersion string `json:"remote_version"` RemoteVersion string `json:"remote_version"`
Cwd string `json:"cwd"` Cwd string `json:"cwd"`
CurrentFilePath string `json:"current_file_path"` CurrentFilePath string `json:"current_file_path"`
Mode string `json:"mode"` Mode string `json:"mode"`
Model string `json:"model"` Model string `json:"model"`
ShellType string `json:"shell_type"` ShellType string `json:"shell_type"`
SessionMode string `json:"session_mode"` SessionMode string `json:"session_mode"`
TimeoutSeconds int `json:"timeout"` 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 err := json.Unmarshal(data, &fileCfg); err == nil {
if fileCfg.Host != "" { if fileCfg.Host != "" {
@@ -702,6 +987,12 @@ func defaultConfig() service.Config {
if fileCfg.TimeoutSeconds > 0 { if fileCfg.TimeoutSeconds > 0 {
cfg.Timeout = time.Duration(fileCfg.TimeoutSeconds) * time.Second 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 break // loaded successfully
} }
@@ -732,6 +1023,20 @@ func maskIdentifier(value string) string {
return string(runes[:4]) + "..." + string(runes[len(runes)-4:]) 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 { func configSearchPaths() []string {
var paths []string var paths []string
// 1. Executable directory (for dev / portable mode) // 1. Executable directory (for dev / portable mode)

View File

@@ -6,7 +6,7 @@ import Models from './views/Models.vue'
import Requests from './views/Requests.vue' import Requests from './views/Requests.vue'
import Settings from './views/Settings.vue' import Settings from './views/Settings.vue'
import { EventsOff, EventsOn } from '../wailsjs/runtime' 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' import lingmaIcon from './assets/images/lingma-icon.png'
const currentTab = ref('dashboard') const currentTab = ref('dashboard')
@@ -42,8 +42,13 @@ function showToast(message) {
}, 2200) }, 2200)
} }
function clearLocalLogs() { async function clearLocalLogs() {
logs.value = [] try {
await ClearLogs()
logs.value = []
} catch (e) {
logs.value = []
}
} }
function setStatus(nextStatus) { function setStatus(nextStatus) {
@@ -158,14 +163,25 @@ onMounted(() => {
systemThemeQuery?.addEventListener?.('change', applyTheme) systemThemeQuery?.addEventListener?.('change', applyTheme)
applyTheme() applyTheme()
refreshStatus() refreshStatus()
GetLogs().then((items) => {
logs.value = Array.isArray(items) ? items : []
}).catch(() => {})
safeEventsOn('models:updated', (data) => { safeEventsOn('models:updated', (data) => {
status.value.models = Array.isArray(data) ? data.length : status.value.models status.value.models = Array.isArray(data) ? data.length : status.value.models
addLog('info', `模型列表已更新:${status.value.models} 个模型`) addLog('info', `模型列表已更新:${status.value.models} 个模型`)
}) })
safeEventsOn('log', (data) => { 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() refreshStatus()
}) })
safeEventsOn('logs:updated', (data) => {
logs.value = Array.isArray(data) ? data : []
})
safeEventsOn('quit:confirm', (message) => { safeEventsOn('quit:confirm', (message) => {
showToast(message || '再按一次退出快捷键将停止代理并退出应用') showToast(message || '再按一次退出快捷键将停止代理并退出应用')
}) })
@@ -183,6 +199,7 @@ onUnmounted(() => {
systemThemeQuery?.removeEventListener?.('change', applyTheme) systemThemeQuery?.removeEventListener?.('change', applyTheme)
safeEventsOff('models:updated') safeEventsOff('models:updated')
safeEventsOff('log') safeEventsOff('log')
safeEventsOff('logs:updated')
safeEventsOff('quit:confirm') safeEventsOff('quit:confirm')
safeEventsOff('status:updated') safeEventsOff('status:updated')
safeEventsOff('requests:updated') safeEventsOff('requests:updated')
@@ -222,7 +239,7 @@ onUnmounted(() => {
<span class="status-dot" :class="{ running: status.running }"></span> <span class="status-dot" :class="{ running: status.running }"></span>
<div> <div>
<strong>{{ status.running ? 'Proxy Running' : 'Proxy Stopped' }}</strong> <strong>{{ status.running ? 'Proxy Running' : 'Proxy Stopped' }}</strong>
<small>v1.4.2</small> <small>v1.4.3</small>
</div> </div>
</div> </div>
</aside> </aside>

View File

@@ -1,71 +0,0 @@
<script lang="ts" setup>
import {reactive} from 'vue'
import {Greet} from '../../wailsjs/go/main/App'
const data = reactive({
name: "",
resultText: "Please enter your name below 👇",
})
function greet() {
Greet(data.name).then(result => {
data.resultText = result
})
}
</script>
<template>
<main>
<div id="result" class="result">{{ data.resultText }}</div>
<div id="input" class="input-box">
<input id="name" v-model="data.name" autocomplete="off" class="input" type="text"/>
<button class="btn" @click="greet">Greet</button>
</div>
</main>
</template>
<style scoped>
.result {
height: 20px;
line-height: 20px;
margin: 1.5rem auto;
}
.input-box .btn {
width: 60px;
height: 30px;
line-height: 30px;
border-radius: 3px;
border: none;
margin: 0 0 0 20px;
padding: 0 8px;
cursor: pointer;
}
.input-box .btn:hover {
background-image: linear-gradient(to top, #cfd9df 0%, #e2ebf0 100%);
color: #333333;
}
.input-box .input {
border: none;
border-radius: 3px;
outline: none;
height: 30px;
line-height: 30px;
padding: 0 10px;
background-color: rgba(240, 240, 240, 1);
-webkit-font-smoothing: antialiased;
}
.input-box .input:hover {
border: none;
background-color: rgba(255, 255, 255, 1);
}
.input-box .input:focus {
border: none;
background-color: rgba(255, 255, 255, 1);
}
</style>

View File

@@ -1,5 +1,12 @@
:root { :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; color: #172033;
background: #eef2f6; background: #eef2f6;
font-synthesis: none; font-synthesis: none;
@@ -26,7 +33,7 @@
--radius: 8px; --radius: 8px;
} }
:root[data-theme="dark"] { :root[data-theme='dark'] {
color: #edf3ff; color: #edf3ff;
background: #111827; background: #111827;
--bg: #111827; --bg: #111827;
@@ -68,7 +75,7 @@ body {
background: var(--bg); background: var(--bg);
} }
:root[data-theme="dark"] body { :root[data-theme='dark'] body {
background: var(--bg); background: var(--bg);
} }
@@ -106,7 +113,7 @@ button {
box-shadow: none; box-shadow: none;
} }
:root[data-theme="dark"] .app-shell { :root[data-theme='dark'] .app-shell {
border-color: rgba(148, 163, 184, 0.22); border-color: rgba(148, 163, 184, 0.22);
background: rgba(16, 24, 36, 0.78); background: rgba(16, 24, 36, 0.78);
} }
@@ -123,7 +130,7 @@ button {
box-shadow: inset -1px 0 0 rgba(125, 139, 158, 0.16); 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); 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)); 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); box-shadow: inset -1px 0 0 rgba(148, 163, 184, 0.12);
@@ -145,13 +152,13 @@ button {
background: rgba(255, 255, 255, 0.58); background: rgba(255, 255, 255, 0.58);
} }
:root[data-theme="dark"] .brand:hover, :root[data-theme='dark'] .brand:hover,
:root[data-theme="dark"] .nav-item:hover, :root[data-theme='dark'] .nav-item:hover,
:root[data-theme="dark"] .sidebar-status { :root[data-theme='dark'] .sidebar-status {
background: rgba(255, 255, 255, 0.08); background: rgba(255, 255, 255, 0.08);
} }
:root[data-theme="dark"] .nav-item { :root[data-theme='dark'] .nav-item {
color: #aebbd0; color: #aebbd0;
} }
@@ -224,7 +231,7 @@ button {
box-shadow: inset 0 0 0 1px rgba(37, 99, 235, 0.12); 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; color: #d8e6ff;
background: rgba(67, 111, 190, 0.24); background: rgba(67, 111, 190, 0.24);
box-shadow: inset 0 0 0 1px rgba(105, 161, 255, 0.18); box-shadow: inset 0 0 0 1px rgba(105, 161, 255, 0.18);
@@ -285,13 +292,13 @@ button {
min-height: 46px; min-height: 46px;
padding: 0 16px; padding: 0 16px;
border-bottom: 1px solid rgba(112, 128, 148, 0.18); border-bottom: 1px solid rgba(112, 128, 148, 0.18);
background: rgba(255, 255, 255, 0.58); background: #f6f9fd;
backdrop-filter: blur(20px) saturate(1.08); backdrop-filter: none;
} }
:root[data-theme="dark"] .topbar { :root[data-theme='dark'] .topbar {
border-bottom-color: rgba(148, 163, 184, 0.14); border-bottom-color: rgba(148, 163, 184, 0.14);
background: rgba(20, 30, 45, 0.66); background: #162131;
} }
.topbar-spacer { .topbar-spacer {
@@ -372,10 +379,10 @@ button {
backdrop-filter: blur(18px) saturate(1.12); backdrop-filter: blur(18px) saturate(1.12);
} }
:root[data-theme="dark"] .glass-panel, :root[data-theme='dark'] .glass-panel,
:root[data-theme="dark"] .metric, :root[data-theme='dark'] .metric,
:root[data-theme="dark"] .table-panel, :root[data-theme='dark'] .table-panel,
:root[data-theme="dark"] .config-panel { :root[data-theme='dark'] .config-panel {
border-color: rgba(148, 163, 184, 0.14); border-color: rgba(148, 163, 184, 0.14);
background: var(--surface); background: var(--surface);
} }
@@ -583,6 +590,10 @@ button {
gap: 18px; gap: 18px;
} }
.settings-grid {
align-items: start;
}
.grid-3 { .grid-3 {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -591,10 +602,11 @@ button {
.dashboard-grid { .dashboard-grid {
display: grid; display: grid;
align-items: stretch;
grid-template-columns: minmax(0, 1fr) minmax(0, 0.95fr) minmax(300px, 0.95fr); grid-template-columns: minmax(0, 1fr) minmax(0, 0.95fr) minmax(300px, 0.95fr);
grid-template-areas: grid-template-areas:
"health models config" 'health models config'
"requests requests config"; 'requests requests usage';
gap: 12px; gap: 12px;
} }
@@ -604,16 +616,109 @@ button {
.area-models { .area-models {
grid-area: models; grid-area: models;
min-height: 0;
} }
.area-config { .area-config {
grid-area: config; grid-area: config;
} }
.compact-header {
margin-bottom: 10px;
}
.compact-header p {
margin-top: 4px;
}
.area-usage {
grid-area: usage;
}
.area-requests { .area-requests {
grid-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 { .activity-chart {
display: grid; display: grid;
grid-template-columns: repeat(36, minmax(3px, 1fr)); grid-template-columns: repeat(36, minmax(3px, 1fr));
@@ -637,13 +742,13 @@ button {
white-space: nowrap; white-space: nowrap;
} }
:root[data-theme="dark"] .activity-chart, :root[data-theme='dark'] .activity-chart,
:root[data-theme="dark"] .data-table th, :root[data-theme='dark'] .data-table th,
:root[data-theme="dark"] .field input, :root[data-theme='dark'] .field input,
:root[data-theme="dark"] .field textarea, :root[data-theme='dark'] .field textarea,
:root[data-theme="dark"] .search-input, :root[data-theme='dark'] .search-input,
:root[data-theme="dark"] .detail-panel pre, :root[data-theme='dark'] .detail-panel pre,
:root[data-theme="dark"] .code-block { :root[data-theme='dark'] .code-block {
color: var(--text); color: var(--text);
border-color: var(--line); border-color: var(--line);
background: rgba(15, 23, 42, 0.74); 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); 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:hover,
:root[data-theme="dark"] .model-choice:focus-visible { :root[data-theme='dark'] .model-choice:focus-visible {
color: #f3f7ff; color: #f3f7ff;
border-color: rgba(105, 161, 255, 0.38); border-color: rgba(105, 161, 255, 0.38);
background: rgba(72, 118, 214, 0.34); background: rgba(72, 118, 214, 0.34);
@@ -728,7 +833,48 @@ button {
.models-list .model-row, .models-list .model-row,
.model-list-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 { .model-brand-icon {
@@ -744,6 +890,8 @@ button {
display: flex; display: flex;
min-height: 0; min-height: 0;
flex-direction: column; flex-direction: column;
overflow: hidden;
height: 295px;
} }
.model-card-list, .model-card-list,
@@ -754,7 +902,14 @@ button {
} }
.model-card-list { .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 { .model-page-list {
@@ -870,6 +1025,66 @@ button {
border-bottom: 1px solid var(--line); 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 { .table-scroll {
flex: 0 0 auto; flex: 0 0 auto;
max-height: none; max-height: none;
@@ -893,7 +1108,7 @@ button {
.area-requests .table-scroll { .area-requests .table-scroll {
min-height: 0; min-height: 0;
max-height: 260px; max-height: 211px;
overflow: auto; overflow: auto;
} }
@@ -932,10 +1147,12 @@ button {
} }
.data-table tbody tr { .data-table tbody tr {
height: var(--request-row-height, 64px); height: var(--request-row-height, 42px);
cursor: pointer; cursor: pointer;
background: rgba(255, 255, 255, 0.34); 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 { .data-table tbody tr:hover {
@@ -947,23 +1164,23 @@ button {
box-shadow: inset 3px 0 0 var(--blue); 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); 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); 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); 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); 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); background: rgba(38, 65, 112, 0.96);
box-shadow: inset 3px 0 0 #67a1ff; box-shadow: inset 3px 0 0 #67a1ff;
} }
@@ -1001,8 +1218,7 @@ button {
background: var(--red-soft); background: var(--red-soft);
} }
.link-row, .link-row {
.table-footer button {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
@@ -1014,24 +1230,10 @@ button {
cursor: pointer; cursor: pointer;
} }
:root[data-theme="dark"] .link-row, :root[data-theme='dark'] .link-row {
:root[data-theme="dark"] .table-footer button {
color: #dce8fb; 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 { .method-chip {
color: #334155; color: #334155;
@@ -1047,7 +1249,10 @@ button {
min-height: 32px; min-height: 32px;
border-radius: 8px; border-radius: 8px;
cursor: pointer; 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 { .primary-button {
@@ -1132,12 +1337,92 @@ button:disabled {
gap: 6px; 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 { .field label {
color: var(--muted); color: var(--muted);
font-size: 12px; font-size: 12px;
font-weight: 680; 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 input,
.field textarea, .field textarea,
.search-input { .search-input {
@@ -1151,6 +1436,14 @@ button:disabled {
outline: none; outline: none;
} }
.field .switch input {
width: auto;
min-height: 0;
padding: 0;
border: 0;
background: transparent;
}
.field textarea { .field textarea {
min-height: 78px; min-height: 78px;
padding-top: 9px; padding-top: 9px;
@@ -1230,14 +1523,14 @@ button:disabled {
background: var(--blue-soft); background: var(--blue-soft);
} }
:root[data-theme="dark"] .custom-select > button { :root[data-theme='dark'] .custom-select > button {
color: var(--text); color: var(--text);
border-color: var(--line); border-color: var(--line);
background: rgba(15, 23, 42, 0.74); background: rgba(15, 23, 42, 0.74);
} }
:root[data-theme="dark"] .select-menu button:hover, :root[data-theme='dark'] .select-menu button:hover,
:root[data-theme="dark"] .select-menu button.selected { :root[data-theme='dark'] .select-menu button.selected {
color: #dce9ff; color: #dce9ff;
background: rgba(72, 118, 214, 0.32); background: rgba(72, 118, 214, 0.32);
} }
@@ -1262,7 +1555,7 @@ button:disabled {
.hint-box code { .hint-box code {
color: var(--text); color: var(--text);
font-family: "SF Mono", ui-monospace, Menlo, Consolas, monospace; font-family: 'SF Mono', ui-monospace, Menlo, Consolas, monospace;
font-size: 12px; font-size: 12px;
} }
@@ -1276,7 +1569,7 @@ button:disabled {
background: rgba(255, 255, 255, 0.54); 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); background: rgba(15, 23, 42, 0.52);
} }
@@ -1326,7 +1619,7 @@ button:disabled {
margin: 0; margin: 0;
color: var(--text); color: var(--text);
overflow-wrap: anywhere; overflow-wrap: anywhere;
font-family: "SF Mono", ui-monospace, Menlo, Consolas, monospace; font-family: 'SF Mono', ui-monospace, Menlo, Consolas, monospace;
font-size: 12px; font-size: 12px;
line-height: 1.45; line-height: 1.45;
} }
@@ -1377,7 +1670,7 @@ button:disabled {
user-select: text; user-select: text;
} }
:root[data-theme="dark"] .detail-panel { :root[data-theme='dark'] .detail-panel {
background: rgba(12, 18, 30, 0.96); background: rgba(12, 18, 30, 0.96);
} }
@@ -1423,7 +1716,7 @@ button:disabled {
-webkit-user-select: text; -webkit-user-select: text;
user-select: text; user-select: text;
background: rgba(255, 255, 255, 0.82); 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; font-size: 12px;
line-height: 1.55; line-height: 1.55;
overflow-wrap: anywhere; overflow-wrap: anywhere;
@@ -1525,28 +1818,28 @@ button:disabled {
border-color: rgba(44, 111, 231, 0.38); border-color: rgba(44, 111, 231, 0.38);
} }
:root[data-theme="dark"] .json-key { :root[data-theme='dark'] .json-key {
color: #c4b5fd; color: #c4b5fd;
} }
:root[data-theme="dark"] .json-string { :root[data-theme='dark'] .json-string {
color: #86efac; color: #86efac;
} }
:root[data-theme="dark"] .json-number { :root[data-theme='dark'] .json-number {
color: #93c5fd; color: #93c5fd;
} }
:root[data-theme="dark"] .json-boolean { :root[data-theme='dark'] .json-boolean {
color: #fca5a5; color: #fca5a5;
} }
:root[data-theme="dark"] .json-null, :root[data-theme='dark'] .json-null,
:root[data-theme="dark"] .json-punctuation { :root[data-theme='dark'] .json-punctuation {
color: #9aa8bd; color: #9aa8bd;
} }
:root[data-theme="dark"] .json-summary { :root[data-theme='dark'] .json-summary {
color: #b7c3d6; color: #b7c3d6;
border-color: rgba(148, 163, 184, 0.24); border-color: rgba(148, 163, 184, 0.24);
background: rgba(30, 41, 59, 0.78); background: rgba(30, 41, 59, 0.78);
@@ -1563,19 +1856,17 @@ button:disabled {
height: 0; height: 0;
} }
:root[data-theme="dark"] .detail-panel pre, :root[data-theme='dark'] .detail-panel pre,
:root[data-theme="dark"] .code-block, :root[data-theme='dark'] .code-block,
:root[data-theme="dark"] .json-viewer { :root[data-theme='dark'] .json-viewer {
color: var(--text); color: var(--text);
border-color: var(--line); border-color: var(--line);
background: rgba(17, 24, 39, 0.94); background: rgba(17, 24, 39, 0.94);
} }
.log-row { .log-row {
grid-template-columns: 82px 58px minmax(0, 1fr); 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; font-size: 12px;
-webkit-user-select: text; -webkit-user-select: text;
user-select: text; user-select: text;
@@ -1636,9 +1927,10 @@ button:disabled {
.dashboard-grid { .dashboard-grid {
grid-template-areas: grid-template-areas:
"health models" 'health models'
"config config" 'config config'
"requests requests"; 'usage usage'
'requests requests';
} }
.status-strip { .status-strip {
@@ -1651,37 +1943,41 @@ button:disabled {
border-left: 0; border-left: 0;
} }
.strip-actions { .strip-actions {
grid-column: span 2; grid-column: span 2;
} }
.config-summary {
grid-template-columns: 1fr 1fr;
}
} }
:root[data-theme="dark"] .strip-actions, :root[data-theme='dark'] .strip-actions,
:root[data-theme="dark"] .secondary-button, :root[data-theme='dark'] .secondary-button,
:root[data-theme="dark"] .ghost-button, :root[data-theme='dark'] .ghost-button,
:root[data-theme="dark"] .icon-button, :root[data-theme='dark'] .icon-button,
:root[data-theme="dark"] .segmented, :root[data-theme='dark'] .segmented,
:root[data-theme="dark"] .segmented button { :root[data-theme='dark'] .segmented button {
color: var(--text); color: var(--text);
border-color: var(--line); border-color: var(--line);
background: rgba(30, 41, 59, 0.66); 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); background: rgba(15, 23, 42, 0.78);
} }
:root[data-theme="dark"] .strip-actions button { :root[data-theme='dark'] .strip-actions button {
color: #e6eefc; color: #e6eefc;
} }
:root[data-theme="dark"] .strip-actions button:disabled { :root[data-theme='dark'] .strip-actions button:disabled {
color: #a9b7cc; color: #a9b7cc;
background: rgba(15, 23, 42, 0.52); background: rgba(15, 23, 42, 0.52);
opacity: 0.86; opacity: 0.86;
} }
:root[data-theme="dark"] .segmented button.active { :root[data-theme='dark'] .segmented button.active {
color: #f8fbff; color: #f8fbff;
background: rgba(72, 118, 214, 0.42); background: rgba(72, 118, 214, 0.42);
} }
@@ -1759,10 +2055,11 @@ button:disabled {
.dashboard-grid { .dashboard-grid {
grid-template-areas: grid-template-areas:
"health" 'health'
"models" 'models'
"config" 'config'
"requests"; 'usage'
'requests';
} }
.status-strip { .status-strip {
@@ -1775,6 +2072,14 @@ button:disabled {
width: 100%; width: 100%;
} }
.config-summary {
grid-template-columns: 1fr;
}
.config-summary-item.span-2 {
grid-column: auto;
}
.span-2 { .span-2 {
grid-column: auto; grid-column: auto;
} }

View File

@@ -1,23 +1,14 @@
<script setup> <script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
import { import { GetModels, GetConfig, GetRequests, GetStatus, GetTokenStats, QuitApp, RefreshModels, StartProxy, StopProxy } from '../../wailsjs/go/main/App.js'
GetModels,
GetConfig,
GetRequests,
GetStatus,
QuitApp,
RefreshModels,
StartProxy,
StopProxy,
} from '../../wailsjs/go/main/App.js'
import { ClipboardSetText } from '../../wailsjs/runtime' import { ClipboardSetText } from '../../wailsjs/runtime'
import { modelIcon } from '../modelIcons' import { modelIcon } from '../modelIcons'
const props = defineProps({ const props = defineProps({
shellStatus: { shellStatus: {
type: Object, type: Object,
default: () => ({ running: false, addr: '', models: 0 }), default: () => ({ running: false, addr: '', models: 0 })
}, }
}) })
const emit = defineEmits(['log', 'status', 'notice', 'open-settings', 'open-requests', 'open-models']) const emit = defineEmits(['log', 'status', 'notice', 'open-settings', 'open-requests', 'open-models'])
@@ -25,9 +16,11 @@ const emit = defineEmits(['log', 'status', 'notice', 'open-settings', 'open-requ
const status = ref(props.shellStatus) const status = ref(props.shellStatus)
const models = ref([]) const models = ref([])
const requests = ref([]) const requests = ref([])
const tokenStats = ref({ totalRequests: 0, successRequests: 0, inputTokens: 0, outputTokens: 0, totalTokens: 0 })
const health = ref(null) const health = ref(null)
const config = ref({}) const config = ref({})
const loading = ref(false) const proxyLoading = ref(false)
const modelsLoading = ref(false)
const testing = ref(false) const testing = ref(false)
const now = ref(Date.now()) const now = ref(Date.now())
let interval = null let interval = null
@@ -63,7 +56,7 @@ const healthStats = computed(() => {
avg, avg,
p50: percentile(sorted, 0.5), p50: percentile(sorted, 0.5),
p95: percentile(sorted, 0.95), p95: percentile(sorted, 0.95),
max: sorted[sorted.length - 1], max: Math.round(sorted[sorted.length - 1])
} }
}) })
const chartBars = computed(() => { const chartBars = computed(() => {
@@ -78,17 +71,22 @@ const displayRequests = computed(() => {
}) })
const displayModels = computed(() => { const displayModels = computed(() => {
if (models.value.length > 0) { if (models.value.length > 0) {
return models.value.slice(0, 5).map((model) => ({ ...model, online: true })) return models.value.map((model) => ({ ...model, online: true }))
} }
return [] return []
}) })
const successRate = computed(() => {
const total = Number(tokenStats.value.totalRequests || 0)
if (!total) return '0%'
return `${Math.round((Number(tokenStats.value.successRequests || 0) / total) * 100)}%`
})
function parseDurationMs(duration) { function parseDurationMs(duration) {
const text = String(duration || '').trim() const text = String(duration || '').trim()
if (!text) return 0 if (!text) return 0
if (text.endsWith('ms')) return Number.parseFloat(text) if (text.endsWith('ms')) return Math.round(Number.parseFloat(text))
if (text.endsWith('s')) return Number.parseFloat(text) * 1000 if (text.endsWith('s')) return Math.round(Number.parseFloat(text) * 1000)
return Number.parseFloat(text) || 0 return Math.round(Number.parseFloat(text) || 0)
} }
function percentile(sorted, p) { function percentile(sorted, p) {
@@ -97,12 +95,20 @@ function percentile(sorted, p) {
return Math.round(sorted[index]) return Math.round(sorted[index])
} }
function formatNumber(value) {
const n = Number(value || 0)
if (n >= 1000000) return `${(n / 1000000).toFixed(1)}M`
if (n >= 10000) return `${Math.round(n / 1000)}K`
return n.toLocaleString('zh-CN')
}
async function refresh() { async function refresh() {
try { try {
const nextStatus = await GetStatus() const nextStatus = await GetStatus()
status.value = nextStatus status.value = nextStatus
emit('status', nextStatus) emit('status', nextStatus)
requests.value = await GetRequests() requests.value = await GetRequests()
tokenStats.value = await GetTokenStats()
config.value = await GetConfig() config.value = await GetConfig()
if (nextStatus.running) { if (nextStatus.running) {
models.value = await GetModels() models.value = await GetModels()
@@ -113,7 +119,7 @@ async function refresh() {
} }
async function refreshModels() { async function refreshModels() {
loading.value = true modelsLoading.value = true
try { try {
models.value = await RefreshModels() models.value = await RefreshModels()
emit('log', 'info', `模型探测完成:${models.value.length}`) emit('log', 'info', `模型探测完成:${models.value.length}`)
@@ -121,7 +127,7 @@ async function refreshModels() {
} catch (e) { } catch (e) {
emit('log', 'error', '模型探测失败:' + (e.message || String(e)) + '。请确认 Lingma 插件已启动并登录;自动探测失败时可到设置页手动填写 WebSocketws://127.0.0.1:36510/,或 Windows Named Pipe\\\\.\\pipe\\lingma-xxxx。') emit('log', 'error', '模型探测失败:' + (e.message || String(e)) + '。请确认 Lingma 插件已启动并登录;自动探测失败时可到设置页手动填写 WebSocketws://127.0.0.1:36510/,或 Windows Named Pipe\\\\.\\pipe\\lingma-xxxx。')
} finally { } finally {
loading.value = false modelsLoading.value = false
} }
} }
@@ -141,7 +147,7 @@ async function copyModelName(model) {
} }
async function toggleProxy() { async function toggleProxy() {
loading.value = true proxyLoading.value = true
try { try {
if (isRunning.value) { if (isRunning.value) {
await StopProxy() await StopProxy()
@@ -154,13 +160,13 @@ async function toggleProxy() {
} catch (e) { } catch (e) {
emit('log', 'error', '代理切换失败:' + (e.message || String(e))) emit('log', 'error', '代理切换失败:' + (e.message || String(e)))
} finally { } finally {
loading.value = false proxyLoading.value = false
} }
} }
async function restartProxy() { async function restartProxy() {
if (!isRunning.value) return if (!isRunning.value) return
loading.value = true proxyLoading.value = true
try { try {
await StopProxy() await StopProxy()
await StartProxy() await StartProxy()
@@ -169,7 +175,7 @@ async function restartProxy() {
} catch (e) { } catch (e) {
emit('log', 'error', '代理重启失败:' + (e.message || String(e))) emit('log', 'error', '代理重启失败:' + (e.message || String(e)))
} finally { } finally {
loading.value = false proxyLoading.value = false
} }
} }
@@ -241,9 +247,9 @@ onUnmounted(() => {
<strong>{{ sessionLabel }}</strong> <strong>{{ sessionLabel }}</strong>
</div> </div>
<div class="strip-actions"> <div class="strip-actions">
<button :class="{ active: !isRunning }" type="button" :disabled="loading || isRunning" @click="toggleProxy">启动</button> <button :class="{ active: !isRunning }" type="button" :disabled="proxyLoading || isRunning" @click="toggleProxy">启动</button>
<button :class="{ active: isRunning }" type="button" :disabled="loading || !isRunning" @click="toggleProxy">停止</button> <button :class="{ active: isRunning }" type="button" :disabled="proxyLoading || !isRunning" @click="toggleProxy">停止</button>
<button type="button" :disabled="loading || !isRunning" @click="restartProxy">重启</button> <button type="button" :disabled="proxyLoading || !isRunning" @click="restartProxy">重启</button>
</div> </div>
</section> </section>
@@ -257,12 +263,7 @@ onUnmounted(() => {
<span class="status-chip ok">Healthy</span> <span class="status-chip ok">Healthy</span>
</div> </div>
<div class="activity-chart" aria-label="延迟趋势图"> <div class="activity-chart" aria-label="延迟趋势图">
<span <span v-for="(height, index) in chartBars" :key="index" class="bar" :style="{ height: `${height}%`, opacity: 0.55 + index / 45 }"></span>
v-for="(height, index) in chartBars"
:key="index"
class="bar"
:style="{ height: `${height}%`, opacity: 0.55 + index / 45 }"
></span>
<span v-if="chartBars.length === 0" class="chart-empty">暂无请求</span> <span v-if="chartBars.length === 0" class="chart-empty">暂无请求</span>
</div> </div>
<div class="health-stats"> <div class="health-stats">
@@ -278,22 +279,13 @@ onUnmounted(() => {
<div> <div>
<h2>Models</h2> <h2>Models</h2>
</div> </div>
<button class="secondary-button" type="button" :disabled="loading || !isRunning" @click="refreshModels">探测模型</button> <button class="btn-sm-outline" type="button" :disabled="modelsLoading || !isRunning" @click="refreshModels">
{{ modelsLoading ? '探测中...' : '探测模型' }}
</button>
</div> </div>
<div class="model-card-list hidden-scrollbar"> <div class="model-card-list hidden-scrollbar">
<button <button v-for="model in displayModels" :key="model.id" class="model-row model-choice" type="button" :title="`复制模型 ID${model.id}`" @click="copyModelName(model)">
v-for="model in displayModels" <span class="model-brand-icon" :style="{ '--model-icon': `url(${modelIcon(model).src})`, '--model-icon-color': modelIcon(model).color }" aria-hidden="true"></span>
:key="model.id"
class="model-row model-choice"
type="button"
:title="`复制模型 ID${model.id}`"
@click="copyModelName(model)"
>
<span
class="model-brand-icon"
:style="{ '--model-icon': `url(${modelIcon(model).src})`, '--model-icon-color': modelIcon(model).color }"
aria-hidden="true"
></span>
<div> <div>
<div class="model-name">{{ model.name || model.id }}</div> <div class="model-name">{{ model.name || model.id }}</div>
</div> </div>
@@ -301,107 +293,112 @@ onUnmounted(() => {
</button> </button>
</div> </div>
<div v-if="displayModels.length === 0" class="empty-state compact">暂无模型启动代理后点击探测模型</div> <div v-if="displayModels.length === 0" class="empty-state compact">暂无模型启动代理后点击探测模型</div>
<button class="link-row" type="button" @click="emit('open-models')">查看全部模型 <i class="bi bi-chevron-right"></i></button>
</div> </div>
<div class="glass-panel area-config"> <div class="glass-panel area-config">
<div class="panel-header"> <div class="panel-header compact-header">
<div> <div>
<h2>Configuration</h2> <h2>Configuration</h2>
<p>首页只展示关键配置完整项在设置页查看</p>
</div> </div>
<span class="status-chip ok">Valid</span> <span class="status-chip ok">Valid</span>
</div> </div>
<div class="setting-row"> <div class="config-summary">
<div> <div class="config-summary-item">
<div class="cell-main">Host</div> <label>监听地址</label>
<div class="cell-sub">{{ config.Host || '127.0.0.1' }}</div> <strong>{{ config.Host || '127.0.0.1' }}:{{ config.Port || 8095 }}</strong>
</div>
<div class="config-summary-item">
<label>传输方式</label>
<strong>{{ transportLabel }}</strong>
</div>
<div class="config-summary-item">
<label>会话策略</label>
<strong>{{ config.SessionMode || 'Reuse' }}</strong>
</div>
<div class="config-summary-item">
<label>超时</label>
<strong>{{ config.Timeout || 120 }} </strong>
</div>
<div class="config-summary-item span-2">
<label>工作目录</label>
<strong :title="config.Cwd || '未配置'">{{ config.Cwd || '未配置' }}</strong>
</div>
<div v-if="config.CurrentFilePath" class="config-summary-item span-2">
<label>当前文件</label>
<strong :title="config.CurrentFilePath">{{ config.CurrentFilePath }}</strong>
</div> </div>
<span class="status-chip ok"><i class="bi bi-check"></i></span>
</div> </div>
<div class="setting-row"> </div>
<div class="glass-panel area-usage">
<div class="panel-header">
<div> <div>
<div class="cell-main">Port</div> <h2>Token 统计</h2>
<div class="cell-sub">{{ config.Port || 8095 }}</div> <p>按代理返回的 usage 累计流式缺失字段时只统计可获得部分</p>
</div> </div>
<span class="status-chip ok"><i class="bi bi-check"></i></span> <span class="status-chip ok">Persisted</span>
</div> </div>
<div class="setting-row"> <div class="usage-grid">
<div> <div>
<div class="cell-main">Transport</div> <label> Token</label>
<div class="cell-sub">{{ transportLabel }}</div> <strong>{{ formatNumber(tokenStats.totalTokens) }}</strong>
</div>
<div>
<label>输入</label>
<strong>{{ formatNumber(tokenStats.inputTokens) }}</strong>
</div>
<div>
<label>输出</label>
<strong>{{ formatNumber(tokenStats.outputTokens) }}</strong>
</div>
<div>
<label>成功率</label>
<strong>{{ successRate }}</strong>
</div> </div>
<span class="status-chip ok"><i class="bi bi-check"></i></span>
</div> </div>
<div class="setting-row"> <div class="usage-foot">
<div> <span>累计请求 {{ formatNumber(tokenStats.totalRequests) }} </span>
<div class="cell-main">Session</div> <span v-if="tokenStats.lastModel">最近模型 {{ tokenStats.lastModel }}</span>
<div class="cell-sub">{{ config.SessionMode || 'Reuse' }}</div>
</div>
<span class="status-chip ok"><i class="bi bi-check"></i></span>
</div>
<div class="setting-row">
<div>
<div class="cell-main">Timeout (s)</div>
<div class="cell-sub">{{ config.Timeout || 120 }} </div>
</div>
<span class="status-chip ok"><i class="bi bi-check"></i></span>
</div>
<div class="setting-row">
<div>
<div class="cell-main">CWD</div>
<div class="cell-sub">{{ config.Cwd || '未配置' }}</div>
</div>
<span class="status-chip ok"><i class="bi bi-check"></i></span>
</div>
<div class="setting-row">
<div>
<div class="cell-main">Current File</div>
<div class="cell-sub">{{ config.CurrentFilePath || '未配置' }}</div>
</div>
<span class="status-chip ok"><i class="bi bi-check"></i></span>
</div> </div>
</div> </div>
<div class="table-panel area-requests"> <div class="table-panel area-requests">
<div class="table-toolbar"> <div class="table-toolbar">
<div> <div class="panel-header toolbar-header">
<div class="panel-header" style="margin: 0">
<h2>Recent Requests</h2> <h2>Recent Requests</h2>
</div> </div>
<button type="button" class="btn-sm-outline" @click="emit('open-requests')">
查看全部请求 <i class="bi bi-chevron-right"></i>
</button>
</div> </div>
<button class="secondary-button" type="button" @click="emit('open-requests')">查看全部</button> <div v-if="displayRequests.length > 0" class="table-scroll hidden-scrollbar">
</div> <table class="data-table">
<div v-if="displayRequests.length > 0" class="table-scroll hidden-scrollbar"> <thead>
<table class="data-table"> <tr>
<thead> <th>Time</th>
<tr> <th>Method</th>
<th>Time</th> <th>Path</th>
<th>Method</th> <th>Model</th>
<th>Path</th> <th>Status</th>
<th>Model</th> <th>Duration</th>
<th>Status</th> <th>Size</th>
<th>Duration</th> </tr>
<th>Size</th> </thead>
</tr> <tbody>
</thead> <tr v-for="(request, index) in displayRequests" :key="index">
<tbody> <td>{{ request.time }}</td>
<tr v-for="(request, index) in displayRequests" :key="index"> <td>{{ request.method }}</td>
<td>{{ request.time }}</td> <td>{{ request.path }}</td>
<td>{{ request.method }}</td> <td>{{ request.model || '-' }}</td>
<td>{{ request.path }}</td> <td><span class="status-chip" :class="statusClass(request.statusCode)">{{ request.statusCode }}</span></td>
<td>{{ request.model || '-' }}</td> <td>{{ request.duration }}</td>
<td><span class="status-chip" :class="statusClass(request.statusCode)">{{ request.statusCode }}</span></td> <td>{{ request.size || '-' }}</td>
<td>{{ request.duration }}</td> </tr>
<td>{{ request.size || '-' }}</td> </tbody>
</tr> </table>
</tbody> </div>
</table> <div v-else class="empty-state compact">暂无请求记录连接客户端后会显示真实调用</div>
</div>
<div v-else class="empty-state compact">暂无请求记录连接客户端后会显示真实调用</div>
<div class="table-footer">
<span>Showing {{ displayRequests.length }} of {{ requests.length }}</span>
<button type="button" @click="emit('open-requests')">查看全部请求 <i class="bi bi-chevron-right"></i></button>
</div>
</div> </div>
</section> </section>
</div> </div>

View File

@@ -17,20 +17,81 @@ const filtered = computed(() => {
return models.value.filter((model) => `${model.id} ${model.name}`.toLowerCase().includes(q)) return models.value.filter((model) => `${model.id} ${model.name}`.toLowerCase().includes(q))
}) })
function modelTag(model) { function modelSpec(model) {
const text = `${model.id} ${model.name}`.toLowerCase() const text = `${model.id} ${model.name}`.toLowerCase()
if (text.includes('coder')) return '工具优先' if (text.includes('kmodel') || text.includes('kimi')) {
if (text.includes('thinking')) return '推理' return {
if (text.includes('kimi')) return '长文本' context: '256K',
if (text.includes('minimax')) return '通用' capability: '文本/图像/视频/工具',
return 'Lingma' source: 'Kimi 官方',
}
}
if (text.includes('mmodel') || text.includes('minimax')) {
return {
context: '200K',
capability: 'Agent / Tool Use',
source: 'MiniMax 官方',
}
}
if (text.includes('coder')) {
return {
context: '1M',
capability: '思考 / Function Calling / 结构化输出',
source: '阿里云百炼 Qwen3-Coder',
}
}
if (text.includes('thinking')) {
return {
context: '256K',
capability: '思考 / Function Calling / 推理',
source: '阿里云百炼 Qwen3',
}
}
if (text.includes('qwen_max') || text.includes('qwen3-max')) {
return {
context: '256K',
capability: '思考 / Function Calling / 内置工具',
source: '阿里云百炼 Qwen3-Max',
}
}
if (text.includes('qmodel') || text.includes('qwen3.6')) {
return {
context: '1M',
capability: 'Function Calling / 内置工具 / 结构化输出',
source: '阿里云百炼 Qwen3.6-Plus',
}
}
if (text.includes('auto')) {
return {
context: '自动',
capability: 'Lingma 自动路由',
source: '账号返回',
}
}
return {
context: '未公开',
capability: '通用',
source: '账号返回',
}
}
async function loadCachedModels() {
loading.value = true
try {
status.value = await GetStatus()
models.value = await GetModels()
} catch (e) {
emit('log', 'error', '模型缓存读取失败:' + (e.message || String(e)))
} finally {
loading.value = false
}
} }
async function refresh() { async function refresh() {
loading.value = true loading.value = true
try { try {
status.value = await GetStatus() status.value = await GetStatus()
models.value = status.value.running ? await RefreshModels() : await GetModels() models.value = await RefreshModels()
emit('log', 'info', `模型列表刷新完成:${models.value.length}`) emit('log', 'info', `模型列表刷新完成:${models.value.length}`)
} catch (e) { } catch (e) {
emit('log', 'error', '模型列表刷新失败:' + (e.message || String(e)) + '。自动探测失败时请到设置页手动填写 WebSocketws://127.0.0.1:36510/,或 Windows Named Pipe\\\\.\\pipe\\lingma-xxxx。') emit('log', 'error', '模型列表刷新失败:' + (e.message || String(e)) + '。自动探测失败时请到设置页手动填写 WebSocketws://127.0.0.1:36510/,或 Windows Named Pipe\\\\.\\pipe\\lingma-xxxx。')
@@ -54,7 +115,7 @@ async function copyModelName(model) {
} }
} }
onMounted(refresh) onMounted(loadCachedModels)
</script> </script>
<template> <template>
@@ -111,7 +172,11 @@ onMounted(refresh)
<div class="model-name">{{ model.name || model.id }}</div> <div class="model-name">{{ model.name || model.id }}</div>
<div class="model-meta">{{ model.id }}</div> <div class="model-meta">{{ model.id }}</div>
</div> </div>
<span class="status-chip" :class="modelTag(model) === '工具优先' ? 'ok' : 'warn'">{{ modelTag(model) }}</span> <div class="model-specs">
<span class="spec-chip strong">{{ modelSpec(model).context }}</span>
<span class="spec-chip">{{ modelSpec(model).capability }}</span>
<span class="spec-chip muted-chip">{{ modelSpec(model).source }}</span>
</div>
</button> </button>
</div> </div>
<div v-else class="empty-state">启动代理并刷新后会显示模型</div> <div v-else class="empty-state">启动代理并刷新后会显示模型</div>

View File

@@ -14,7 +14,7 @@ const activeStatus = ref('all')
const filtered = computed(() => { const filtered = computed(() => {
const q = query.value.trim().toLowerCase() const q = query.value.trim().toLowerCase()
return requests.value.filter((request) => { return requests.value.filter((request) => {
const matchesQuery = !q || `${request.method} ${request.path} ${request.statusCode}`.toLowerCase().includes(q) const matchesQuery = !q || `${request.method} ${request.path} ${request.statusCode} ${request.model || ''}`.toLowerCase().includes(q)
const code = Number(request.statusCode) const code = Number(request.statusCode)
const matchesStatus = const matchesStatus =
activeStatus.value === 'all' || activeStatus.value === 'all' ||
@@ -117,7 +117,10 @@ onUnmounted(() => {
<section class="table-panel requests-panel"> <section class="table-panel requests-panel">
<div class="table-toolbar"> <div class="table-toolbar">
<input v-model="query" class="search-input" type="search" placeholder="搜索路径、方法或状态码" /> <div class="toolbar-search-wrap">
<input v-model="query" class="search-input toolbar-search-input" type="search" placeholder="搜索路径、方法或状态码" />
<span class="muted toolbar-count">Showing {{ filtered.length }} of {{ requests.length }}</span>
</div>
<div class="segmented"> <div class="segmented">
<button :class="{ active: activeStatus === 'all' }" type="button" @click="activeStatus = 'all'">全部</button> <button :class="{ active: activeStatus === 'all' }" type="button" @click="activeStatus = 'all'">全部</button>
<button :class="{ active: activeStatus === 'ok' }" type="button" @click="activeStatus = 'ok'">成功</button> <button :class="{ active: activeStatus === 'ok' }" type="button" @click="activeStatus = 'ok'">成功</button>
@@ -133,6 +136,7 @@ onUnmounted(() => {
<th>时间</th> <th>时间</th>
<th>方法</th> <th>方法</th>
<th>路径</th> <th>路径</th>
<th>模型</th>
<th>状态</th> <th>状态</th>
<th>耗时</th> <th>耗时</th>
</tr> </tr>
@@ -150,6 +154,7 @@ onUnmounted(() => {
<div class="cell-main">{{ request.path }}</div> <div class="cell-main">{{ request.path }}</div>
<div class="cell-sub">{{ request.reqBody ? '包含请求体' : '无请求体' }}</div> <div class="cell-sub">{{ request.reqBody ? '包含请求体' : '无请求体' }}</div>
</td> </td>
<td>{{ request.model || '-' }}</td>
<td><span class="status-chip" :class="statusClass(request.statusCode)">{{ request.statusCode }}</span></td> <td><span class="status-chip" :class="statusClass(request.statusCode)">{{ request.statusCode }}</span></td>
<td>{{ request.duration }}</td> <td>{{ request.duration }}</td>
</tr> </tr>

View File

@@ -8,6 +8,8 @@ const config = ref({})
const detection = ref(null) const detection = ref(null)
const saving = ref(false) const saving = ref(false)
const openSelect = ref('') const openSelect = ref('')
const fallbackModelsText = ref('')
const isIPCBackend = computed(() => (config.value.Backend || 'ipc') === 'ipc')
const selectOptions = { const selectOptions = {
Backend: [ Backend: [
@@ -54,6 +56,9 @@ function chooseOption(field, value) {
onMounted(async () => { onMounted(async () => {
try { try {
config.value = await GetConfig() config.value = await GetConfig()
fallbackModelsText.value = Array.isArray(config.value.RemoteFallbackModels)
? config.value.RemoteFallbackModels.join('\n')
: ''
await refreshDetection() await refreshDetection()
} catch (e) { } catch (e) {
emit('log', 'error', '配置加载失败:' + (e.message || String(e))) emit('log', 'error', '配置加载失败:' + (e.message || String(e)))
@@ -71,6 +76,10 @@ async function refreshDetection() {
async function save() { async function save() {
saving.value = true saving.value = true
try { try {
config.value.RemoteFallbackModels = fallbackModelsText.value
.split(/\n|,/)
.map((item) => item.trim())
.filter(Boolean)
await UpdateConfig(config.value) await UpdateConfig(config.value)
await refreshDetection() await refreshDetection()
emit('log', 'info', '配置已保存,代理已按需重启') emit('log', 'info', '配置已保存,代理已按需重启')
@@ -95,7 +104,7 @@ async function save() {
</button> </button>
</div> </div>
<section class="grid-2"> <section class="grid-2 settings-grid">
<div class="glass-panel"> <div class="glass-panel">
<div class="panel-header"> <div class="panel-header">
<div> <div>
@@ -156,6 +165,23 @@ async function save() {
<label>超时秒数</label> <label>超时秒数</label>
<input v-model.number="config.Timeout" type="number" min="1" /> <input v-model.number="config.Timeout" type="number" min="1" />
</div> </div>
<div class="field span-2 switch-field">
<div>
<label>远端超时兜底</label>
<p>远端 API 超时限流或 5xx 且尚未流式输出时自动切换到下一个可用模型</p>
</div>
<label class="switch">
<input v-model="config.RemoteFallbackEnabled" type="checkbox" />
<span></span>
</label>
</div>
<div class="field span-2">
<label>兜底模型顺序</label>
<textarea
v-model="fallbackModelsText"
placeholder="kmodel&#10;mmodel&#10;dashscope_qwen3_coder&#10;dashscope_qmodel"
></textarea>
</div>
<div class="field span-2"> <div class="field span-2">
<label>WebSocket 地址</label> <label>WebSocket 地址</label>
<input v-model="config.WebSocketURL" type="text" placeholder="留空自动探测 Lingma WebSocket" /> <input v-model="config.WebSocketURL" type="text" placeholder="留空自动探测 Lingma WebSocket" />
@@ -231,10 +257,16 @@ async function save() {
<div class="panel-header"> <div class="panel-header">
<div> <div>
<h2>会话与环境</h2> <h2>会话与环境</h2>
<p>影响 Lingma 会话上下文和工具执行环境</p> <p>仅在 IPC 插件模式下生效影响 Lingma 会话上下文和工具执行环境</p>
</div> </div>
<span class="status-chip" :class="isIPCBackend ? 'ok' : 'warn'">{{ isIPCBackend ? '仅 IPC 生效' : '远端模式忽略' }}</span>
</div> </div>
<div class="form-grid"> <div v-if="!isIPCBackend" class="hint-box compact-hint">
<strong>当前为远端 API 模式</strong>
<span>右侧这组参数不会参与远端请求只在切换到 IPC 插件模式后生效</span>
</div>
<fieldset class="settings-fieldset" :disabled="!isIPCBackend">
<div class="form-grid compact-form-grid">
<div class="field"> <div class="field">
<label>模式</label> <label>模式</label>
<div class="custom-select" :class="{ open: openSelect === 'Mode' }"> <div class="custom-select" :class="{ open: openSelect === 'Mode' }">
@@ -301,9 +333,10 @@ async function save() {
</div> </div>
<div class="field span-2"> <div class="field span-2">
<label>工作目录</label> <label>工作目录</label>
<textarea v-model="config.Cwd" placeholder="Lingma 创建 session 时使用的 cwd"></textarea> <input v-model="config.Cwd" type="text" placeholder="Lingma 创建 session 时使用的 cwd" />
</div> </div>
</div> </div>
</fieldset>
</div> </div>
</section> </section>
</div> </div>

View File

@@ -11,12 +11,16 @@ export function GetConfig():Promise<service.Config>;
export function GetDetectionInfo():Promise<main.DetectionInfo>; export function GetDetectionInfo():Promise<main.DetectionInfo>;
export function GetLogs():Promise<Array<main.AppLog>>;
export function GetModels():Promise<Array<main.ModelInfo>>; export function GetModels():Promise<Array<main.ModelInfo>>;
export function GetRequests():Promise<Array<main.RequestRecord>>; export function GetRequests():Promise<Array<main.RequestRecord>>;
export function GetStatus():Promise<main.ProxyStatus>; export function GetStatus():Promise<main.ProxyStatus>;
export function GetTokenStats():Promise<main.TokenStats>;
export function HideWindow():Promise<void>; export function HideWindow():Promise<void>;
export function MinimizeWindow():Promise<void>; export function MinimizeWindow():Promise<void>;

View File

@@ -18,6 +18,10 @@ export function GetDetectionInfo() {
return window['go']['main']['App']['GetDetectionInfo'](); return window['go']['main']['App']['GetDetectionInfo']();
} }
export function GetLogs() {
return window['go']['main']['App']['GetLogs']();
}
export function GetModels() { export function GetModels() {
return window['go']['main']['App']['GetModels'](); return window['go']['main']['App']['GetModels']();
} }
@@ -30,6 +34,10 @@ export function GetStatus() {
return window['go']['main']['App']['GetStatus'](); return window['go']['main']['App']['GetStatus']();
} }
export function GetTokenStats() {
return window['go']['main']['App']['GetTokenStats']();
}
export function HideWindow() { export function HideWindow() {
return window['go']['main']['App']['HideWindow'](); return window['go']['main']['App']['HideWindow']();
} }

View File

@@ -1,5 +1,21 @@
export namespace main { export namespace main {
export class AppLog {
time: string;
level: string;
message: string;
static createFrom(source: any = {}) {
return new AppLog(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.time = source["time"];
this.level = source["level"];
this.message = source["message"];
}
}
export class DetectionInfo { export class DetectionInfo {
listenUrl: string; listenUrl: string;
backend: string; backend: string;
@@ -86,6 +102,9 @@ export namespace main {
statusCode: number; statusCode: number;
duration: string; duration: string;
size?: string; size?: string;
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
reqBody?: string; reqBody?: string;
respBody?: string; respBody?: string;
@@ -102,10 +121,39 @@ export namespace main {
this.statusCode = source["statusCode"]; this.statusCode = source["statusCode"];
this.duration = source["duration"]; this.duration = source["duration"];
this.size = source["size"]; this.size = source["size"];
this.inputTokens = source["inputTokens"];
this.outputTokens = source["outputTokens"];
this.totalTokens = source["totalTokens"];
this.reqBody = source["reqBody"]; this.reqBody = source["reqBody"];
this.respBody = source["respBody"]; this.respBody = source["respBody"];
} }
} }
export class TokenStats {
totalRequests: number;
successRequests: number;
inputTokens: number;
outputTokens: number;
totalTokens: number;
byModel?: Record<string, number>;
lastModel?: string;
lastUpdated?: string;
static createFrom(source: any = {}) {
return new TokenStats(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.totalRequests = source["totalRequests"];
this.successRequests = source["successRequests"];
this.inputTokens = source["inputTokens"];
this.outputTokens = source["outputTokens"];
this.totalTokens = source["totalTokens"];
this.byModel = source["byModel"];
this.lastModel = source["lastModel"];
this.lastUpdated = source["lastUpdated"];
}
}
} }
@@ -128,6 +176,8 @@ export namespace service {
ShellType: string; ShellType: string;
SessionMode: string; SessionMode: string;
Timeout: number; Timeout: number;
RemoteFallbackEnabled: boolean;
RemoteFallbackModels: string[];
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new Config(source); return new Config(source);
@@ -151,6 +201,8 @@ export namespace service {
this.ShellType = source["ShellType"]; this.ShellType = source["ShellType"];
this.SessionMode = source["SessionMode"]; this.SessionMode = source["SessionMode"];
this.Timeout = source["Timeout"]; this.Timeout = source["Timeout"];
this.RemoteFallbackEnabled = source["RemoteFallbackEnabled"];
this.RemoteFallbackModels = source["RemoteFallbackModels"];
} }
} }

View File

@@ -2,6 +2,7 @@ package main
import ( import (
"embed" "embed"
"os"
goruntime "runtime" goruntime "runtime"
"github.com/wailsapp/wails/v2" "github.com/wailsapp/wails/v2"
@@ -17,6 +18,7 @@ var assets embed.FS
func main() { func main() {
app := NewApp() app := NewApp()
enableInspector := os.Getenv("LINGMA_DESKTOP_DEBUG") == "1"
err := wails.Run(&options.App{ err := wails.Run(&options.App{
Title: "Lingma IPC Proxy", Title: "Lingma IPC Proxy",
@@ -28,6 +30,10 @@ func main() {
AssetServer: &assetserver.Options{ AssetServer: &assetserver.Options{
Assets: assets, Assets: assets,
}, },
EnableDefaultContextMenu: enableInspector,
Debug: options.Debug{
OpenInspectorOnStartup: enableInspector,
},
BackgroundColour: &options.RGBA{R: 15, G: 23, B: 42, A: 1}, BackgroundColour: &options.RGBA{R: 15, G: 23, B: 42, A: 1},
Menu: appMenu(app), Menu: appMenu(app),
OnStartup: app.startup, OnStartup: app.startup,

View File

@@ -11,6 +11,6 @@
"email": "lutc5@asiainfo.com" "email": "lutc5@asiainfo.com"
}, },
"info": { "info": {
"productVersion": "1.4.2" "productVersion": "1.4.3"
} }
} }

View File

@@ -1,424 +1,316 @@
# lingma-ipc-proxy 架构文档 # lingma-ipc-proxy Architecture
本文档描述 lingma-ipc-proxy 的系统架构、工作原理和核心流程。 This document describes the current architecture of `lingma-ipc-proxy`, including both backend modes:
- `ipc`: bridge to the local Lingma IDE plugin transport
- `remote`: call Lingma remote HTTP APIs directly with detected credentials
--- ---
## 1. 整体架构 ## 1. System Overview
``` ```mermaid
┌─────────────────────────────────────────────────────────────────────────┐ flowchart LR
客户端层 │ A["Clients<br/>Claude Code / Hermes / Cline / Continue / OpenAI SDK / Anthropic SDK"]
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ B["internal/httpapi<br/>OpenAI + Anthropic compatible routes"]
Claude Code │ │ OpenAI │ │ Cline │ │ Continue │ │ C["internal/service<br/>request normalization / session policy / streaming / fallback"]
(Anthropic) │ │ SDK │ │ (OpenAI) │ │ (OpenAI) │ │ D["internal/toolemulation<br/>tool prompt injection + action block parsing"]
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │ E["internal/lingmaipc<br/>WebSocket / Named Pipe"]
└─────────┼─────────────────┼─────────────────┼─────────────────┼─────────┘ F["internal/remote<br/>credential detection / model list / chat / SSE"]
│ │ │ │ G["Lingma plugin local process"]
└─────────────────┴────────┬────────┴─────────────────┘ H["Lingma remote API"]
│ HTTP I["Desktop app<br/>Wails GUI / logs / token stats / persisted state"]
┌─────────────────────────────────────────────────────────────────────────┐ A --> B
│ lingma-ipc-proxy │ I --> B
┌─────────────────────────────────────────────────────────────────┐ │ B --> C
│ │ internal/httpapi │ │ C --> D
┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │ │ C --> E
│ /v1/models │ │/v1/chat/comp│ │ /v1/messages │ │ │ C --> F
│ │ (GET) │ │ (POST) │ │ (POST) │ │ │ E --> G
└──────┬──────┘ └──────┬──────┘ └───────────┬─────────────┘ │ │ F --> H
│ │ └─────────────────┴──────────┬──────────┘ │ │
│ │ │ normalizeRequest │ │
│ │ ▼ │ │
│ │ ┌─────────────────────────────────────────────────────────┐ │ │
│ │ │ internal/service │ │ │
│ │ │ ┌──────────┐ ┌──────────┐ ┌────────────────────────┐ │ │ │
│ │ │ │ Session │ │ Prompt │ │ Stream/Event │ │ │ │
│ │ │ │ Manager │ │ Builder │ │ Handler │ │ │ │
│ │ │ └────┬─────┘ └────┬─────┘ └───────────┬────────────┘ │ │ │
│ │ │ └─────────────┴──────────┬─────────┘ │ │ │
│ │ │ │ buildLingmaPrompt │ │ │
│ │ │ ▼ │ │ │
│ │ │ ┌─────────────────────────────────────────────────┐ │ │ │
│ │ │ │ internal/lingmaipc │ │ │ │
│ │ │ │ ┌──────────────┐ ┌──────────────────────────┐ │ │ │ │
│ │ │ │ │ WebSocket │ │ Named Pipe (Win) │ │ │ │ │
│ │ │ │ │ Transport │ │ Transport │ │ │ │ │
│ │ │ │ └──────┬───────┘ └───────────┬──────────────┘ │ │ │ │
│ │ │ └─────────┼──────────────────────┼────────────────┘ │ │ │
│ │ └────────────┼──────────────────────┼────────────────────┘ │ │
│ │ │ │ │ │
│ │ ┌────────────┼──────────────────────┼────────────────────┐ │ │
│ │ │ ▼ ▼ │ │ │
│ │ │ ┌─────────────────────────────────────────────────┐ │ │ │
│ │ │ │ internal/toolemulation │ │ │ │
│ │ │ │ ┌──────────────┐ ┌──────────────────────────┐ │ │ │ │
│ │ │ │ │InjectTooling │ │ ParseActionBlocks │ │ │ │ │
│ │ │ │ │ (Prompt) │ │ (Response) │ │ │ │ │
│ │ │ │ └──────────────┘ └──────────────────────────┘ │ │ │ │
│ │ │ └─────────────────────────────────────────────────┘ │ │ │
│ │ └───────────────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
│ WebSocket / Named Pipe
┌─────────────────────────────────────────────────────────────────────────┐
│ Lingma 后端进程 │
│ (VS Code 插件的本地 IPC 服务) │
│ ws://127.0.0.1:8899/ws │
└─────────────────────────────────────────────────────────────────────────┘
│ HTTP API
┌─────────────────────────────────────────────────────────────────────────┐
│ 云端模型服务 │
│ (Kimi-K2.6 / Qwen3-Max / MiniMax-M2.7 等) │
└─────────────────────────────────────────────────────────────────────────┘
``` ```
--- ---
## 2. 模块职责 ## 2. Runtime Modes
### 2.1 internal/httpapi ### 2.1 IPC mode
HTTP API 适配层,负责将外部请求转换为内部 `service.ChatRequest` `backend=ipc`
| 端点 | 协议 | 功能 | - Reads local plugin transport information
|------|------|------| - Connects through:
| `GET /v1/models` | OpenAI | 返回可用模型列表 | - WebSocket on macOS / Linux
| `POST /v1/chat/completions` | OpenAI | 聊天补全(流式/非流式) | - Named Pipe on Windows
| `POST /v1/messages` | Anthropic | 消息接口(流式/非流式) | - Reuses Lingma plugin session semantics
- Session/environment options in the desktop UI apply only here
**核心函数:** ### 2.2 Remote API mode
- `handleOpenAIChatCompletions()` - 处理 OpenAI 格式请求
- `handleAnthropicMessages()` - 处理 Anthropic 格式请求
- `normalizeOpenAIRequest()` / `normalizeAnthropicRequest()` - 归一化请求
**关键设计:** `backend=remote`
- 支持 CORS 预检请求 (`OPTIONS`)
- 单请求并发控制 (`tryAcquire()` / `release()`)
- 流式响应通过 `http.Flusher` 实现 SSE
### 2.2 internal/service - Reads Lingma remote base URL
- Loads credentials from:
业务逻辑层,负责会话管理和 Prompt 构建。 - explicit `remote_auth_file`
- or detected Lingma cache under `~/.lingma`
**核心结构:** - Calls remote model list and chat endpoints directly
```go - Supports timeout / 429 / 5xx fallback across available remote models
type Service struct { - Does not use local plugin session environment knobs
cfg Config
client *lingmaipc.Client
stickySessionID string
stickyModelID string
}
```
**核心函数:**
- `Generate()` - 非流式生成
- `GenerateStream()` - 流式生成(返回 `events` + `done` channel
- `buildLingmaPrompt()` - 构建 Lingma 原生 Prompt
- `runPromptLocked()` - 发送 `session/prompt` RPC 并监听 `session/update` 通知
**会话模式:**
| 模式 | 行为 |
|------|------|
| `reuse` | 复用 sticky session多轮对话保持上下文 |
| `fresh` | 每个请求新建临时 session完成后删除 |
| `auto` | 单轮请求复用;带 system/history 的请求用 fresh |
### 2.3 internal/lingmaipc
IPC 通信层,负责与 Lingma 后端进程建立连接。
**传输方式:**
| 平台 | 默认传输 | 说明 |
|------|----------|------|
| Windows | Named Pipe | `\\.\pipe\lingma-*` |
| macOS/Linux | WebSocket | `ws://127.0.0.1:{port}/ws` |
**连接发现:**
- 读取 VS Code 插件缓存:`~/.config/Lingma/SharedClientCache/.info.json`
- 获取 WebSocket 端口号
- 自动重连机制
**RPC 协议:**
- `session/new` - 创建会话
- `session/prompt` - 发送用户消息
- `session/update` - 接收流式响应通知
- `session/set_model` - 切换模型
- `chat/deleteSessionById` - 删除会话
### 2.4 internal/toolemulation
Tool 调用模拟层,将标准 `tools` 协议转换为 Prompt 层契约。
**核心流程:**
```
Client tools ──→ ExtractAnthropicTools() ──→ []Tool
InjectTooling() ──→ System Prompt + Tool 说明
模型输出 action block
ParseActionBlocks() ──→ []ToolCall
编码为 Anthropic tool_use / OpenAI tool_calls
```
**Prompt 契约格式:**
```
```json action
{"tool":"NAME","parameters":{"key":"value"}}
```
```
**支持格式:**
- `{"tool":"X","parameters":{}}` ✅ 标准格式
- `{"tool":"X","arguments":{}}` ✅ 兼容格式
- `{"tool":"X","input":{}}` ✅ 兼容格式
- `{"tool":"X","arg1":"val"}` ✅ 顶层参数(部分模型)
--- ---
## 3. 核心流程 ## 3. Module Responsibilities
### 3.1 普通聊天请求流程 ### 3.1 `cmd/lingma-ipc-proxy`
Entry point and config loading.
Responsibilities:
- parse CLI flags
- merge config file + environment + flags
- choose backend mode
- build `service.Config`
- start `internal/httpapi.Server`
Important config fields:
- `backend`
- `transport`
- `websocket_url`
- `pipe`
- `remote_base_url`
- `remote_auth_file`
- `remote_version`
- `remote_fallback_enabled`
- `remote_fallback_models`
### 3.2 `internal/httpapi`
Compatibility layer for OpenAI and Anthropic style APIs.
Primary routes:
- `GET /v1/models`
- `POST /v1/chat/completions`
- `POST /v1/messages`
- `GET /health`
- `GET /props`
Responsibilities:
- normalize OpenAI / Anthropic requests into `service.ChatRequest`
- convert service results back to OpenAI / Anthropic payloads
- stream SSE responses
- sanitize and record request / response payloads for debug UI
### 3.3 `internal/service`
Core orchestration layer.
Responsibilities:
- choose active backend
- warm up backend connection / credentials
- list models
- generate non-streaming responses
- generate streaming responses
- apply session reuse policy in IPC mode
- inject / parse tool emulation
- normalize image inputs
- apply remote fallback order
Important behavior split:
- IPC path uses `internal/lingmaipc`
- Remote path uses `internal/remote`
### 3.4 `internal/lingmaipc`
Local transport client for Lingma plugin IPC.
Responsibilities:
- detect WebSocket / pipe endpoint
- dial and reconnect
- send RPC messages such as:
- `session/new`
- `session/prompt`
- `session/set_model`
- `chat/deleteSessionById`
- consume `session/update` notifications
### 3.5 `internal/remote`
Remote HTTP client for Lingma cloud APIs.
Responsibilities:
- resolve base URL
- load and validate credentials
- derive machine / user identity for remote auth
- list remote models
- call remote chat endpoint
- handle remote SSE streaming
### 3.6 `internal/toolemulation`
Prompt-based tool bridge for models that do not expose native tool calling in Lingma transport.
Responsibilities:
- extract tool definitions from OpenAI / Anthropic requests
- append tool contract to prompt
- parse JSON action blocks from model output
- project tool calls back to:
- Anthropic `tool_use`
- OpenAI `tool_calls`
---
## 4. Request Flow
### 4.1 Shared ingress flow
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
participant C as Client participant Client
participant H as HTTP API participant HTTP as httpapi
participant S as Service participant Service as service
participant L as Lingma IPC
participant B as Lingma Backend
C->>H: POST /v1/messages Client->>HTTP: OpenAI/Anthropic request
H->>H: normalizeAnthropicRequest() HTTP->>HTTP: normalize request
H->>S: GenerateStream(req) HTTP->>Service: Generate / GenerateStream
S->>S: ensureConnected()
S->>S: resolveSession()
S->>S: buildLingmaPrompt()
S->>L: Send("session/prompt", params)
L->>B: WebSocket RPC
B->>L: session/update (agent_message_chunk)
loop 流式响应
L->>S: notification (chunk)
S->>H: events <- StreamEvent{Delta}
H->>C: SSE: content_block_delta
end
B->>L: session/update (chat_finish)
L->>S: notification (finish)
S->>H: done <- StreamResult
H->>C: SSE: message_stop
``` ```
### 3.2 Tool 调用流程 ### 4.2 IPC backend flow
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
participant C as Client participant Service
participant H as HTTP API participant Tool as toolemulation
participant T as ToolEmulation participant IPC as lingmaipc
participant S as Service participant Plugin as Lingma plugin
participant L as Lingma IPC
C->>H: POST /v1/messages (with tools) Service->>Tool: inject tool contract if needed
H->>T: ExtractAnthropicTools() Service->>IPC: ensure connected
H->>S: GenerateStream(req) Service->>IPC: create/reuse session
S->>T: InjectTooling(system, tools) Service->>IPC: session/prompt
S->>L: session/prompt (with tool prompt) IPC->>Plugin: RPC message
L->>S: response (with action blocks) Plugin-->>IPC: session/update chunks
S->>T: ParseActionBlocks(text) IPC-->>Service: stream events
T->>S: []ToolCall Service-->>Service: parse tool blocks / image references / stop reason
S->>H: ChatResult{Text, ToolCalls}
H->>C: SSE: tool_use blocks
C->>H: POST /v1/messages (tool_result)
H->>T: ActionOutputPrompt(toolUseID, content)
H->>S: GenerateStream(req)
S->>L: session/prompt (with tool result)
L->>S: response
S->>H: ChatResult
H->>C: SSE: final response
``` ```
### 3.3 图片传输流程 ### 4.3 Remote backend flow
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
participant C as Client participant Service
participant H as HTTP API participant Remote as remote client
participant S as Service participant API as Lingma remote API
participant L as Lingma IPC
C->>H: POST /v1/messages (with image) Service->>Remote: load credentials / ensure client
H->>H: extractAnthropicImages() Service->>Remote: list models if needed
H->>S: ChatRequest{Images: [...]} Service->>Remote: chat request
S->>S: runPromptLocked() Remote->>API: HTTPS request
Note over S: 1. 保存 base64 到 /tmp/lingma-img-*.ext API-->>Remote: JSON or SSE response
Note over S: 2. 构建 URI: lingma:///agent/file?path=... Remote-->>Service: normalized result
S->>L: session/prompt Service-->>Service: fallback to next model when allowed
Note over L: prompt: [{type:"text"}, {type:"image", mimeType, uri, data}]
L->>S: response (model sees image)
S->>H: ChatResult
H->>C: SSE response
```
### 3.4 流式输出 SSE 事件序列
**Anthropic 格式(流式):**
```
event: message_start
data: {"type":"message_start","message":{...}}
event: content_block_start
data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}
event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"你"}}
event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"好"}}
... (更多 delta)
event: content_block_stop
data: {"type":"content_block_stop","index":0}
[如有 tool_calls]
event: content_block_start
data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"...","name":"Bash","input":{"command":"ls /"}}}
event: content_block_stop
data: {"type":"content_block_stop","index":1}
event: message_delta
data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":5}}
event: message_stop
data: {"type":"message_stop"}
``` ```
--- ---
## 4. 关键技术决策 ## 5. Remote Fallback Strategy
### 4.1 为什么使用 Tool Emulation 而非原生 Tool Calling Remote fallback is used only when all conditions are true:
Lingma 后端模型Kimi、Qwen 等)不原生支持 OpenAI/Anthropic 的 `tools` 协议。因此代理层需要将工具定义注入到 Prompt 中,通过结构化文本输出模拟工具调用。 - `backend=remote`
- `remote_fallback_enabled=true`
- request has not emitted stream output yet
- upstream error matches timeout / 429 / 5xx class
**优点:** Current default order:
- 不依赖上游模型能力
- 兼容任何纯聊天模型
- 可精确控制 Prompt 格式
**缺点:** 1. `kmodel`
- 模型需要学习特定格式 2. `mmodel`
- 解析可能有容错问题 3. `dashscope_qwen3_coder`
- 增加了 Prompt 长度 4. `dashscope_qmodel`
5. `dashscope_qwen_max_latest`
6. `dashscope_qwen_plus_20250428_thinking`
### 4.2 为什么使用 WebSocket/Named Pipe 而非 HTTP Before using that order, the service filters candidates against the actual `/v1/models` result from the remote backend so unavailable models are skipped.
Lingma 插件使用本地 IPC 与后端通信,优势:
- 低延迟(本地通信)
- 双向实时通知session/update
- 认证信息由插件管理,代理无需处理
### 4.3 图片传输的双保险策略
```
Prompt 数组 (Lingma 原生格式):
[
{"type":"text","text":"..."},
{"type":"image","mimeType":"image/png","uri":"lingma:///agent/file?path=...","data":"base64..."}
]
```
- `uri`: Lingma 后端必须验证的本地文件路径
- `data`: base64 编码的图像数据(备用)
- `mimeType`: 图像类型标识
### 4.4 单请求并发控制
Lingma IPC 一次只能处理一个请求,因此代理使用 `tryAcquire()` 机制:
```go
if !s.tryAcquire() {
writeAnthropicError(w, 429, "rate_limit_error",
"Lingma IPC proxy handles one request at a time.")
return
}
defer s.release()
```
--- ---
## 5. 配置说明 ## 6. Desktop App Architecture
### 5.1 配置文件结构 The Wails desktop app is a management UI around the local proxy process.
```json Responsibilities:
{
"host": "127.0.0.1",
"port": 8095,
"transport": "websocket",
"mode": "agent",
"shell_type": "zsh",
"session_mode": "auto",
"timeout": 120,
"cwd": "/Users/tiancheng"
}
```
### 5.2 配置项说明 - start / stop / restart proxy
- show current backend and resolved endpoints
- persist:
- request history
- logs
- token statistics
- show detected IPC and remote credentials metadata
- edit config and restart proxy on save
| 配置项 | 类型 | 默认值 | 说明 | Persisted local state:
|--------|------|--------|------|
| `host` | string | `127.0.0.1` | HTTP 监听地址 | - config: `~/.config/lingma-ipc-proxy/config.json`
| `port` | int | `8095` | HTTP 监听端口 | - UI/runtime state: `~/.config/lingma-ipc-proxy/app-state.json`
| `transport` | string | `auto` | IPC 传输方式:`auto`/`pipe`/`websocket` |
| `mode` | string | `chat` | 模式:`chat`/`agent` | Production packaging rules:
| `shell_type` | string | `powershell` | 终端类型 |
| `session_mode` | string | `auto` | 会话模式:`reuse`/`fresh`/`auto` | - packaged app should not auto-open inspector
| `timeout` | int | `120` | 请求超时(秒) | - local development can opt in with `LINGMA_DESKTOP_DEBUG=1`
| `cwd` | string | `""` | 工作目录(传给 Lingma 后端) |
--- ---
## 6. 扩展点 ## 7. Key Design Decisions
### 6.1 添加新模型 ### 7.1 Why keep both IPC and remote modes?
`service.go` 的模型映射中添加: Because the two modes solve different problems:
```go - IPC mode preserves plugin session semantics and local tool environment
func (s *Service) resolveInternalModelID(model string) string { - Remote mode avoids plugin runtime coupling and is usually better for third-party agent clients
switch strings.ToLower(strings.TrimSpace(model)) {
case "kimi-k2.6":
return "kimi2.6"
case "qwen3-max":
return "qwen3max"
// 添加新模型映射
default:
return ""
}
}
```
### 6.2 添加新 Tool 格式支持 ### 7.2 Why keep tool emulation even with remote mode?
`toolemulation.go``parseToolCallJSON()` 中扩展参数解析逻辑。 Because Lingma-exposed models are not guaranteed to speak OpenAI/Anthropic native tool protocol consistently across all routes. The proxy must keep a stable external contract even when the upstream model capability is uneven.
### 6.3 添加新 API 端点 ### 7.3 Why persist requests and token stats in the desktop app?
`httpapi/server.go``NewServer()` 中注册新路由。 Because the GUI is used as an operational console, not a transient preview. Users need model usage, logs, and recent traffic to survive app restarts.
--- ---
*文档版本: 2025-04-25* ## 8. Known Boundaries
*对应代码版本: 当前 master*
- IPC mode still has stronger environment coupling with the local Lingma plugin
- remote credential detection depends on local Lingma cache / auth file layout
- image payloads are sanitized in persisted request logs to avoid oversized local state
- request history may contain mixed models in remote mode when fallback is triggered or when different clients specify different models
---
## 9. Files to Read First
If you are extending the system, start here:
- `cmd/lingma-ipc-proxy/main.go`
- `internal/httpapi/server.go`
- `internal/service/service.go`
- `internal/lingmaipc/*`
- `internal/remote/*`
- `desktop/app.go`
- `desktop/main.go`
---
Document version: 2026-04-30

View File

@@ -1,424 +1,315 @@
# lingma-ipc-proxy 架构文档 # lingma-ipc-proxy 架构文档
本文档描述 lingma-ipc-proxy 的系统架构、工作原理和核心流程。 本文档描述 `lingma-ipc-proxy` 的当前架构,覆盖两种后端模式:
- `ipc`:桥接本地 Lingma IDE 插件传输层
- `remote`:直接调用 Lingma 远端 HTTP API
--- ---
## 1. 整体架构 ## 1. 系统总览
``` ```mermaid
┌─────────────────────────────────────────────────────────────────────────┐ flowchart LR
│ 客户端层 │ A["客户端<br/>Claude Code / Hermes / Cline / Continue / OpenAI SDK / Anthropic SDK"]
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ B["internal/httpapi<br/>OpenAI + Anthropic 兼容路由"]
│ Claude Code │ │ OpenAI │ │ Cline │ │ Continue │ │ C["internal/service<br/>请求归一化 / 会话策略 / 流式输出 / 兜底"]
(Anthropic) │ │ SDK │ │ (OpenAI) │ │ (OpenAI) │ │ D["internal/toolemulation<br/>工具提示词注入 + action block 解析"]
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │ E["internal/lingmaipc<br/>WebSocket / 命名管道"]
└─────────┼─────────────────┼─────────────────┼─────────────────┼─────────┘ F["internal/remote<br/>登录态探测 / 模型列表 / Chat / SSE"]
│ │ │ │ G["Lingma 插件本地进程"]
└─────────────────┴────────┬────────┴─────────────────┘ H["Lingma 远端 API"]
│ HTTP I["桌面端 GUI<br/>Wails / 日志 / Token 统计 / 持久化状态"]
┌─────────────────────────────────────────────────────────────────────────┐ A --> B
│ lingma-ipc-proxy │ I --> B
┌─────────────────────────────────────────────────────────────────┐ │ B --> C
│ │ internal/httpapi │ │ C --> D
┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │ │ C --> E
│ /v1/models │ │/v1/chat/comp│ │ /v1/messages │ │ │ C --> F
│ │ (GET) │ │ (POST) │ │ (POST) │ │ │ E --> G
└──────┬──────┘ └──────┬──────┘ └───────────┬─────────────┘ │ │ F --> H
│ │ └─────────────────┴──────────┬──────────┘ │ │
│ │ │ normalizeRequest │ │
│ │ ▼ │ │
│ │ ┌─────────────────────────────────────────────────────────┐ │ │
│ │ │ internal/service │ │ │
│ │ │ ┌──────────┐ ┌──────────┐ ┌────────────────────────┐ │ │ │
│ │ │ │ Session │ │ Prompt │ │ Stream/Event │ │ │ │
│ │ │ │ Manager │ │ Builder │ │ Handler │ │ │ │
│ │ │ └────┬─────┘ └────┬─────┘ └───────────┬────────────┘ │ │ │
│ │ │ └─────────────┴──────────┬─────────┘ │ │ │
│ │ │ │ buildLingmaPrompt │ │ │
│ │ │ ▼ │ │ │
│ │ │ ┌─────────────────────────────────────────────────┐ │ │ │
│ │ │ │ internal/lingmaipc │ │ │ │
│ │ │ │ ┌──────────────┐ ┌──────────────────────────┐ │ │ │ │
│ │ │ │ │ WebSocket │ │ Named Pipe (Win) │ │ │ │ │
│ │ │ │ │ Transport │ │ Transport │ │ │ │ │
│ │ │ │ └──────┬───────┘ └───────────┬──────────────┘ │ │ │ │
│ │ │ └─────────┼──────────────────────┼────────────────┘ │ │ │
│ │ └────────────┼──────────────────────┼────────────────────┘ │ │
│ │ │ │ │ │
│ │ ┌────────────┼──────────────────────┼────────────────────┐ │ │
│ │ │ ▼ ▼ │ │ │
│ │ │ ┌─────────────────────────────────────────────────┐ │ │ │
│ │ │ │ internal/toolemulation │ │ │ │
│ │ │ │ ┌──────────────┐ ┌──────────────────────────┐ │ │ │ │
│ │ │ │ │InjectTooling │ │ ParseActionBlocks │ │ │ │ │
│ │ │ │ │ (Prompt) │ │ (Response) │ │ │ │ │
│ │ │ │ └──────────────┘ └──────────────────────────┘ │ │ │ │
│ │ │ └─────────────────────────────────────────────────┘ │ │ │
│ │ └───────────────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
│ WebSocket / Named Pipe
┌─────────────────────────────────────────────────────────────────────────┐
│ Lingma 后端进程 │
│ (VS Code 插件的本地 IPC 服务) │
│ ws://127.0.0.1:8899/ws │
└─────────────────────────────────────────────────────────────────────────┘
│ HTTP API
┌─────────────────────────────────────────────────────────────────────────┐
│ 云端模型服务 │
│ (Kimi-K2.6 / Qwen3-Max / MiniMax-M2.7 等) │
└─────────────────────────────────────────────────────────────────────────┘
``` ```
--- ---
## 2. 模块职责 ## 2. 运行模式
### 2.1 internal/httpapi ### 2.1 IPC 模式
HTTP API 适配层,负责将外部请求转换为内部 `service.ChatRequest` `backend=ipc`
| 端点 | 协议 | 功能 | - 读取本地 Lingma 插件传输信息
|------|------|------| - 通过以下方式连接:
| `GET /v1/models` | OpenAI | 返回可用模型列表 | - macOS / LinuxWebSocket
| `POST /v1/chat/completions` | OpenAI | 聊天补全(流式/非流式) | - WindowsNamed Pipe
| `POST /v1/messages` | Anthropic | 消息接口(流式/非流式) | - 复用 Lingma 插件自身的 session 语义
- 桌面端里“会话与环境”相关配置只在这里生效
**核心函数:** ### 2.2 Remote API 模式
- `handleOpenAIChatCompletions()` - 处理 OpenAI 格式请求
- `handleAnthropicMessages()` - 处理 Anthropic 格式请求
- `normalizeOpenAIRequest()` / `normalizeAnthropicRequest()` - 归一化请求
**关键设计:** `backend=remote`
- 支持 CORS 预检请求 (`OPTIONS`)
- 单请求并发控制 (`tryAcquire()` / `release()`)
- 流式响应通过 `http.Flusher` 实现 SSE
### 2.2 internal/service - 解析远端域名
- 加载认证信息:
业务逻辑层,负责会话管理和 Prompt 构建。 - 显式指定的 `remote_auth_file`
- 或自动探测 `~/.lingma` 下的缓存
**核心结构:** - 直接请求远端模型列表和聊天接口
```go - 支持远端超时 / 429 / 5xx 的模型兜底切换
type Service struct { - 不依赖本地插件会话环境参数
cfg Config
client *lingmaipc.Client
stickySessionID string
stickyModelID string
}
```
**核心函数:**
- `Generate()` - 非流式生成
- `GenerateStream()` - 流式生成(返回 `events` + `done` channel
- `buildLingmaPrompt()` - 构建 Lingma 原生 Prompt
- `runPromptLocked()` - 发送 `session/prompt` RPC 并监听 `session/update` 通知
**会话模式:**
| 模式 | 行为 |
|------|------|
| `reuse` | 复用 sticky session多轮对话保持上下文 |
| `fresh` | 每个请求新建临时 session完成后删除 |
| `auto` | 单轮请求复用;带 system/history 的请求用 fresh |
### 2.3 internal/lingmaipc
IPC 通信层,负责与 Lingma 后端进程建立连接。
**传输方式:**
| 平台 | 默认传输 | 说明 |
|------|----------|------|
| Windows | Named Pipe | `\\.\pipe\lingma-*` |
| macOS/Linux | WebSocket | `ws://127.0.0.1:{port}/ws` |
**连接发现:**
- 读取 VS Code 插件缓存:`~/.config/Lingma/SharedClientCache/.info.json`
- 获取 WebSocket 端口号
- 自动重连机制
**RPC 协议:**
- `session/new` - 创建会话
- `session/prompt` - 发送用户消息
- `session/update` - 接收流式响应通知
- `session/set_model` - 切换模型
- `chat/deleteSessionById` - 删除会话
### 2.4 internal/toolemulation
Tool 调用模拟层,将标准 `tools` 协议转换为 Prompt 层契约。
**核心流程:**
```
Client tools ──→ ExtractAnthropicTools() ──→ []Tool
InjectTooling() ──→ System Prompt + Tool 说明
模型输出 action block
ParseActionBlocks() ──→ []ToolCall
编码为 Anthropic tool_use / OpenAI tool_calls
```
**Prompt 契约格式:**
```
```json action
{"tool":"NAME","parameters":{"key":"value"}}
```
```
**支持格式:**
- `{"tool":"X","parameters":{}}` ✅ 标准格式
- `{"tool":"X","arguments":{}}` ✅ 兼容格式
- `{"tool":"X","input":{}}` ✅ 兼容格式
- `{"tool":"X","arg1":"val"}` ✅ 顶层参数(部分模型)
--- ---
## 3. 核心流程 ## 3. 模块职责
### 3.1 普通聊天请求流程 ### 3.1 `cmd/lingma-ipc-proxy`
入口与配置装配层。
职责:
- 解析命令行参数
- 合并配置文件 / 环境变量 / CLI flags
- 选择后端模式
- 构建 `service.Config`
- 启动 `internal/httpapi.Server`
关键配置字段:
- `backend`
- `transport`
- `websocket_url`
- `pipe`
- `remote_base_url`
- `remote_auth_file`
- `remote_version`
- `remote_fallback_enabled`
- `remote_fallback_models`
### 3.2 `internal/httpapi`
OpenAI / Anthropic 兼容层。
主要路由:
- `GET /v1/models`
- `POST /v1/chat/completions`
- `POST /v1/messages`
- `GET /health`
- `GET /props`
职责:
- 把 OpenAI / Anthropic 请求归一化为 `service.ChatRequest`
- 把 service 结果重新编码成 OpenAI / Anthropic 响应
- 输出 SSE 流
- 记录调试用请求 / 响应摘要
### 3.3 `internal/service`
核心编排层。
职责:
- 选择当前 backend
- backend 预热
- 拉取模型列表
- 非流式生成
- 流式生成
- IPC 模式下的 session 复用策略
- 工具模拟注入与解析
- 图片输入归一化
- 远端 fallback 顺序控制
分支逻辑:
- IPC 路径走 `internal/lingmaipc`
- Remote 路径走 `internal/remote`
### 3.4 `internal/lingmaipc`
本地 Lingma 插件 IPC 客户端。
职责:
- 自动探测 WebSocket / pipe 端点
- 建立连接与重连
- 发送 RPC
- `session/new`
- `session/prompt`
- `session/set_model`
- `chat/deleteSessionById`
- 消费 `session/update` 通知
### 3.5 `internal/remote`
Lingma 远端 HTTP 客户端。
职责:
- 解析远端 base URL
- 加载并校验登录态
- 生成远端请求所需身份信息
- 获取远端模型列表
- 调用远端聊天接口
- 处理远端 SSE 流式响应
### 3.6 `internal/toolemulation`
工具调用模拟层。
职责:
- 从 OpenAI / Anthropic 请求中提取工具定义
- 将工具契约注入 prompt
- 从模型文本里解析 JSON action block
- 回投为:
- Anthropic `tool_use`
- OpenAI `tool_calls`
---
## 4. 请求主流程
### 4.1 通用入口
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
participant C as Client participant Client as Client
participant H as HTTP API participant HTTP as httpapi
participant S as Service participant Service as service
participant L as Lingma IPC
participant B as Lingma Backend
C->>H: POST /v1/messages Client->>HTTP: OpenAI / Anthropic 请求
H->>H: normalizeAnthropicRequest() HTTP->>HTTP: 归一化请求
H->>S: GenerateStream(req) HTTP->>Service: Generate / GenerateStream
S->>S: ensureConnected()
S->>S: resolveSession()
S->>S: buildLingmaPrompt()
S->>L: Send("session/prompt", params)
L->>B: WebSocket RPC
B->>L: session/update (agent_message_chunk)
loop 流式响应
L->>S: notification (chunk)
S->>H: events <- StreamEvent{Delta}
H->>C: SSE: content_block_delta
end
B->>L: session/update (chat_finish)
L->>S: notification (finish)
S->>H: done <- StreamResult
H->>C: SSE: message_stop
``` ```
### 3.2 Tool 调用流程 ### 4.2 IPC 后端流程
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
participant C as Client participant Service as service
participant H as HTTP API participant Tool as toolemulation
participant T as ToolEmulation participant IPC as lingmaipc
participant S as Service participant Plugin as Lingma 插件
participant L as Lingma IPC
C->>H: POST /v1/messages (with tools) Service->>Tool: 按需注入工具契约
H->>T: ExtractAnthropicTools() Service->>IPC: ensure connected
H->>S: GenerateStream(req) Service->>IPC: 创建/复用 session
S->>T: InjectTooling(system, tools) Service->>IPC: session/prompt
S->>L: session/prompt (with tool prompt) IPC->>Plugin: RPC
L->>S: response (with action blocks) Plugin-->>IPC: session/update chunk
S->>T: ParseActionBlocks(text) IPC-->>Service: 流式事件
T->>S: []ToolCall Service-->>Service: 解析工具 block / 图片 / stop reason
S->>H: ChatResult{Text, ToolCalls}
H->>C: SSE: tool_use blocks
C->>H: POST /v1/messages (tool_result)
H->>T: ActionOutputPrompt(toolUseID, content)
H->>S: GenerateStream(req)
S->>L: session/prompt (with tool result)
L->>S: response
S->>H: ChatResult
H->>C: SSE: final response
``` ```
### 3.3 图片传输流程 ### 4.3 Remote 后端流程
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
participant C as Client participant Service as service
participant H as HTTP API participant Remote as remote client
participant S as Service participant API as Lingma 远端 API
participant L as Lingma IPC
C->>H: POST /v1/messages (with image) Service->>Remote: 加载登录态 / 初始化 client
H->>H: extractAnthropicImages() Service->>Remote: 需要时拉取模型列表
H->>S: ChatRequest{Images: [...]} Service->>Remote: 发送 chat 请求
S->>S: runPromptLocked() Remote->>API: HTTPS
Note over S: 1. 保存 base64 到 /tmp/lingma-img-*.ext API-->>Remote: JSON 或 SSE
Note over S: 2. 构建 URI: lingma:///agent/file?path=... Remote-->>Service: 归一化结果
S->>L: session/prompt Service-->>Service: 按条件执行 fallback
Note over L: prompt: [{type:"text"}, {type:"image", mimeType, uri, data}]
L->>S: response (model sees image)
S->>H: ChatResult
H->>C: SSE response
```
### 3.4 流式输出 SSE 事件序列
**Anthropic 格式(流式):**
```
event: message_start
data: {"type":"message_start","message":{...}}
event: content_block_start
data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}
event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"你"}}
event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"好"}}
... (更多 delta)
event: content_block_stop
data: {"type":"content_block_stop","index":0}
[如有 tool_calls]
event: content_block_start
data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"...","name":"Bash","input":{"command":"ls /"}}}
event: content_block_stop
data: {"type":"content_block_stop","index":1}
event: message_delta
data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":5}}
event: message_stop
data: {"type":"message_stop"}
``` ```
--- ---
## 4. 关键技术决策 ## 5. 远端兜底策略
### 4.1 为什么使用 Tool Emulation 而非原生 Tool Calling 仅在以下条件同时满足时启用:
Lingma 后端模型Kimi、Qwen 等)不原生支持 OpenAI/Anthropic 的 `tools` 协议。因此代理层需要将工具定义注入到 Prompt 中,通过结构化文本输出模拟工具调用。 - `backend=remote`
- `remote_fallback_enabled=true`
- 还没有向客户端输出任何流式 token
- 上游错误属于 timeout / 429 / 5xx
**优点:** 当前默认顺序:
- 不依赖上游模型能力
- 兼容任何纯聊天模型
- 可精确控制 Prompt 格式
**缺点:** 1. `kmodel`
- 模型需要学习特定格式 2. `mmodel`
- 解析可能有容错问题 3. `dashscope_qwen3_coder`
- 增加了 Prompt 长度 4. `dashscope_qmodel`
5. `dashscope_qwen_max_latest`
6. `dashscope_qwen_plus_20250428_thinking`
### 4.2 为什么使用 WebSocket/Named Pipe 而非 HTTP 实际执行前service 会先拿远端 `/v1/models` 的真实结果过滤一遍,只保留当前账号真的可用的模型。
Lingma 插件使用本地 IPC 与后端通信,优势:
- 低延迟(本地通信)
- 双向实时通知session/update
- 认证信息由插件管理,代理无需处理
### 4.3 图片传输的双保险策略
```
Prompt 数组 (Lingma 原生格式):
[
{"type":"text","text":"..."},
{"type":"image","mimeType":"image/png","uri":"lingma:///agent/file?path=...","data":"base64..."}
]
```
- `uri`: Lingma 后端必须验证的本地文件路径
- `data`: base64 编码的图像数据(备用)
- `mimeType`: 图像类型标识
### 4.4 单请求并发控制
Lingma IPC 一次只能处理一个请求,因此代理使用 `tryAcquire()` 机制:
```go
if !s.tryAcquire() {
writeAnthropicError(w, 429, "rate_limit_error",
"Lingma IPC proxy handles one request at a time.")
return
}
defer s.release()
```
--- ---
## 5. 配置说明 ## 6. 桌面端架构
### 5.1 配置文件结构 Wails 桌面端不是简单预览壳,而是本地代理的运维控制台。
```json 职责:
{
"host": "127.0.0.1",
"port": 8095,
"transport": "websocket",
"mode": "agent",
"shell_type": "zsh",
"session_mode": "auto",
"timeout": 120,
"cwd": "/Users/tiancheng"
}
```
### 5.2 配置项说明 - 启动 / 停止 / 重启代理
- 展示当前 backend、监听地址、探测结果
- 持久化:
- 请求历史
- 日志
- Token 统计
- 编辑配置并保存后按需重启
| 配置项 | 类型 | 默认值 | 说明 | 本地持久化路径:
|--------|------|--------|------|
| `host` | string | `127.0.0.1` | HTTP 监听地址 | - 配置:`~/.config/lingma-ipc-proxy/config.json`
| `port` | int | `8095` | HTTP 监听端口 | - GUI 运行状态:`~/.config/lingma-ipc-proxy/app-state.json`
| `transport` | string | `auto` | IPC 传输方式:`auto`/`pipe`/`websocket` |
| `mode` | string | `chat` | 模式:`chat`/`agent` | 打包要求:
| `shell_type` | string | `powershell` | 终端类型 |
| `session_mode` | string | `auto` | 会话模式:`reuse`/`fresh`/`auto` | - 生产包不自动打开 Inspector / 调试入口
| `timeout` | int | `120` | 请求超时(秒) | - 本地开发可通过 `LINGMA_DESKTOP_DEBUG=1` 显式开启
| `cwd` | string | `""` | 工作目录(传给 Lingma 后端) |
--- ---
## 6. 扩展点 ## 7. 关键设计决策
### 6.1 添加新模型 ### 7.1 为什么同时保留 IPC 和 Remote
`service.go` 的模型映射中添加 因为两种模式解决的问题不同
```go - IPC 模式更贴近插件本地上下文和 session 语义
func (s *Service) resolveInternalModelID(model string) string { - Remote 模式更适合第三方 agent 客户端,减少对插件运行态的依赖
switch strings.ToLower(strings.TrimSpace(model)) {
case "kimi-k2.6":
return "kimi2.6"
case "qwen3-max":
return "qwen3max"
// 添加新模型映射
default:
return ""
}
}
```
### 6.2 添加新 Tool 格式支持 ### 7.2 为什么 Remote 也保留 Tool Emulation
`toolemulation.go``parseToolCallJSON()` 中扩展参数解析逻辑 因为 Lingma 暴露出来的模型能力并不保证始终稳定兼容 OpenAI / Anthropic 原生 tools 协议。代理层必须对外提供稳定契约,不能把上游模型差异直接泄露给客户端
### 6.3 添加新 API 端点 ### 7.3 为什么桌面端要持久化请求和 Token
`httpapi/server.go``NewServer()` 中注册新路由 因为这个 GUI 已经是运维面板,不是一次性调试页。重启后仍然需要保留最近请求、日志和 usage 统计,便于排障和观察模型表现
--- ---
*文档版本: 2025-04-25* ## 8. 当前边界
*对应代码版本: 当前 master*
- IPC 模式仍然受本地 Lingma 插件运行态影响
- Remote 登录态探测依赖本地 Lingma 缓存结构
- 图片类请求在本地持久化时会做裁剪/脱敏,避免状态文件过大
- Remote 模式下如果启用了 fallback最近一次“聊天模型”可能与客户端最初指定模型不同
---
## 9. 代码入口建议
如果要继续扩展,优先看这些文件:
- `cmd/lingma-ipc-proxy/main.go`
- `internal/httpapi/server.go`
- `internal/service/service.go`
- `internal/lingmaipc/*`
- `internal/remote/*`
- `desktop/app.go`
- `desktop/main.go`
---
文档版本2026-04-30

View File

@@ -1400,9 +1400,6 @@ func redactRecordedValue(value any) any {
if looksLikeImagePayload(typed) { if looksLikeImagePayload(typed) {
return imageRedaction(typed) return imageRedaction(typed)
} }
if len(typed) > 12000 {
return typed[:12000] + "... [truncated]"
}
return typed return typed
default: default:
return typed return typed
@@ -1443,11 +1440,7 @@ func mustMarshalJSON(value any) []byte {
} }
func truncateRecordedString(value string) string { func truncateRecordedString(value string) string {
const maxRecordedBody = 120000 return value
if len(value) <= maxRecordedBody {
return value
}
return value[:maxRecordedBody] + "... [truncated]"
} }
func withCORS(next http.Handler) http.Handler { func withCORS(next http.Handler) http.Handler {

View File

@@ -76,7 +76,7 @@ func New(cfg Config) *Client {
cfg.CosyVersion = "2.11.2" cfg.CosyVersion = "2.11.2"
} }
if cfg.Timeout <= 0 { if cfg.Timeout <= 0 {
cfg.Timeout = 120 * time.Second cfg.Timeout = 300 * time.Second
} }
cfg.BaseURL = strings.TrimRight(cfg.BaseURL, "/") cfg.BaseURL = strings.TrimRight(cfg.BaseURL, "/")
return &Client{cfg: cfg, client: &http.Client{Timeout: cfg.Timeout}} return &Client{cfg: cfg, client: &http.Client{Timeout: cfg.Timeout}}

View File

@@ -35,22 +35,24 @@ const (
) )
type Config struct { type Config struct {
Host string Host string
Port int Port int
Backend BackendMode Backend BackendMode
Transport lingmaipc.Transport Transport lingmaipc.Transport
Pipe string Pipe string
WebSocketURL string WebSocketURL string
RemoteBaseURL string RemoteBaseURL string
RemoteAuthFile string RemoteAuthFile string
RemoteVersion string RemoteVersion string
Cwd string Cwd string
CurrentFilePath string CurrentFilePath string
Mode string Mode string
Model string Model string
ShellType string ShellType string
SessionMode SessionMode SessionMode SessionMode
Timeout time.Duration Timeout time.Duration
RemoteFallbackEnabled bool
RemoteFallbackModels []string
} }
type Image struct { type Image struct {
@@ -166,7 +168,7 @@ func New(cfg Config) *Service {
cfg.ShellType = lingmaipc.DefaultShellType() cfg.ShellType = lingmaipc.DefaultShellType()
} }
if cfg.Timeout <= 0 { if cfg.Timeout <= 0 {
cfg.Timeout = 120 * time.Second cfg.Timeout = 300 * time.Second
} }
if cfg.Transport == "" { if cfg.Transport == "" {
cfg.Transport = lingmaipc.TransportAuto cfg.Transport = lingmaipc.TransportAuto
@@ -174,6 +176,11 @@ func New(cfg Config) *Service {
if cfg.Backend == "" { if cfg.Backend == "" {
cfg.Backend = BackendRemote cfg.Backend = BackendRemote
} }
if cfg.Backend == BackendRemote {
if len(cfg.RemoteFallbackModels) == 0 {
cfg.RemoteFallbackModels = DefaultRemoteFallbackModels()
}
}
cfg.Model = normalizeModelForBackend(cfg.Backend, cfg.Model) cfg.Model = normalizeModelForBackend(cfg.Backend, cfg.Model)
if cfg.SessionMode == "" { if cfg.SessionMode == "" {
cfg.SessionMode = SessionModeAuto cfg.SessionMode = SessionModeAuto
@@ -181,6 +188,17 @@ func New(cfg Config) *Service {
return &Service{cfg: cfg} return &Service{cfg: cfg}
} }
func DefaultRemoteFallbackModels() []string {
return []string{
"kmodel",
"mmodel",
"dashscope_qwen3_coder",
"dashscope_qmodel",
"dashscope_qwen_max_latest",
"dashscope_qwen_plus_20250428_thinking",
}
}
func (s *Service) SetDefaultModel(model string) { func (s *Service) SetDefaultModel(model string) {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
@@ -331,9 +349,6 @@ func (s *Service) generateRemote(
req ChatRequest, req ChatRequest,
onDelta func(string), onDelta func(string),
) (*ChatResult, error) { ) (*ChatResult, error) {
requestCtx, cancel := context.WithTimeout(ctx, s.cfg.Timeout)
defer cancel()
if strings.TrimSpace(req.Model) == "" { if strings.TrimSpace(req.Model) == "" {
req.Model = s.DefaultModel() req.Model = s.DefaultModel()
} }
@@ -346,20 +361,54 @@ func (s *Service) generateRemote(
return nil, errors.New("empty user message") return nil, errors.New("empty user message")
} }
models := s.remoteAttemptModels(ctx, req.Model)
client := s.remoteClientLocked() client := s.remoteClientLocked()
remoteResult, err := client.Chat(requestCtx, remote.ChatRequest{ var lastErr error
Model: req.Model, for i, model := range models {
attemptCtx, cancel := context.WithTimeout(ctx, s.cfg.Timeout)
result, emitted, err := s.generateRemoteWithModel(attemptCtx, client, req, prompt, model, onDelta)
cancel()
if err == nil {
return result, nil
}
lastErr = err
if i == len(models)-1 || emitted || !isRemoteFallbackError(err) {
return nil, err
}
}
return nil, lastErr
}
func (s *Service) generateRemoteWithModel(
ctx context.Context,
client *remote.Client,
req ChatRequest,
prompt string,
model string,
onDelta func(string),
) (*ChatResult, bool, error) {
emitted := false
delta := func(text string) {
if text != "" {
emitted = true
}
if onDelta != nil {
onDelta(text)
}
}
remoteResult, err := client.Chat(ctx, remote.ChatRequest{
Model: model,
Prompt: prompt, Prompt: prompt,
Stream: onDelta != nil, Stream: onDelta != nil,
Temperature: req.Temperature, Temperature: req.Temperature,
}, onDelta) }, delta)
if err != nil { if err != nil {
return nil, err return nil, emitted, err
} }
result := &ChatResult{ result := &ChatResult{
Text: remoteResult.Text, Text: remoteResult.Text,
Model: valueOr(strings.TrimSpace(req.Model), "lingma"), Model: valueOr(strings.TrimSpace(model), "lingma"),
InputTokens: remoteResult.InputTokens, InputTokens: remoteResult.InputTokens,
OutputTokens: remoteResult.OutputTokens, OutputTokens: remoteResult.OutputTokens,
SessionID: "", SessionID: "",
@@ -370,9 +419,9 @@ func (s *Service) generateRemote(
Transport: "remote", Transport: "remote",
EffectiveSession: SessionModeFresh, EffectiveSession: SessionModeFresh,
} }
s.applyToolEmulation(requestCtx, req, prompt, result, onDelta, func(hintPrompt string) (string, int, error) { s.applyToolEmulation(ctx, req, prompt, result, onDelta, func(hintPrompt string) (string, int, error) {
retryResult, retryErr := client.Chat(requestCtx, remote.ChatRequest{ retryResult, retryErr := client.Chat(ctx, remote.ChatRequest{
Model: req.Model, Model: model,
Prompt: hintPrompt, Prompt: hintPrompt,
Stream: onDelta != nil, Stream: onDelta != nil,
Temperature: req.Temperature, Temperature: req.Temperature,
@@ -385,7 +434,78 @@ func (s *Service) generateRemote(
} }
return retryResult.Text, retryResult.OutputTokens, nil return retryResult.Text, retryResult.OutputTokens, nil
}) })
return result, nil return result, emitted, nil
}
func (s *Service) remoteAttemptModels(ctx context.Context, primary string) []string {
primary = normalizeModelForBackend(BackendRemote, primary)
models := []string{primary}
if !s.cfg.RemoteFallbackEnabled {
return models
}
availableCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
remoteModels, err := s.remoteClientLocked().ListModels(availableCtx)
cancel()
if err != nil {
return models
}
available := make(map[string]bool, len(remoteModels))
for _, model := range remoteModels {
key := normalizeModelForBackend(BackendRemote, model.Key)
if key != "" {
available[key] = true
}
}
fallbackModels := s.cfg.RemoteFallbackModels
if len(fallbackModels) == 0 {
fallbackModels = DefaultRemoteFallbackModels()
}
ordered := make([]string, 0, len(fallbackModels))
seen := map[string]bool{primary: true}
primaryIndex := -1
for _, candidate := range fallbackModels {
model := normalizeModelForBackend(BackendRemote, candidate)
if model == "" {
continue
}
if model == primary && primaryIndex == -1 {
primaryIndex = len(ordered)
}
ordered = append(ordered, model)
}
start := 0
if primaryIndex >= 0 {
start = primaryIndex + 1
}
for _, model := range ordered[start:] {
if seen[model] || !available[model] {
continue
}
seen[model] = true
models = append(models, model)
}
return models
}
func isRemoteFallbackError(err error) bool {
if err == nil {
return false
}
if errors.Is(err, context.DeadlineExceeded) {
return true
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "context deadline exceeded") ||
strings.Contains(msg, "client.timeout") ||
strings.Contains(msg, "timeout awaiting response") ||
strings.Contains(msg, "remote chat status 5") ||
strings.Contains(msg, "remote chat status 429") ||
strings.Contains(msg, "connection reset") ||
strings.Contains(msg, "unexpected eof")
} }
func (s *Service) generateLocked( func (s *Service) generateLocked(

View File

@@ -7,6 +7,15 @@
"model": "kmodel", "model": "kmodel",
"shell_type": "zsh", "shell_type": "zsh",
"session_mode": "auto", "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" "cwd": "/Users/tiancheng"
} }