Improve remote endpoint detection
This commit is contained in:
18
README.md
18
README.md
@@ -1,8 +1,8 @@
|
|||||||
# Lingma IPC Proxy
|
# Lingma Proxy
|
||||||
|
|
||||||
[English](./README.md) | [简体中文](./README.zh-CN.md)
|
[English](./README.md) | [简体中文](./README.zh-CN.md)
|
||||||
|
|
||||||
Lingma IPC Proxy exposes Tongyi Lingma's local IDE plugin capability as standard **OpenAI-compatible** and **Anthropic-compatible** HTTP APIs. It can be used as a CLI proxy service or as a cross-platform desktop app for macOS and Windows.
|
Lingma Proxy exposes Tongyi Lingma as standard **OpenAI-compatible** and **Anthropic-compatible** HTTP APIs. It can use either the local IDE plugin IPC channel or an experimental remote API backend, and ships as both a CLI proxy service and a cross-platform desktop app for macOS and Windows.
|
||||||
|
|
||||||
The project is designed for tools such as Claude Code, Cline, Continue, OpenCode, custom agents, and any client that can talk to OpenAI or Anthropic style APIs.
|
The project is designed for tools such as Claude Code, Cline, Continue, OpenCode, custom agents, and any client that can talk to OpenAI or Anthropic style APIs.
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ The proxy now supports two backend modes:
|
|||||||
|
|
||||||
## Current Version
|
## Current Version
|
||||||
|
|
||||||
The current desktop line is `v1.4.0`.
|
The current desktop line is `v1.4.1`.
|
||||||
|
|
||||||
Release builds are produced by GitHub Actions for:
|
Release builds are produced by GitHub Actions for:
|
||||||
|
|
||||||
@@ -156,9 +156,9 @@ flowchart LR
|
|||||||
|
|
||||||
| Platform | Default transport | Detection |
|
| Platform | Default transport | Detection |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| macOS | WebSocket | reads Lingma `SharedClientCache` files under user application support paths |
|
| macOS | WebSocket | reads Lingma `SharedClientCache` files under user application support paths and `~/.lingma` fallbacks |
|
||||||
| Windows | Named Pipe / WebSocket | scans Lingma named pipes and shared cache hints |
|
| Windows | Named Pipe / WebSocket | scans Lingma named pipes plus `%APPDATA%`, `%LOCALAPPDATA%`, `%ProgramData%`, and `%USERPROFILE%\.lingma` shared cache hints |
|
||||||
| Linux | WebSocket | manual `--ws-url` is recommended |
|
| Linux | WebSocket | reads `~/.lingma` / XDG hints when present; manual `--ws-url` is still recommended |
|
||||||
|
|
||||||
If auto detection fails, set the path manually in the desktop Settings page or pass CLI flags:
|
If auto detection fails, set the path manually in the desktop Settings page or pass CLI flags:
|
||||||
|
|
||||||
@@ -193,6 +193,9 @@ By default it reads the local Lingma login cache in read-only mode:
|
|||||||
~/.lingma/cache/user
|
~/.lingma/cache/user
|
||||||
~/.lingma/cache/id
|
~/.lingma/cache/id
|
||||||
~/.lingma/logs/lingma.log
|
~/.lingma/logs/lingma.log
|
||||||
|
%APPDATA%\Lingma\cache\user
|
||||||
|
%LOCALAPPDATA%\Lingma\cache\user
|
||||||
|
XDG config/state Lingma cache paths when present
|
||||||
```
|
```
|
||||||
|
|
||||||
You can also pass an explicit credential file:
|
You can also pass an explicit credential file:
|
||||||
@@ -222,7 +225,8 @@ Credential file format:
|
|||||||
Notes:
|
Notes:
|
||||||
|
|
||||||
- Remote mode does not write or migrate login state. It only reads the local Lingma cache or the credential file you provide.
|
- Remote mode does not write or migrate login state. It only reads the local Lingma cache or the credential file you provide.
|
||||||
- If your Lingma plugin uses a dedicated domain, set `--remote-base-url`, `LINGMA_REMOTE_BASE_URL`, or the JSON config field explicitly.
|
- If your Lingma plugin uses a dedicated domain, remote mode first uses `--remote-base-url`, `LINGMA_REMOTE_BASE_URL`, or the JSON config field. If those are empty, it scans Lingma's local logs on macOS, Windows, and Linux for endpoint hints such as `endpoint config:` and marketplace service URLs.
|
||||||
|
- The desktop Settings page shows the resolved remote domain and detection source without exposing tokens.
|
||||||
- `/v1/models` in remote mode returns remote API model keys, which may not match the IPC plugin display IDs such as `MiniMax-M2.7` or `Kimi-K2.6`.
|
- `/v1/models` in remote mode returns remote API model keys, which may not match the IPC plugin display IDs such as `MiniMax-M2.7` or `Kimi-K2.6`.
|
||||||
- Local validation passed `/health`, `/v1/models`, OpenAI streaming/non-streaming chat, and Claude Code Anthropic + Bash tool use. Claude Code full tool runs are much slower than simple OpenAI requests because the client sends a large context and performs a second tool-result turn.
|
- Local validation passed `/health`, `/v1/models`, OpenAI streaming/non-streaming chat, and Claude Code Anthropic + Bash tool use. Claude Code full tool runs are much slower than simple OpenAI requests because the client sends a large context and performs a second tool-result turn.
|
||||||
- This mode is inspired by the remote API and credential-signing research in [ZipperCode/lingma2api](https://github.com/ZipperCode/lingma2api), integrated here as a switchable backend under the existing OpenAI / Anthropic / desktop app architecture.
|
- This mode is inspired by the remote API and credential-signing research in [ZipperCode/lingma2api](https://github.com/ZipperCode/lingma2api), integrated here as a switchable backend under the existing OpenAI / Anthropic / desktop app architecture.
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# Lingma IPC Proxy
|
# Lingma Proxy
|
||||||
|
|
||||||
[English](./README.md) | [简体中文](./README.zh-CN.md)
|
[English](./README.md) | [简体中文](./README.zh-CN.md)
|
||||||
|
|
||||||
**Lingma IPC Proxy** 是一个通义灵码 IDE 插件 API 适配层。它把 Lingma 插件的本地私有 IPC / WebSocket 能力转换成标准 **OpenAI 兼容接口** 和 **Anthropic 兼容接口**,让 Claude Code、Cline、Continue、OpenCode、自研 Agent 等第三方客户端可以直接调用 Lingma 后端模型。
|
**Lingma Proxy** 是一个通义灵码 API 适配层。它既可以把 Lingma 插件的本地私有 IPC / WebSocket 能力转换成标准 **OpenAI 兼容接口** 和 **Anthropic 兼容接口**,也可以使用实验性的远端 API 模式直接调用 Lingma 远端接口,让 Claude Code、Cline、Continue、OpenCode、自研 Agent 等第三方客户端可以直接调用 Lingma 后端模型。
|
||||||
|
|
||||||
项目同时提供两种使用方式:
|
项目同时提供两种使用方式:
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
## 当前版本
|
## 当前版本
|
||||||
|
|
||||||
当前桌面端版本线:`v1.4.0`
|
当前桌面端版本线:`v1.4.1`
|
||||||
|
|
||||||
GitHub Actions 会在 Release 中产出:
|
GitHub Actions 会在 Release 中产出:
|
||||||
|
|
||||||
@@ -216,9 +216,9 @@ flowchart LR
|
|||||||
|
|
||||||
| 平台 | 优先传输 | 探测方式 |
|
| 平台 | 优先传输 | 探测方式 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| macOS | WebSocket | 扫描用户目录下 Lingma `SharedClientCache` 配置 |
|
| macOS | WebSocket | 扫描 Lingma `SharedClientCache`、`~/.lingma` 等用户目录 |
|
||||||
| Windows | Named Pipe / WebSocket | 扫描 Lingma 命名管道和共享缓存信息 |
|
| Windows | Named Pipe / WebSocket | 扫描 Lingma 命名管道,以及 `%APPDATA%`、`%LOCALAPPDATA%`、`%ProgramData%`、`%USERPROFILE%\.lingma` 下的共享缓存信息 |
|
||||||
| Linux | WebSocket | 建议手动指定 `--ws-url` |
|
| Linux | WebSocket | 尝试读取 `~/.lingma` / XDG 目录,仍建议必要时手动指定 `--ws-url` |
|
||||||
|
|
||||||
如果自动探测失败,桌面端会提供兜底说明。可以在设置里手动填写:
|
如果自动探测失败,桌面端会提供兜底说明。可以在设置里手动填写:
|
||||||
|
|
||||||
@@ -259,6 +259,9 @@ lingma-ipc-proxy --backend remote --port 8095
|
|||||||
~/.lingma/cache/user
|
~/.lingma/cache/user
|
||||||
~/.lingma/cache/id
|
~/.lingma/cache/id
|
||||||
~/.lingma/logs/lingma.log
|
~/.lingma/logs/lingma.log
|
||||||
|
%APPDATA%\Lingma\cache\user
|
||||||
|
%LOCALAPPDATA%\Lingma\cache\user
|
||||||
|
存在时也会尝试 XDG 配置 / 状态目录
|
||||||
```
|
```
|
||||||
|
|
||||||
也可以指定显式凭据文件:
|
也可以指定显式凭据文件:
|
||||||
@@ -288,7 +291,8 @@ lingma-ipc-proxy \
|
|||||||
说明:
|
说明:
|
||||||
|
|
||||||
- 远端模式不会写入或迁移你的登录态,只会读取本机 Lingma 缓存或你指定的凭据文件。
|
- 远端模式不会写入或迁移你的登录态,只会读取本机 Lingma 缓存或你指定的凭据文件。
|
||||||
- 如果 Lingma 插件配置过专属域名,可以通过 `--remote-base-url`、`LINGMA_REMOTE_BASE_URL` 或配置文件显式指定。
|
- 如果 Lingma 插件配置过专属域名,远端模式会优先使用 `--remote-base-url`、`LINGMA_REMOTE_BASE_URL` 或配置文件;这些为空时,会扫描 macOS、Windows、Linux 上 Lingma 本地日志里的 `endpoint config:`、Marketplace service URL 等线索。
|
||||||
|
- 桌面端设置页会展示当前解析到的远端域名和来源,但不会展示 token / key 明文。
|
||||||
- 远端模式的 `/v1/models` 返回的是远端接口模型 key,不一定等同于 IPC 插件模式里看到的 `MiniMax-M2.7`、`Kimi-K2.6` 等展示名。
|
- 远端模式的 `/v1/models` 返回的是远端接口模型 key,不一定等同于 IPC 插件模式里看到的 `MiniMax-M2.7`、`Kimi-K2.6` 等展示名。
|
||||||
- 当前本机实测:`/health`、`/v1/models`、OpenAI 流式 / 非流式、Claude Code Anthropic + Bash 工具调用均可用;Claude Code 完整工具链耗时明显高于简单 OpenAI 请求。
|
- 当前本机实测:`/health`、`/v1/models`、OpenAI 流式 / 非流式、Claude Code Anthropic + Bash 工具调用均可用;Claude Code 完整工具链耗时明显高于简单 OpenAI 请求。
|
||||||
- 该模式参考了 [ZipperCode/lingma2api](https://github.com/ZipperCode/lingma2api) 对 Lingma 远端接口、签名和登录态结构的探索,本仓库将其作为可切换后端集成到现有 OpenAI / Anthropic / 桌面 App 架构中。
|
- 该模式参考了 [ZipperCode/lingma2api](https://github.com/ZipperCode/lingma2api) 对 Lingma 远端接口、签名和登录态结构的探索,本仓库将其作为可切换后端集成到现有 OpenAI / Anthropic / 桌面 App 架构中。
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
|
|
||||||
"lingma-ipc-proxy/internal/httpapi"
|
"lingma-ipc-proxy/internal/httpapi"
|
||||||
"lingma-ipc-proxy/internal/lingmaipc"
|
"lingma-ipc-proxy/internal/lingmaipc"
|
||||||
|
"lingma-ipc-proxy/internal/remote"
|
||||||
"lingma-ipc-proxy/internal/service"
|
"lingma-ipc-proxy/internal/service"
|
||||||
|
|
||||||
"github.com/wailsapp/wails/v2/pkg/options"
|
"github.com/wailsapp/wails/v2/pkg/options"
|
||||||
@@ -58,11 +59,32 @@ type ModelInfo struct {
|
|||||||
type ProxyStatus struct {
|
type ProxyStatus struct {
|
||||||
Running bool `json:"running"`
|
Running bool `json:"running"`
|
||||||
Addr string `json:"addr"`
|
Addr string `json:"addr"`
|
||||||
|
Backend string `json:"backend"`
|
||||||
Models int `json:"models"`
|
Models int `json:"models"`
|
||||||
Model string `json:"model,omitempty"`
|
Model string `json:"model,omitempty"`
|
||||||
StartedAt string `json:"startedAt,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
|
// NewApp creates a new App application struct
|
||||||
func NewApp() *App {
|
func NewApp() *App {
|
||||||
return &App{}
|
return &App{}
|
||||||
@@ -201,6 +223,7 @@ func (a *App) GetStatus() ProxyStatus {
|
|||||||
return ProxyStatus{
|
return ProxyStatus{
|
||||||
Running: a.running,
|
Running: a.running,
|
||||||
Addr: a.addr,
|
Addr: a.addr,
|
||||||
|
Backend: string(a.cfg.Backend),
|
||||||
Models: len(a.models),
|
Models: len(a.models),
|
||||||
Model: a.cfg.Model,
|
Model: a.cfg.Model,
|
||||||
StartedAt: startedAt,
|
StartedAt: startedAt,
|
||||||
@@ -217,6 +240,54 @@ func (a *App) GetConfig() service.Config {
|
|||||||
return cfg
|
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.
|
// UpdateConfig updates the configuration, saves to file, and restarts the proxy if running.
|
||||||
// Frontend sends Timeout in seconds; we convert to time.Duration.
|
// Frontend sends Timeout in seconds; we convert to time.Duration.
|
||||||
func (a *App) UpdateConfig(cfg service.Config) error {
|
func (a *App) UpdateConfig(cfg service.Config) error {
|
||||||
@@ -607,6 +678,27 @@ func defaultConfig() service.Config {
|
|||||||
return cfg
|
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 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)
|
||||||
|
|||||||
@@ -222,7 +222,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.0</small>
|
<small>v1.4.1</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -1266,6 +1266,83 @@ button:disabled {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detect-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 14px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.54);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] .detect-card {
|
||||||
|
background: rgba(15, 23, 42, 0.52);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detect-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detect-title strong {
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detect-title button {
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 0 10px;
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 7px;
|
||||||
|
background: var(--surface-strong);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detect-card dl {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detect-card dl > div {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 82px minmax(0, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detect-card dt {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 680;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detect-card dd {
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
font-family: "SF Mono", ui-monospace, Menlo, Consolas, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted-inline {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warn-text {
|
||||||
|
color: var(--danger-text) !important;
|
||||||
|
}
|
||||||
|
|
||||||
.form-grid {
|
.form-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { GetConfig, UpdateConfig } from '../../wailsjs/go/main/App.js'
|
import { GetConfig, GetDetectionInfo, UpdateConfig } from '../../wailsjs/go/main/App.js'
|
||||||
|
|
||||||
const emit = defineEmits(['log', 'status-refresh'])
|
const emit = defineEmits(['log', 'status-refresh'])
|
||||||
|
|
||||||
const config = ref({})
|
const config = ref({})
|
||||||
|
const detection = ref(null)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const openSelect = ref('')
|
const openSelect = ref('')
|
||||||
|
|
||||||
@@ -47,20 +48,31 @@ function toggleSelect(field) {
|
|||||||
function chooseOption(field, value) {
|
function chooseOption(field, value) {
|
||||||
config.value[field] = value
|
config.value[field] = value
|
||||||
openSelect.value = ''
|
openSelect.value = ''
|
||||||
|
refreshDetection()
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
config.value = await GetConfig()
|
config.value = await GetConfig()
|
||||||
|
await refreshDetection()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
emit('log', 'error', '配置加载失败:' + (e.message || String(e)))
|
emit('log', 'error', '配置加载失败:' + (e.message || String(e)))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
async function refreshDetection() {
|
||||||
|
try {
|
||||||
|
detection.value = await GetDetectionInfo()
|
||||||
|
} catch (e) {
|
||||||
|
emit('log', 'warn', '探测信息加载失败:' + (e.message || String(e)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
await UpdateConfig(config.value)
|
await UpdateConfig(config.value)
|
||||||
|
await refreshDetection()
|
||||||
emit('log', 'info', '配置已保存,代理已按需重启')
|
emit('log', 'info', '配置已保存,代理已按需重启')
|
||||||
emit('status-refresh')
|
emit('status-refresh')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -169,6 +181,50 @@ async function save() {
|
|||||||
<strong>自动探测失败时</strong>
|
<strong>自动探测失败时</strong>
|
||||||
<span>IPC 模式先确认 VS Code / Lingma 插件已启动并登录。远端 API 模式会优先读取认证文件;留空时只读 <code>~/.lingma/cache/user</code>,不会写入或上传登录态。</span>
|
<span>IPC 模式先确认 VS Code / Lingma 插件已启动并登录。远端 API 模式会优先读取认证文件;留空时只读 <code>~/.lingma/cache/user</code>,不会写入或上传登录态。</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="detection" class="detect-card">
|
||||||
|
<div class="detect-title">
|
||||||
|
<strong>当前解析结果</strong>
|
||||||
|
<button type="button" @click="refreshDetection">刷新</button>
|
||||||
|
</div>
|
||||||
|
<dl>
|
||||||
|
<div>
|
||||||
|
<dt>监听地址</dt>
|
||||||
|
<dd>{{ detection.listenUrl || '未启动' }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>当前后端</dt>
|
||||||
|
<dd>{{ detection.backendLabel || detection.backend }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>IPC 地址</dt>
|
||||||
|
<dd v-if="detection.ipcSuccess">{{ detection.ipcTransport }} · {{ detection.ipcEndpoint }}</dd>
|
||||||
|
<dd v-else class="warn-text">{{ detection.ipcError || '未探测到' }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>远端域名</dt>
|
||||||
|
<dd>
|
||||||
|
{{ detection.remoteBaseUrl }}
|
||||||
|
<span v-if="detection.remoteBaseUrlSource" class="muted-inline">来自 {{ detection.remoteBaseUrlSource }}</span>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>登录态来源</dt>
|
||||||
|
<dd v-if="detection.remoteCredentialSuccess">{{ detection.remoteCredentialSource }}</dd>
|
||||||
|
<dd v-else class="warn-text">{{ detection.remoteCredentialError || '未探测到' }}</dd>
|
||||||
|
</div>
|
||||||
|
<div v-if="detection.remoteCredentialSuccess">
|
||||||
|
<dt>账号 / 机器</dt>
|
||||||
|
<dd>{{ detection.remoteUserId || '未知用户' }} · {{ detection.remoteMachineId || '未知机器' }}</dd>
|
||||||
|
</div>
|
||||||
|
<div v-if="detection.remoteCredentialSuccess">
|
||||||
|
<dt>登录态有效期</dt>
|
||||||
|
<dd :class="{ 'warn-text': detection.remoteTokenExpired }">
|
||||||
|
{{ detection.remoteTokenExpireAt || '未提供' }}
|
||||||
|
<span v-if="detection.remoteTokenExpired">(已过期)</span>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="glass-panel">
|
<div class="glass-panel">
|
||||||
|
|||||||
2
desktop/frontend/wailsjs/go/main/App.d.ts
vendored
2
desktop/frontend/wailsjs/go/main/App.d.ts
vendored
@@ -9,6 +9,8 @@ export function ClearRequests():Promise<void>;
|
|||||||
|
|
||||||
export function GetConfig():Promise<service.Config>;
|
export function GetConfig():Promise<service.Config>;
|
||||||
|
|
||||||
|
export function GetDetectionInfo():Promise<main.DetectionInfo>;
|
||||||
|
|
||||||
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>>;
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ export function GetConfig() {
|
|||||||
return window['go']['main']['App']['GetConfig']();
|
return window['go']['main']['App']['GetConfig']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetDetectionInfo() {
|
||||||
|
return window['go']['main']['App']['GetDetectionInfo']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetModels() {
|
export function GetModels() {
|
||||||
return window['go']['main']['App']['GetModels']();
|
return window['go']['main']['App']['GetModels']();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,47 @@
|
|||||||
export namespace main {
|
export namespace main {
|
||||||
|
|
||||||
|
export class DetectionInfo {
|
||||||
|
listenUrl: string;
|
||||||
|
backend: string;
|
||||||
|
backendLabel: string;
|
||||||
|
ipcSuccess: boolean;
|
||||||
|
ipcTransport?: string;
|
||||||
|
ipcEndpoint?: string;
|
||||||
|
ipcError?: string;
|
||||||
|
remoteBaseUrl: string;
|
||||||
|
remoteBaseUrlSource?: string;
|
||||||
|
remoteCredentialSuccess: boolean;
|
||||||
|
remoteCredentialSource?: string;
|
||||||
|
remoteUserId?: string;
|
||||||
|
remoteMachineId?: string;
|
||||||
|
remoteTokenExpireAt?: string;
|
||||||
|
remoteTokenExpired: boolean;
|
||||||
|
remoteCredentialError?: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new DetectionInfo(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.listenUrl = source["listenUrl"];
|
||||||
|
this.backend = source["backend"];
|
||||||
|
this.backendLabel = source["backendLabel"];
|
||||||
|
this.ipcSuccess = source["ipcSuccess"];
|
||||||
|
this.ipcTransport = source["ipcTransport"];
|
||||||
|
this.ipcEndpoint = source["ipcEndpoint"];
|
||||||
|
this.ipcError = source["ipcError"];
|
||||||
|
this.remoteBaseUrl = source["remoteBaseUrl"];
|
||||||
|
this.remoteBaseUrlSource = source["remoteBaseUrlSource"];
|
||||||
|
this.remoteCredentialSuccess = source["remoteCredentialSuccess"];
|
||||||
|
this.remoteCredentialSource = source["remoteCredentialSource"];
|
||||||
|
this.remoteUserId = source["remoteUserId"];
|
||||||
|
this.remoteMachineId = source["remoteMachineId"];
|
||||||
|
this.remoteTokenExpireAt = source["remoteTokenExpireAt"];
|
||||||
|
this.remoteTokenExpired = source["remoteTokenExpired"];
|
||||||
|
this.remoteCredentialError = source["remoteCredentialError"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class ModelInfo {
|
export class ModelInfo {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -17,6 +59,7 @@ export namespace main {
|
|||||||
export class ProxyStatus {
|
export class ProxyStatus {
|
||||||
running: boolean;
|
running: boolean;
|
||||||
addr: string;
|
addr: string;
|
||||||
|
backend: string;
|
||||||
models: number;
|
models: number;
|
||||||
model?: string;
|
model?: string;
|
||||||
startedAt?: string;
|
startedAt?: string;
|
||||||
@@ -29,6 +72,7 @@ export namespace main {
|
|||||||
if ('string' === typeof source) source = JSON.parse(source);
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
this.running = source["running"];
|
this.running = source["running"];
|
||||||
this.addr = source["addr"];
|
this.addr = source["addr"];
|
||||||
|
this.backend = source["backend"];
|
||||||
this.models = source["models"];
|
this.models = source["models"];
|
||||||
this.model = source["model"];
|
this.model = source["model"];
|
||||||
this.startedAt = source["startedAt"];
|
this.startedAt = source["startedAt"];
|
||||||
|
|||||||
@@ -194,30 +194,68 @@ func resolveSharedClientInfo() (sharedClientInfo, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func defaultSharedClientInfoPaths() []string {
|
func defaultSharedClientInfoPaths() []string {
|
||||||
bases := make([]string, 0, 2)
|
if explicit := strings.TrimSpace(os.Getenv("LINGMA_SHARED_CLIENT_INFO")); explicit != "" {
|
||||||
if appData := strings.TrimSpace(os.Getenv("APPDATA")); appData != "" {
|
return []string{explicit}
|
||||||
bases = append(bases, appData)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bases := make([]string, 0, 8)
|
||||||
if userConfigDir, err := os.UserConfigDir(); err == nil && strings.TrimSpace(userConfigDir) != "" {
|
if userConfigDir, err := os.UserConfigDir(); err == nil && strings.TrimSpace(userConfigDir) != "" {
|
||||||
bases = append(bases, userConfigDir)
|
bases = append(bases, userConfigDir)
|
||||||
}
|
}
|
||||||
|
if home, err := os.UserHomeDir(); err == nil && strings.TrimSpace(home) != "" {
|
||||||
|
bases = append(bases,
|
||||||
|
filepath.Join(home, ".lingma", "vscode"),
|
||||||
|
filepath.Join(home, ".lingma"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
for _, envName := range []string{"APPDATA", "LOCALAPPDATA", "ProgramData"} {
|
||||||
|
if value := strings.TrimSpace(os.Getenv(envName)); value != "" {
|
||||||
|
bases = append(bases, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
seen := make(map[string]struct{})
|
seen := make(map[string]struct{})
|
||||||
paths := make([]string, 0, len(bases)*2)
|
paths := make([]string, 0, len(bases)*2)
|
||||||
for _, base := range bases {
|
for _, base := range uniquePathStrings(bases) {
|
||||||
cacheDir := filepath.Join(base, "Lingma", "SharedClientCache")
|
cacheDirs := []string{
|
||||||
for _, name := range []string{".info.json", ".info"} {
|
filepath.Join(base, "Lingma", "SharedClientCache"),
|
||||||
path := filepath.Join(cacheDir, name)
|
filepath.Join(base, "Lingma", "sharedClientCache"),
|
||||||
if _, ok := seen[path]; ok {
|
filepath.Join(base, "SharedClientCache"),
|
||||||
continue
|
filepath.Join(base, "sharedClientCache"),
|
||||||
|
}
|
||||||
|
for _, cacheDir := range cacheDirs {
|
||||||
|
for _, name := range []string{".info.json", ".info"} {
|
||||||
|
path := filepath.Join(cacheDir, name)
|
||||||
|
if _, ok := seen[path]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[path] = struct{}{}
|
||||||
|
paths = append(paths, path)
|
||||||
}
|
}
|
||||||
seen[path] = struct{}{}
|
|
||||||
paths = append(paths, path)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return paths
|
return paths
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func uniquePathStrings(values []string) []string {
|
||||||
|
seen := make(map[string]struct{}, len(values))
|
||||||
|
out := make([]string, 0, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cleaned := filepath.Clean(value)
|
||||||
|
key := strings.ToLower(cleaned)
|
||||||
|
if _, ok := seen[key]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
out = append(out, cleaned)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func resolveSharedClientInfoFromPaths(paths []string) (sharedClientInfo, error) {
|
func resolveSharedClientInfoFromPaths(paths []string) (sharedClientInfo, error) {
|
||||||
var parseErrors []string
|
var parseErrors []string
|
||||||
foundFile := false
|
foundFile := false
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -35,6 +37,11 @@ type Client struct {
|
|||||||
client *http.Client
|
client *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type BaseURLHint struct {
|
||||||
|
URL string
|
||||||
|
Source string
|
||||||
|
}
|
||||||
|
|
||||||
type Model struct {
|
type Model struct {
|
||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
DisplayName string `json:"display_name"`
|
DisplayName string `json:"display_name"`
|
||||||
@@ -76,18 +83,22 @@ func New(cfg Config) *Client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ResolveBaseURL(explicit string) string {
|
func ResolveBaseURL(explicit string) string {
|
||||||
|
return ResolveBaseURLWithSource(explicit).URL
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResolveBaseURLWithSource(explicit string) BaseURLHint {
|
||||||
if strings.TrimSpace(explicit) != "" {
|
if strings.TrimSpace(explicit) != "" {
|
||||||
return strings.TrimRight(strings.TrimSpace(explicit), "/")
|
return BaseURLHint{URL: strings.TrimRight(strings.TrimSpace(explicit), "/"), Source: "explicit config"}
|
||||||
}
|
}
|
||||||
if value := strings.TrimSpace(os.Getenv("LINGMA_REMOTE_BASE_URL")); value != "" {
|
if value := strings.TrimSpace(os.Getenv("LINGMA_REMOTE_BASE_URL")); value != "" {
|
||||||
return strings.TrimRight(value, "/")
|
return BaseURLHint{URL: strings.TrimRight(value, "/"), Source: "LINGMA_REMOTE_BASE_URL"}
|
||||||
}
|
}
|
||||||
for _, path := range candidateConfigFiles() {
|
for _, path := range candidateConfigFiles() {
|
||||||
if value := readBaseURLHint(path); value != "" {
|
if value := readBaseURLHint(path); value != "" {
|
||||||
return strings.TrimRight(value, "/")
|
return BaseURLHint{URL: strings.TrimRight(value, "/"), Source: path}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return DefaultBaseURL
|
return BaseURLHint{URL: DefaultBaseURL, Source: "default"}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) Warmup(ctx context.Context) error {
|
func (c *Client) Warmup(ctx context.Context) error {
|
||||||
@@ -292,7 +303,7 @@ func (c *Client) headers(cred Credential, path string, body string) (map[string]
|
|||||||
"Cosy-User": cred.UserID,
|
"Cosy-User": cred.UserID,
|
||||||
"Cosy-Clientip": "198.18.0.1",
|
"Cosy-Clientip": "198.18.0.1",
|
||||||
"Cosy-Clienttype": "2",
|
"Cosy-Clienttype": "2",
|
||||||
"Cosy-Machineos": "x86_64_windows",
|
"Cosy-Machineos": MachineOSHeader(),
|
||||||
"Cosy-Machinetoken": "",
|
"Cosy-Machinetoken": "",
|
||||||
"Cosy-Machinetype": "",
|
"Cosy-Machinetype": "",
|
||||||
"Cosy-Version": c.cfg.CosyVersion,
|
"Cosy-Version": c.cfg.CosyVersion,
|
||||||
@@ -381,12 +392,20 @@ func candidateConfigFiles() []string {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return []string{
|
paths := []string{
|
||||||
filepath.Join(home, ".lingma", "extension", "server", "config.json"),
|
filepath.Join(home, ".lingma", "extension", "server", "config.json"),
|
||||||
filepath.Join(home, ".lingma", "extension", "local", "config.json"),
|
filepath.Join(home, ".lingma", "extension", "local", "config.json"),
|
||||||
filepath.Join(home, ".lingma", "bin", "config.json"),
|
filepath.Join(home, ".lingma", "bin", "config.json"),
|
||||||
filepath.Join(home, ".config", "lingma-ipc-proxy", "config.json"),
|
filepath.Join(home, ".config", "lingma-ipc-proxy", "config.json"),
|
||||||
|
filepath.Join(home, ".lingma", "logs", "lingma.log"),
|
||||||
|
filepath.Join(home, ".lingma", "logs", "lingma-extension.log"),
|
||||||
|
filepath.Join(home, ".lingma", "vscode", "sharedClientCache", "logs", "lingma.log"),
|
||||||
|
filepath.Join(home, ".lingma", "vscode", "sharedClientCache", "logs", "lingma-extension.log"),
|
||||||
}
|
}
|
||||||
|
for _, root := range lingmaLogRoots(home) {
|
||||||
|
paths = append(paths, recentLingmaAppLogs(root)...)
|
||||||
|
}
|
||||||
|
return paths
|
||||||
}
|
}
|
||||||
|
|
||||||
func readBaseURLHint(path string) string {
|
func readBaseURLHint(path string) string {
|
||||||
@@ -396,13 +415,12 @@ func readBaseURLHint(path string) string {
|
|||||||
}
|
}
|
||||||
var value any
|
var value any
|
||||||
if err := json.Unmarshal(body, &value); err != nil {
|
if err := json.Unmarshal(body, &value); err != nil {
|
||||||
text := string(body)
|
return extractBaseURLFromText(string(body))
|
||||||
if strings.Contains(text, "lingma.alibabacloud.com") {
|
|
||||||
return DefaultBaseURL
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
}
|
||||||
return findBaseURL(value)
|
if value := findBaseURL(value); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return extractBaseURLFromText(string(body))
|
||||||
}
|
}
|
||||||
|
|
||||||
func findBaseURL(value any) string {
|
func findBaseURL(value any) string {
|
||||||
@@ -429,6 +447,146 @@ func findBaseURL(value any) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func lingmaLogRoots(home string) []string {
|
||||||
|
roots := []string{
|
||||||
|
filepath.Join(home, ".lingma", "logs"),
|
||||||
|
filepath.Join(home, ".lingma", "vscode", "sharedClientCache", "logs"),
|
||||||
|
filepath.Join(home, "Library", "Application Support", "Lingma", "logs"),
|
||||||
|
}
|
||||||
|
for _, envName := range []string{"APPDATA", "LOCALAPPDATA", "ProgramData"} {
|
||||||
|
if value := strings.TrimSpace(os.Getenv(envName)); value != "" {
|
||||||
|
roots = append(roots,
|
||||||
|
filepath.Join(value, "Lingma", "logs"),
|
||||||
|
filepath.Join(value, "Code", "User", "globalStorage", "alibaba-cloud.tongyi-lingma", "logs"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if value := strings.TrimSpace(os.Getenv("XDG_CONFIG_HOME")); value != "" {
|
||||||
|
roots = append(roots, filepath.Join(value, "Lingma", "logs"))
|
||||||
|
}
|
||||||
|
if value := strings.TrimSpace(os.Getenv("XDG_STATE_HOME")); value != "" {
|
||||||
|
roots = append(roots, filepath.Join(value, "Lingma", "logs"))
|
||||||
|
}
|
||||||
|
roots = append(roots,
|
||||||
|
filepath.Join(home, ".config", "Lingma", "logs"),
|
||||||
|
filepath.Join(home, ".local", "state", "Lingma", "logs"),
|
||||||
|
)
|
||||||
|
return uniqueStrings(roots)
|
||||||
|
}
|
||||||
|
|
||||||
|
func recentLingmaAppLogs(root string) []string {
|
||||||
|
entries, err := os.ReadDir(root)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
type logDir struct {
|
||||||
|
path string
|
||||||
|
modTime int64
|
||||||
|
}
|
||||||
|
dirs := make([]logDir, 0, len(entries))
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
info, err := entry.Info()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dirs = append(dirs, logDir{path: filepath.Join(root, entry.Name()), modTime: info.ModTime().UnixNano()})
|
||||||
|
}
|
||||||
|
sort.Slice(dirs, func(i, j int) bool { return dirs[i].modTime > dirs[j].modTime })
|
||||||
|
if len(dirs) > 5 {
|
||||||
|
dirs = dirs[:5]
|
||||||
|
}
|
||||||
|
paths := make([]string, 0, len(dirs)*4)
|
||||||
|
for _, dir := range dirs {
|
||||||
|
_ = filepath.WalkDir(dir.path, func(path string, entry os.DirEntry, err error) error {
|
||||||
|
if err != nil || entry.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
name := entry.Name()
|
||||||
|
lowerName := strings.ToLower(name)
|
||||||
|
if lowerName == "renderer.log" ||
|
||||||
|
lowerName == "sharedprocess.log" ||
|
||||||
|
lowerName == "main.log" ||
|
||||||
|
strings.HasSuffix(name, "Lingma.log") ||
|
||||||
|
strings.Contains(lowerName, "lingma") && strings.HasSuffix(lowerName, ".log") {
|
||||||
|
paths = append(paths, path)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return paths
|
||||||
|
}
|
||||||
|
|
||||||
|
func uniqueStrings(values []string) []string {
|
||||||
|
seen := make(map[string]struct{}, len(values))
|
||||||
|
out := make([]string, 0, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[value]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[value] = struct{}{}
|
||||||
|
out = append(out, value)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractBaseURLFromText(text string) string {
|
||||||
|
for _, marker := range []string{
|
||||||
|
"endpoint config:",
|
||||||
|
"Using service url:",
|
||||||
|
"Download asset from:",
|
||||||
|
"https://ai-lingma",
|
||||||
|
"https://lingma",
|
||||||
|
} {
|
||||||
|
if value := extractBaseURLAfterMarker(text, marker); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractBaseURLAfterMarker(text, marker string) string {
|
||||||
|
lowerText := strings.ToLower(text)
|
||||||
|
lowerMarker := strings.ToLower(marker)
|
||||||
|
index := strings.LastIndex(lowerText, lowerMarker)
|
||||||
|
if index < 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
tail := text[index+len(marker):]
|
||||||
|
if strings.HasPrefix(lowerMarker, "https://") {
|
||||||
|
tail = marker + tail
|
||||||
|
}
|
||||||
|
for _, field := range strings.Fields(tail) {
|
||||||
|
field = strings.Trim(field, `"'<>),]}`)
|
||||||
|
if value := normalizeRemoteBaseURLHint(field); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeRemoteBaseURLHint(raw string) string {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
parsed, err := url.Parse(raw)
|
||||||
|
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
host := strings.ToLower(parsed.Host)
|
||||||
|
if !strings.Contains(host, "lingma") && !strings.Contains(host, "rdc.aliyuncs.com") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return parsed.Scheme + "://" + parsed.Host
|
||||||
|
}
|
||||||
|
|
||||||
func estimateTokens(text string) int {
|
func estimateTokens(text string) int {
|
||||||
text = strings.TrimSpace(text)
|
text = strings.TrimSpace(text)
|
||||||
if text == "" {
|
if text == "" {
|
||||||
|
|||||||
19
internal/remote/client_test.go
Normal file
19
internal/remote/client_test.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package remote
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestExtractBaseURLFromEndpointLog(t *testing.T) {
|
||||||
|
got := extractBaseURLFromText(`2026-04-10 INFO Update endpoint success. endpoint config: https://ai-lingma-cmb01-cn-beijing.rdc.aliyuncs.com`)
|
||||||
|
want := "https://ai-lingma-cmb01-cn-beijing.rdc.aliyuncs.com"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractBaseURLFromMarketplaceLog(t *testing.T) {
|
||||||
|
got := extractBaseURLFromText(`2026-04-30 [info] [Marketplace] Using service url: https://ai-lingma-cmb01-cn-beijing.rdc.aliyuncs.com/marketplace/_apis/public/gallery`)
|
||||||
|
want := "https://ai-lingma-cmb01-cn-beijing.rdc.aliyuncs.com"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -62,22 +63,33 @@ func loadCredentialFile(path string) (Credential, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func importLingmaCacheCredential() (Credential, error) {
|
func importLingmaCacheCredential() (Credential, error) {
|
||||||
home, err := os.UserHomeDir()
|
var attempts []string
|
||||||
if err != nil {
|
for _, lingmaDir := range candidateLingmaCacheDirs() {
|
||||||
return Credential{}, err
|
cred, err := importLingmaCacheCredentialFromDir(lingmaDir)
|
||||||
|
if err == nil {
|
||||||
|
return cred, nil
|
||||||
|
}
|
||||||
|
attempts = append(attempts, fmt.Sprintf("%s: %v", lingmaDir, err))
|
||||||
}
|
}
|
||||||
lingmaDir := filepath.Join(home, ".lingma")
|
if len(attempts) == 0 {
|
||||||
|
return Credential{}, errors.New("no Lingma cache directory candidate was found")
|
||||||
|
}
|
||||||
|
return Credential{}, fmt.Errorf("load Lingma login cache: %s", strings.Join(attempts, "; "))
|
||||||
|
}
|
||||||
|
|
||||||
|
func importLingmaCacheCredentialFromDir(lingmaDir string) (Credential, error) {
|
||||||
machineID, err := loadMachineID(lingmaDir)
|
machineID, err := loadMachineID(lingmaDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Credential{}, err
|
return Credential{}, err
|
||||||
}
|
}
|
||||||
encrypted, err := os.ReadFile(filepath.Join(lingmaDir, "cache", "user"))
|
userPath := filepath.Join(lingmaDir, "cache", "user")
|
||||||
|
encrypted, err := os.ReadFile(userPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Credential{}, fmt.Errorf("read ~/.lingma/cache/user: %w", err)
|
return Credential{}, fmt.Errorf("read %s: %w", userPath, err)
|
||||||
}
|
}
|
||||||
ciphertext, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(encrypted)))
|
ciphertext, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(encrypted)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Credential{}, fmt.Errorf("decode ~/.lingma/cache/user: %w", err)
|
return Credential{}, fmt.Errorf("decode %s: %w", userPath, err)
|
||||||
}
|
}
|
||||||
plaintext, err := decryptCacheUser(machineID, ciphertext)
|
plaintext, err := decryptCacheUser(machineID, ciphertext)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -90,19 +102,46 @@ func importLingmaCacheCredential() (Credential, error) {
|
|||||||
ExpireTime any `json:"expire_time"`
|
ExpireTime any `json:"expire_time"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(plaintext, &payload); err != nil {
|
if err := json.Unmarshal(plaintext, &payload); err != nil {
|
||||||
return Credential{}, fmt.Errorf("parse ~/.lingma/cache/user: %w", err)
|
return Credential{}, fmt.Errorf("parse %s: %w", userPath, err)
|
||||||
}
|
}
|
||||||
cred := Credential{
|
cred := Credential{
|
||||||
CosyKey: payload.Key,
|
CosyKey: payload.Key,
|
||||||
EncryptUserInfo: payload.EncryptUserInfo,
|
EncryptUserInfo: payload.EncryptUserInfo,
|
||||||
UserID: payload.UserID,
|
UserID: payload.UserID,
|
||||||
MachineID: machineID,
|
MachineID: machineID,
|
||||||
Source: "~/.lingma/cache/user",
|
Source: userPath,
|
||||||
TokenExpireTime: parseExpireAny(payload.ExpireTime),
|
TokenExpireTime: parseExpireAny(payload.ExpireTime),
|
||||||
}
|
}
|
||||||
return cred, validateCredential(cred)
|
return cred, validateCredential(cred)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func candidateLingmaCacheDirs() []string {
|
||||||
|
if explicit := strings.TrimSpace(os.Getenv("LINGMA_CACHE_DIR")); explicit != "" {
|
||||||
|
return []string{expandHome(explicit)}
|
||||||
|
}
|
||||||
|
|
||||||
|
var dirs []string
|
||||||
|
if home, err := os.UserHomeDir(); err == nil && strings.TrimSpace(home) != "" {
|
||||||
|
dirs = append(dirs,
|
||||||
|
filepath.Join(home, ".lingma"),
|
||||||
|
filepath.Join(home, ".config", "Lingma"),
|
||||||
|
filepath.Join(home, ".local", "share", "Lingma"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
for _, envName := range []string{"APPDATA", "LOCALAPPDATA", "ProgramData"} {
|
||||||
|
if value := strings.TrimSpace(os.Getenv(envName)); value != "" {
|
||||||
|
dirs = append(dirs,
|
||||||
|
filepath.Join(value, "Lingma"),
|
||||||
|
filepath.Join(value, "lingma"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if value := strings.TrimSpace(os.Getenv("XDG_CONFIG_HOME")); value != "" {
|
||||||
|
dirs = append(dirs, filepath.Join(value, "Lingma"))
|
||||||
|
}
|
||||||
|
return uniquePathStrings(dirs)
|
||||||
|
}
|
||||||
|
|
||||||
func loadMachineID(lingmaDir string) (string, error) {
|
func loadMachineID(lingmaDir string) (string, error) {
|
||||||
if body, err := os.ReadFile(filepath.Join(lingmaDir, "cache", "id")); err == nil {
|
if body, err := os.ReadFile(filepath.Join(lingmaDir, "cache", "id")); err == nil {
|
||||||
if value := strings.TrimSpace(string(body)); value != "" {
|
if value := strings.TrimSpace(string(body)); value != "" {
|
||||||
@@ -111,7 +150,7 @@ func loadMachineID(lingmaDir string) (string, error) {
|
|||||||
}
|
}
|
||||||
logBody, err := os.ReadFile(filepath.Join(lingmaDir, "logs", "lingma.log"))
|
logBody, err := os.ReadFile(filepath.Join(lingmaDir, "logs", "lingma.log"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("remote credential requires ~/.lingma/cache/id or lingma.log machine id: %w", err)
|
return "", fmt.Errorf("remote credential requires cache/id or lingma.log machine id: %w", err)
|
||||||
}
|
}
|
||||||
markers := []string{"using machine id from file:", "machine id:"}
|
markers := []string{"using machine id from file:", "machine id:"}
|
||||||
text := string(logBody)
|
text := string(logBody)
|
||||||
@@ -128,7 +167,7 @@ func loadMachineID(lingmaDir string) (string, error) {
|
|||||||
return value, nil
|
return value, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "", errors.New("machine id not found in ~/.lingma cache")
|
return "", errors.New("machine id not found in Lingma cache")
|
||||||
}
|
}
|
||||||
|
|
||||||
func decryptCacheUser(machineID string, ciphertext []byte) ([]byte, error) {
|
func decryptCacheUser(machineID string, ciphertext []byte) ([]byte, error) {
|
||||||
@@ -203,3 +242,44 @@ func parseExpireAny(value any) int64 {
|
|||||||
func IsExpired(cred Credential, margin time.Duration) bool {
|
func IsExpired(cred Credential, margin time.Duration) bool {
|
||||||
return cred.TokenExpireTime > 0 && time.Now().Add(margin).UnixMilli() > cred.TokenExpireTime
|
return cred.TokenExpireTime > 0 && time.Now().Add(margin).UnixMilli() > cred.TokenExpireTime
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func MachineOSHeader() string {
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "darwin":
|
||||||
|
if runtime.GOARCH == "arm64" {
|
||||||
|
return "arm64_darwin"
|
||||||
|
}
|
||||||
|
return "x86_64_darwin"
|
||||||
|
case "windows":
|
||||||
|
if runtime.GOARCH == "arm64" {
|
||||||
|
return "arm64_windows"
|
||||||
|
}
|
||||||
|
return "x86_64_windows"
|
||||||
|
case "linux":
|
||||||
|
if runtime.GOARCH == "arm64" {
|
||||||
|
return "arm64_linux"
|
||||||
|
}
|
||||||
|
return "x86_64_linux"
|
||||||
|
default:
|
||||||
|
return runtime.GOARCH + "_" + runtime.GOOS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func uniquePathStrings(values []string) []string {
|
||||||
|
seen := make(map[string]struct{}, len(values))
|
||||||
|
out := make([]string, 0, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cleaned := filepath.Clean(value)
|
||||||
|
key := strings.ToLower(cleaned)
|
||||||
|
if _, ok := seen[key]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
out = append(out, cleaned)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user