Files
lingma-proxy-compose/desktop/app.go
2026-05-06 17:52:42 +08:00

1107 lines
30 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"os"
"path/filepath"
goruntime "runtime"
"strings"
"sync"
"time"
"lingma-ipc-proxy/internal/httpapi"
"lingma-ipc-proxy/internal/lingmaipc"
"lingma-ipc-proxy/internal/remote"
"lingma-ipc-proxy/internal/service"
"github.com/wailsapp/wails/v2/pkg/options"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
// App struct
// RequestRecord stores a single HTTP request summary
type RequestRecord struct {
Time string `json:"time"`
Method string `json:"method"`
Path string `json:"path"`
Model string `json:"model,omitempty"`
StatusCode int `json:"statusCode"`
Duration string `json:"duration"`
Size string `json:"size,omitempty"`
InputTokens int `json:"inputTokens,omitempty"`
OutputTokens int `json:"outputTokens,omitempty"`
TotalTokens int `json:"totalTokens,omitempty"`
ReqBody string `json:"reqBody,omitempty"`
RespBody string `json:"respBody,omitempty"`
}
type AppLog struct {
Time string `json:"time"`
Level string `json:"level"`
Message string `json:"message"`
}
type TokenStats struct {
TotalRequests int `json:"totalRequests"`
SuccessRequests int `json:"successRequests"`
InputTokens int `json:"inputTokens"`
OutputTokens int `json:"outputTokens"`
TotalTokens int `json:"totalTokens"`
ByModel map[string]int `json:"byModel,omitempty"`
LastModel string `json:"lastModel,omitempty"`
LastUpdated string `json:"lastUpdated,omitempty"`
}
type App struct {
ctx context.Context
mu sync.RWMutex
cfg service.Config
server *httpapi.Server
running bool
quitting bool
addr string
startedAt time.Time
quitHint time.Time
models []ModelInfo
requests []RequestRecord
logs []AppLog
stats TokenStats
}
// ModelInfo represents a model returned by /v1/models
type ModelInfo struct {
ID string `json:"id"`
Name string `json:"name"`
}
// ProxyStatus represents the current proxy status
type ProxyStatus struct {
Running bool `json:"running"`
Addr string `json:"addr"`
Backend string `json:"backend"`
Models int `json:"models"`
Model string `json:"model,omitempty"`
StartedAt string `json:"startedAt,omitempty"`
}
// DetectionInfo exposes non-sensitive resolved connection details for the UI.
type DetectionInfo struct {
ListenURL string `json:"listenUrl"`
Backend string `json:"backend"`
BackendLabel string `json:"backendLabel"`
IPCSuccess bool `json:"ipcSuccess"`
IPCTransport string `json:"ipcTransport,omitempty"`
IPCEndpoint string `json:"ipcEndpoint,omitempty"`
IPCError string `json:"ipcError,omitempty"`
RemoteBaseURL string `json:"remoteBaseUrl"`
RemoteBaseURLSource string `json:"remoteBaseUrlSource,omitempty"`
RemoteCredentialSuccess bool `json:"remoteCredentialSuccess"`
RemoteCredentialSource string `json:"remoteCredentialSource,omitempty"`
RemoteUserID string `json:"remoteUserId,omitempty"`
RemoteMachineID string `json:"remoteMachineId,omitempty"`
RemoteTokenExpireAt string `json:"remoteTokenExpireAt,omitempty"`
RemoteTokenExpired bool `json:"remoteTokenExpired"`
RemoteCredentialError string `json:"remoteCredentialError,omitempty"`
}
// NewApp creates a new App application struct
func NewApp() *App {
return &App{}
}
// startup is called when the app starts
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
a.cfg = defaultConfig()
if err := a.loadAppState(); err != nil {
runtime.LogWarningf(a.ctx, "failed to load app state: %v", err)
}
// Auto-save default config on first run so users can find/edit it later
if err := a.saveConfig(a.cfg); err != nil {
runtime.LogWarningf(a.ctx, "failed to save default config: %v", err)
}
// Auto-start proxy so the app is usable immediately
go func() {
if err := a.StartProxy(); err != nil {
a.emitLog("error", fmt.Sprintf("Auto-start failed: %v. %s", err, transportFallbackHint()))
} else {
a.emitLog("info", "Proxy auto-started")
}
}()
}
// onDomReady is called when the frontend DOM is ready
func (a *App) onDomReady(ctx context.Context) {
a.ctx = ctx
}
// onSecondInstanceLaunch is called when user clicks the dock icon while app is already running.
// We show the window so the user can interact with it again.
func (a *App) onSecondInstanceLaunch(secondInstanceData options.SecondInstanceData) {
a.ShowWindow()
}
// beforeClose hides the window by default so the proxy can keep running.
// QuitApp sets quitting=true before allowing the process to exit.
func (a *App) beforeClose(ctx context.Context) bool {
a.mu.Lock()
if a.quitting {
a.mu.Unlock()
return true
}
now := time.Now()
if !a.quitHint.IsZero() && now.Sub(a.quitHint) <= 2*time.Second {
a.mu.Unlock()
go a.forceQuit()
return true
}
a.quitHint = now
a.mu.Unlock()
message := "再按一次退出快捷键将停止代理并退出应用"
a.emitLog("warn", message)
runtime.EventsEmit(a.ctx, "quit:confirm", message)
return true
}
// ShowWindow shows the main window
func (a *App) ShowWindow() {
runtime.Show(a.ctx)
runtime.WindowShow(a.ctx)
runtime.WindowUnminimise(a.ctx)
}
// HideWindow hides the main window
func (a *App) HideWindow() {
runtime.Hide(a.ctx)
}
// MinimizeWindow minimises the main window.
func (a *App) MinimizeWindow() {
runtime.WindowMinimise(a.ctx)
}
func (a *App) beginQuit() {
go a.forceQuit()
}
// QuitApp fully quits the application
func (a *App) QuitApp() {
a.beginQuit()
}
// ForceQuitApp stops the proxy and exits the desktop process immediately.
func (a *App) ForceQuitApp() {
a.beginQuit()
}
// RequestQuitShortcut requires two shortcut presses to avoid accidental exits.
func (a *App) RequestQuitShortcut() {
now := time.Now()
a.mu.Lock()
shouldQuit := !a.quitHint.IsZero() && now.Sub(a.quitHint) <= 2*time.Second
a.quitHint = now
a.mu.Unlock()
if shouldQuit {
go a.forceQuit()
return
}
message := "再按一次退出快捷键将停止代理并退出应用"
a.emitLog("warn", message)
runtime.EventsEmit(a.ctx, "quit:confirm", message)
}
func (a *App) forceQuit() {
a.mu.Lock()
if a.quitting {
a.mu.Unlock()
return
}
a.quitting = true
a.mu.Unlock()
a.emitLog("info", "正在停止代理并退出应用")
done := make(chan struct{})
go func() {
if err := a.StopProxy(); err != nil {
runtime.LogWarningf(a.ctx, "stop proxy before exit failed: %v", err)
}
close(done)
}()
select {
case <-done:
case <-time.After(1200 * time.Millisecond):
runtime.LogWarning(a.ctx, "force quit continuing before proxy shutdown completed")
}
os.Exit(0)
}
func (a *App) emitLog(level string, message string) {
entry := AppLog{
Time: time.Now().Format("15:04:05"),
Level: level,
Message: message,
}
a.mu.Lock()
a.logs = append(a.logs, entry)
if len(a.logs) > 2000 {
a.logs = a.logs[len(a.logs)-2000:]
}
a.saveAppStateLocked()
a.mu.Unlock()
runtime.EventsEmit(a.ctx, "log", entry)
}
// GetStatus returns the current proxy status
func (a *App) GetStatus() ProxyStatus {
a.mu.RLock()
defer a.mu.RUnlock()
startedAt := ""
if !a.startedAt.IsZero() {
startedAt = a.startedAt.Format(time.RFC3339)
}
return ProxyStatus{
Running: a.running,
Addr: a.addr,
Backend: string(a.cfg.Backend),
Models: len(a.models),
Model: a.cfg.Model,
StartedAt: startedAt,
}
}
// GetConfig returns the current configuration.
// Timeout is returned in seconds for frontend convenience.
func (a *App) GetConfig() service.Config {
a.mu.RLock()
cfg := a.cfg
a.mu.RUnlock()
cfg.Timeout = cfg.Timeout / time.Second
return cfg
}
// GetDetectionInfo returns resolved IPC/remote details without exposing tokens.
func (a *App) GetDetectionInfo() DetectionInfo {
a.mu.RLock()
cfg := a.cfg
addr := a.addr
a.mu.RUnlock()
if strings.TrimSpace(addr) == "" {
addr = fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
}
baseURL := remote.ResolveBaseURLWithSource(cfg.RemoteBaseURL)
info := DetectionInfo{
ListenURL: "http://" + addr,
Backend: string(cfg.Backend),
BackendLabel: backendLabel(cfg.Backend),
RemoteBaseURL: baseURL.URL,
RemoteBaseURLSource: baseURL.Source,
}
if opts, err := lingmaipc.ResolveDialOptions(cfg.Transport, cfg.Pipe, cfg.WebSocketURL); err == nil {
info.IPCSuccess = true
info.IPCTransport = string(opts.Transport)
switch opts.Transport {
case lingmaipc.TransportPipe:
info.IPCEndpoint = opts.PipePath
case lingmaipc.TransportWebSocket:
info.IPCEndpoint = opts.WebSocketURL
}
} else {
info.IPCError = err.Error()
}
if cred, err := remote.LoadCredential(cfg.RemoteAuthFile); err == nil {
info.RemoteCredentialSuccess = true
info.RemoteCredentialSource = cred.Source
info.RemoteUserID = maskIdentifier(cred.UserID)
info.RemoteMachineID = maskIdentifier(cred.MachineID)
info.RemoteTokenExpired = remote.IsExpired(cred, 0)
if cred.TokenExpireTime > 0 {
info.RemoteTokenExpireAt = time.UnixMilli(cred.TokenExpireTime).Format(time.RFC3339)
}
} else {
info.RemoteCredentialError = err.Error()
}
return info
}
// UpdateConfig updates the configuration, saves to file, and restarts the proxy if running.
// Frontend sends Timeout in seconds; we convert to time.Duration.
func (a *App) UpdateConfig(cfg service.Config) error {
// Convert seconds -> Duration if frontend sent a small value
if cfg.Timeout > 0 && cfg.Timeout < time.Second {
cfg.Timeout = cfg.Timeout * time.Second
}
a.mu.Lock()
wasRunning := a.running
a.cfg = cfg
a.mu.Unlock()
if err := a.saveConfig(cfg); err != nil {
runtime.LogWarningf(a.ctx, "failed to save config: %v", err)
a.emitLog("warn", fmt.Sprintf("Config updated but failed to save: %v", err))
} else {
a.emitLog("info", "Config saved to file")
}
if wasRunning {
if err := a.StopProxy(); err != nil {
return fmt.Errorf("stop failed: %w", err)
}
return a.StartProxy()
}
return nil
}
func (a *App) saveConfig(cfg service.Config) error {
home, err := os.UserHomeDir()
if err != nil {
return err
}
dir := filepath.Join(home, ".config", "lingma-proxy")
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
timeoutSec := int(cfg.Timeout.Seconds())
fileCfg := map[string]any{
"host": cfg.Host,
"port": cfg.Port,
"backend": string(cfg.Backend),
"transport": string(cfg.Transport),
"pipe": cfg.Pipe,
"websocket_url": cfg.WebSocketURL,
"remote_base_url": cfg.RemoteBaseURL,
"remote_auth_file": cfg.RemoteAuthFile,
"remote_version": cfg.RemoteVersion,
"cwd": cfg.Cwd,
"current_file_path": cfg.CurrentFilePath,
"mode": cfg.Mode,
"model": cfg.Model,
"shell_type": cfg.ShellType,
"session_mode": string(cfg.SessionMode),
"timeout": timeoutSec,
"remote_fallback_enabled": cfg.RemoteFallbackEnabled,
"remote_fallback_models": cfg.RemoteFallbackModels,
}
data, err := json.MarshalIndent(fileCfg, "", " ")
if err != nil {
return err
}
path := filepath.Join(dir, "config.json")
return os.WriteFile(path, data, 0644)
}
// StartProxy starts the lingma-ipc-proxy HTTP server
func (a *App) StartProxy() error {
a.mu.Lock()
if a.running {
a.mu.Unlock()
return fmt.Errorf("proxy already running")
}
addr := fmt.Sprintf("%s:%d", a.cfg.Host, a.cfg.Port)
cfg := a.cfg
a.mu.Unlock()
svc := service.New(cfg)
warmupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
if err := svc.Warmup(warmupCtx); err != nil {
runtime.LogWarningf(a.ctx, "warmup failed: %v", err)
a.emitLog("warn", fmt.Sprintf("%s warmup failed: %v. %s", backendLabel(cfg.Backend), err, warmupFallbackHint(cfg.Backend)))
} else {
runtime.LogInfof(a.ctx, "%s warmup completed", backendLabel(cfg.Backend))
a.emitLog("info", fmt.Sprintf("%s warmup completed", backendLabel(cfg.Backend)))
}
cancel()
server := httpapi.NewServer(addr, svc)
server.OnRequest = func(method, path string, statusCode int, duration time.Duration, reqBody, respBody string) {
inputTokens, outputTokens := extractTokenUsage(respBody)
model := extractRequestModel(reqBody)
record := RequestRecord{
Time: time.Now().Format("15:04:05"),
Method: method,
Path: path,
Model: model,
StatusCode: statusCode,
Duration: duration.Round(time.Millisecond).String(),
Size: formatPayloadSize(len(reqBody) + len(respBody)),
InputTokens: inputTokens,
OutputTokens: outputTokens,
TotalTokens: inputTokens + outputTokens,
ReqBody: reqBody,
RespBody: respBody,
}
a.mu.Lock()
a.requests = append(a.requests, record)
if len(a.requests) > 2000 {
a.requests = a.requests[len(a.requests)-2000:]
}
a.accumulateTokenStatsLocked(record)
a.saveAppStateLocked()
a.mu.Unlock()
runtime.EventsEmit(a.ctx, "requests:updated", a.GetRequests())
runtime.EventsEmit(a.ctx, "usage:updated", a.GetTokenStats())
}
// Check if the port is available before claiming we're running
ln, err := net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("port %s is already in use: %w", addr, err)
}
ln.Close()
go func() {
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
runtime.LogErrorf(a.ctx, "server error: %v", err)
a.emitLog("error", fmt.Sprintf("Server error: %v", err))
a.mu.Lock()
a.running = false
a.addr = ""
a.startedAt = time.Time{}
a.mu.Unlock()
}
}()
a.mu.Lock()
if a.running {
a.mu.Unlock()
return fmt.Errorf("proxy already running")
}
a.server = server
a.addr = addr
a.running = true
a.startedAt = time.Now()
a.mu.Unlock()
msg := fmt.Sprintf("Proxy started on http://%s", addr)
runtime.LogInfof(a.ctx, msg)
a.emitLog("info", msg)
// Fetch models in background
go a.fetchModels(addr)
return nil
}
func (a *App) GetLogs() []AppLog {
a.mu.RLock()
defer a.mu.RUnlock()
out := make([]AppLog, len(a.logs))
copy(out, a.logs)
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
out[i], out[j] = out[j], out[i]
}
return out
}
func (a *App) ClearLogs() {
a.mu.Lock()
a.logs = nil
a.saveAppStateLocked()
a.mu.Unlock()
runtime.EventsEmit(a.ctx, "logs:updated", a.GetLogs())
}
// StopProxy stops the proxy server
func (a *App) StopProxy() error {
a.mu.Lock()
if !a.running || a.server == nil {
a.mu.Unlock()
return nil
}
server := a.server
a.server = nil
a.running = false
a.addr = ""
a.startedAt = time.Time{}
a.models = nil
a.mu.Unlock()
runtime.EventsEmit(a.ctx, "status:updated", a.GetStatus())
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
a.emitLog("warn", fmt.Sprintf("Proxy stop forced after graceful shutdown timeout: %v", err))
return err
}
runtime.LogInfo(a.ctx, "proxy stopped")
a.emitLog("info", "Proxy stopped")
return nil
}
// GetModels returns the cached model list
func (a *App) GetModels() []ModelInfo {
a.mu.RLock()
defer a.mu.RUnlock()
return a.models
}
// GetRequests returns recent HTTP request records
func (a *App) GetRequests() []RequestRecord {
a.mu.RLock()
defer a.mu.RUnlock()
out := make([]RequestRecord, len(a.requests))
copy(out, a.requests)
// reverse so newest first
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
}
// ClearRequests clears the request history
func (a *App) ClearRequests() {
a.mu.Lock()
a.requests = nil
a.saveAppStateLocked()
a.mu.Unlock()
a.emitLog("info", "Request history cleared")
}
func (a *App) GetTokenStats() TokenStats {
a.mu.RLock()
defer a.mu.RUnlock()
stats := a.stats
if stats.ByModel != nil {
stats.ByModel = cloneIntMap(stats.ByModel)
}
return stats
}
// RefreshModels probes the running proxy for the latest model list.
func (a *App) RefreshModels() ([]ModelInfo, error) {
a.mu.RLock()
running := a.running
addr := a.addr
a.mu.RUnlock()
if !running || addr == "" {
return nil, fmt.Errorf("proxy is not running")
}
models, err := a.fetchModels(addr)
if err != nil {
return nil, err
}
return models, nil
}
func (a *App) SelectModel(modelID string) (ProxyStatus, error) {
modelID = strings.TrimSpace(modelID)
if modelID == "" {
return a.GetStatus(), fmt.Errorf("model id is required")
}
a.mu.Lock()
found := len(a.models) == 0
for _, model := range a.models {
if model.ID == modelID {
found = true
break
}
}
if !found {
a.mu.Unlock()
return a.GetStatus(), fmt.Errorf("model %q is not in the detected model list", modelID)
}
a.cfg.Model = modelID
cfg := a.cfg
server := a.server
a.mu.Unlock()
if server != nil {
server.SetDefaultModel(modelID)
}
if err := a.saveConfig(cfg); err != nil {
a.emitLog("warn", fmt.Sprintf("Model switched but config save failed: %v", err))
}
a.emitLog("info", fmt.Sprintf("已切换默认模型:%s", modelID))
return a.GetStatus(), nil
}
func (a *App) fetchModels(addr string) ([]ModelInfo, error) {
url := fmt.Sprintf("http://%s/v1/models", addr)
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Get(url)
if err != nil {
runtime.LogWarningf(a.ctx, "fetch models failed: %v", err)
return nil, err
}
defer resp.Body.Close()
var result struct {
Data []struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
runtime.LogWarningf(a.ctx, "decode models failed: %v", err)
return nil, err
}
models := make([]ModelInfo, 0, len(result.Data))
for _, m := range result.Data {
models = append(models, ModelInfo{ID: m.ID, Name: m.Name})
}
a.mu.Lock()
a.models = models
a.mu.Unlock()
runtime.EventsEmit(a.ctx, "models:updated", models)
if len(models) > 0 {
a.emitLog("info", fmt.Sprintf("Loaded %d models", len(models)))
}
return models, nil
}
func extractRequestModel(reqBody string) string {
if strings.TrimSpace(reqBody) == "" {
return ""
}
var payload map[string]any
if err := json.Unmarshal([]byte(reqBody), &payload); err != nil {
return ""
}
if model, ok := payload["model"].(string); ok {
return strings.TrimSpace(model)
}
return ""
}
func formatPayloadSize(bytes int) string {
if bytes <= 0 {
return "-"
}
const kb = 1024
const mb = 1024 * kb
if bytes >= mb {
return fmt.Sprintf("%.1f MB", float64(bytes)/float64(mb))
}
if bytes >= kb {
return fmt.Sprintf("%.1f KB", float64(bytes)/float64(kb))
}
return fmt.Sprintf("%d B", bytes)
}
type appStateFile struct {
Requests []RequestRecord `json:"requests"`
Logs []AppLog `json:"logs"`
Stats TokenStats `json:"stats"`
}
func (a *App) loadAppState() error {
path, err := appStatePath()
if err != nil {
return err
}
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
var state appStateFile
if err := json.Unmarshal(data, &state); err != nil {
return err
}
a.mu.Lock()
defer a.mu.Unlock()
a.requests = state.Requests
a.logs = state.Logs
a.stats = state.Stats
if a.stats.ByModel == nil {
a.stats.ByModel = map[string]int{}
}
a.reconcileTokenStatsLocked()
return nil
}
func (a *App) saveAppStateLocked() {
path, err := appStatePath()
if err != nil {
runtime.LogWarningf(a.ctx, "resolve app state path failed: %v", err)
return
}
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
runtime.LogWarningf(a.ctx, "create app state dir failed: %v", err)
return
}
state := appStateFile{
Requests: a.requests,
Logs: a.logs,
Stats: a.stats,
}
data, err := json.MarshalIndent(state, "", " ")
if err != nil {
runtime.LogWarningf(a.ctx, "marshal app state failed: %v", err)
return
}
if err := os.WriteFile(path, data, 0644); err != nil {
runtime.LogWarningf(a.ctx, "write app state failed: %v", err)
}
}
func appStatePath() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".config", "lingma-ipc-proxy", "app-state.json"), nil
}
func (a *App) accumulateTokenStatsLocked(record RequestRecord) {
a.stats.TotalRequests++
if record.StatusCode >= 200 && record.StatusCode < 300 {
a.stats.SuccessRequests++
}
a.stats.InputTokens += record.InputTokens
a.stats.OutputTokens += record.OutputTokens
a.stats.TotalTokens += record.TotalTokens
if a.stats.ByModel == nil {
a.stats.ByModel = map[string]int{}
}
model := strings.TrimSpace(record.Model)
if model == "" {
model = "-"
}
if record.TotalTokens > 0 {
a.stats.ByModel[model] += record.TotalTokens
if isUsageBearingRequest(record.Path) && model != "-" {
a.stats.LastModel = model
}
}
a.stats.LastUpdated = time.Now().Format(time.RFC3339)
}
func (a *App) reconcileTokenStatsLocked() {
if a.stats.ByModel == nil {
a.stats.ByModel = map[string]int{}
}
a.stats.LastModel = ""
for i := len(a.requests) - 1; i >= 0; i-- {
record := a.requests[i]
model := strings.TrimSpace(record.Model)
if model == "" || record.TotalTokens <= 0 || !isUsageBearingRequest(record.Path) {
continue
}
a.stats.LastModel = model
break
}
}
func isUsageBearingRequest(path string) bool {
switch strings.TrimSpace(path) {
case "/v1/messages", "/v1/chat/completions", "/v1/completions":
return true
default:
return false
}
}
func cloneIntMap(src map[string]int) map[string]int {
out := make(map[string]int, len(src))
for k, v := range src {
out[k] = v
}
return out
}
func extractTokenUsage(respBody string) (int, int) {
if strings.TrimSpace(respBody) == "" {
return 0, 0
}
input, output := extractUsageFromJSON(respBody)
if input != 0 || output != 0 {
return input, output
}
for _, line := range strings.Split(respBody, "\n") {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "data:") {
continue
}
payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if payload == "" || payload == "[DONE]" {
continue
}
in, out := extractUsageFromJSON(payload)
if in > 0 {
input = in
}
if out > 0 {
output = out
}
}
return input, output
}
func extractUsageFromJSON(raw string) (int, int) {
var payload any
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
return 0, 0
}
usage, ok := findUsageMap(payload)
if !ok {
return 0, 0
}
input := intFromAny(usage["input_tokens"]) + intFromAny(usage["prompt_tokens"])
output := intFromAny(usage["output_tokens"]) + intFromAny(usage["completion_tokens"])
return input, output
}
func findUsageMap(value any) (map[string]any, bool) {
switch typed := value.(type) {
case map[string]any:
if usage, ok := typed["usage"].(map[string]any); ok {
return usage, true
}
for _, child := range typed {
if usage, ok := findUsageMap(child); ok {
return usage, true
}
}
case []any:
for _, child := range typed {
if usage, ok := findUsageMap(child); ok {
return usage, true
}
}
}
return nil, false
}
func intFromAny(value any) int {
switch typed := value.(type) {
case float64:
return int(typed)
case int:
return typed
case json.Number:
n, _ := typed.Int64()
return int(n)
default:
return 0
}
}
func defaultConfig() service.Config {
cfg := service.Config{
Host: "127.0.0.1",
Port: 8095,
Backend: service.BackendRemote,
Transport: lingmaipc.TransportAuto,
Cwd: defaultCwd(),
Mode: "agent",
Model: "kmodel",
ShellType: defaultShellType(),
SessionMode: service.SessionModeAuto,
Timeout: 0,
RemoteFallbackEnabled: true,
RemoteFallbackModels: service.DefaultRemoteFallbackModels(),
}
// Try to load config file from multiple locations
configPaths := configSearchPaths()
for _, configPath := range configPaths {
if info, err := os.Stat(configPath); err == nil && !info.IsDir() {
if data, err := os.ReadFile(configPath); err == nil {
var fileCfg struct {
Host string `json:"host"`
Port int `json:"port"`
Backend string `json:"backend"`
Transport string `json:"transport"`
Pipe string `json:"pipe"`
WebSocketURL string `json:"websocket_url"`
RemoteBaseURL string `json:"remote_base_url"`
RemoteAuthFile string `json:"remote_auth_file"`
RemoteVersion string `json:"remote_version"`
Cwd string `json:"cwd"`
CurrentFilePath string `json:"current_file_path"`
Mode string `json:"mode"`
Model string `json:"model"`
ShellType string `json:"shell_type"`
SessionMode string `json:"session_mode"`
TimeoutSeconds int `json:"timeout"`
RemoteFallbackEnabled *bool `json:"remote_fallback_enabled"`
RemoteFallbackModels []string `json:"remote_fallback_models"`
}
if err := json.Unmarshal(data, &fileCfg); err == nil {
if fileCfg.Host != "" {
cfg.Host = fileCfg.Host
}
if fileCfg.Port > 0 {
cfg.Port = fileCfg.Port
}
if fileCfg.Backend != "" {
cfg.Backend = service.BackendMode(fileCfg.Backend)
}
if fileCfg.Transport != "" {
if t, err := lingmaipc.ParseTransport(fileCfg.Transport); err == nil {
cfg.Transport = t
}
}
if fileCfg.Pipe != "" {
cfg.Pipe = fileCfg.Pipe
}
if fileCfg.WebSocketURL != "" {
cfg.WebSocketURL = fileCfg.WebSocketURL
}
if fileCfg.RemoteBaseURL != "" {
cfg.RemoteBaseURL = fileCfg.RemoteBaseURL
}
if fileCfg.RemoteAuthFile != "" {
cfg.RemoteAuthFile = fileCfg.RemoteAuthFile
}
if fileCfg.RemoteVersion != "" {
cfg.RemoteVersion = fileCfg.RemoteVersion
}
if fileCfg.Cwd != "" {
cfg.Cwd = fileCfg.Cwd
}
if fileCfg.CurrentFilePath != "" {
cfg.CurrentFilePath = fileCfg.CurrentFilePath
}
if fileCfg.Mode != "" {
cfg.Mode = fileCfg.Mode
}
if fileCfg.Model != "" {
cfg.Model = fileCfg.Model
}
if fileCfg.ShellType != "" {
cfg.ShellType = fileCfg.ShellType
}
if fileCfg.SessionMode != "" {
cfg.SessionMode = service.SessionMode(fileCfg.SessionMode)
}
if fileCfg.TimeoutSeconds >= 0 {
cfg.Timeout = time.Duration(fileCfg.TimeoutSeconds) * time.Second
}
if fileCfg.RemoteFallbackEnabled != nil {
cfg.RemoteFallbackEnabled = *fileCfg.RemoteFallbackEnabled
}
if len(fileCfg.RemoteFallbackModels) > 0 {
cfg.RemoteFallbackModels = cleanConfigStrings(fileCfg.RemoteFallbackModels)
}
}
break // loaded successfully
}
}
}
return cfg
}
func backendLabel(backend service.BackendMode) string {
switch backend {
case service.BackendRemote:
return "远端 API"
default:
return "IPC 插件"
}
}
func maskIdentifier(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
runes := []rune(value)
if len(runes) <= 8 {
return string(runes[:1]) + "***"
}
return string(runes[:4]) + "..." + string(runes[len(runes)-4:])
}
func cleanConfigStrings(values []string) []string {
out := make([]string, 0, len(values))
seen := map[string]bool{}
for _, value := range values {
item := strings.TrimSpace(value)
if item == "" || seen[item] {
continue
}
seen[item] = true
out = append(out, item)
}
return out
}
func configSearchPaths() []string {
var paths []string
// 1. Executable directory (for dev / portable mode)
if exe, err := os.Executable(); err == nil {
paths = append(paths, filepath.Join(filepath.Dir(exe), "lingma-proxy.json"))
paths = append(paths, filepath.Join(filepath.Dir(exe), "lingma-ipc-proxy.json"))
}
// 2. Current working directory
if wd, err := os.Getwd(); err == nil {
paths = append(paths, filepath.Join(wd, "lingma-proxy.json"))
paths = append(paths, filepath.Join(wd, "lingma-ipc-proxy.json"))
}
// 3. User home directory
if home, err := os.UserHomeDir(); err == nil {
paths = append(paths, filepath.Join(home, "lingma-proxy.json"))
paths = append(paths, filepath.Join(home, "lingma-ipc-proxy.json"))
paths = append(paths, filepath.Join(home, ".config", "lingma-proxy", "config.json"))
paths = append(paths, filepath.Join(home, ".config", "lingma-ipc-proxy", "config.json"))
}
return paths
}
func defaultCwd() string {
// Use the user's home directory as the default working directory
// so it works out-of-the-box regardless of where the app is launched.
if home, err := os.UserHomeDir(); err == nil {
return home
}
if wd, err := os.Getwd(); err == nil {
return wd
}
return "."
}
func defaultShellType() string {
if goruntime.GOOS == "windows" {
return "powershell"
}
return "zsh"
}
func transportFallbackHint() string {
return "请确认 Lingma 插件已启动并登录;如果自动探测失败,请到设置页手动填写:远端 API 官方默认域名 https://lingma.alibabacloud.com企业版请填写你的专属域名macOS WebSocket 示例 ws://127.0.0.1:36510/Windows Named Pipe 示例 \\\\.\\pipe\\lingma-xxxx或 Windows WebSocket 示例 ws://127.0.0.1:36510/。"
}
func warmupFallbackHint(backend service.BackendMode) string {
if backend == service.BackendRemote {
return "请检查设置页“当前解析结果”里的远端域名是否为官方或企业专属 API 域名;如果出现 OSS/静态资源域名或模型列表 404请手动填写远端 API 官方默认域名 https://lingma.alibabacloud.com企业版请填写你的专属域名并确认登录态未过期。"
}
return "请确认 Lingma 插件已启动并登录如果自动探测失败请到设置页手动填写macOS WebSocket 示例 ws://127.0.0.1:36510/Windows Named Pipe 示例 \\\\.\\pipe\\lingma-xxxx或 Windows WebSocket 示例 ws://127.0.0.1:36510/。"
}