diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f5f3ea7..d544f45 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,7 +7,7 @@ on: workflow_dispatch: inputs: tag: - description: "Release tag, for example v1.2.0" + description: "Release tag, for example v1.2.1" required: true permissions: diff --git a/README.md b/README.md index 4f94802..e075a7a 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ The project is designed for tools such as Claude Code, Cline, Continue, OpenCode ## Current Version -The current desktop line is `v1.2.0`. +The current desktop line is `v1.2.1`. Release builds are produced by GitHub Actions for: @@ -249,6 +249,21 @@ Priority order: 4. command-line flags 5. desktop Settings page updates +## Concurrency + +Older builds rejected concurrent chat requests with a `rate_limit_error` saying the proxy handled one request at a time. Current builds use a small execution pool instead: + +- default max concurrent chat requests: `4` +- override with `LINGMA_PROXY_MAX_CONCURRENT` +- allowed range: `1` to `16` +- `session_mode=auto` uses fresh Lingma sessions so parallel editor requests do not share one sticky session + +Example: + +```bash +LINGMA_PROXY_MAX_CONCURRENT=8 lingma-ipc-proxy --port 8095 +``` + ## Function Calling / Tool Calling Lingma does not expose a native public OpenAI/Anthropic tool-call protocol, so this proxy emulates tool calling: @@ -291,7 +306,7 @@ The desktop bundle name is always `Lingma IPC Proxy`. The release workflow is triggered by: -- pushing a tag such as `v1.2.0` +- pushing a tag such as `v1.2.1` - manually running the `Release` workflow with a tag input Planned improvements: diff --git a/README.zh-CN.md b/README.zh-CN.md index 4acba81..f139e3a 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -11,7 +11,7 @@ ## 当前版本 -当前桌面端版本线:`v1.2.0` +当前桌面端版本线:`v1.2.1` GitHub Actions 会在 Release 中产出: @@ -328,6 +328,27 @@ export ANTHROPIC_API_KEY="any" 4. 命令行参数 5. 桌面端设置页保存的配置 +## 并发请求 + +旧版本为了避免 Lingma 会话串扰,在 HTTP 层做了全局单请求限制,所以并发请求会返回: + +```json +{"error":{"message":"Lingma IPC proxy handles one request at a time.","type":"rate_limit_error"},"type":"error"} +``` + +现在已经改成有限并发执行池: + +- 默认最多同时处理 `4` 个 Chat 请求。 +- 可以用 `LINGMA_PROXY_MAX_CONCURRENT` 覆盖。 +- 合法范围是 `1` 到 `16`。 +- `session_mode=auto` 默认使用 fresh Lingma 会话,避免多个编辑器并发请求挤到同一个 sticky session 里串上下文。 + +示例: + +```bash +LINGMA_PROXY_MAX_CONCURRENT=8 lingma-ipc-proxy --port 8095 +``` + ## 工具调用实现 Lingma 插件本身没有公开标准 OpenAI / Anthropic Tools 协议,所以本项目使用 **Tool Emulation**: @@ -394,8 +415,8 @@ Lingma IPC Proxy 发布方式: ```bash -git tag v1.2.0 -git push origin v1.2.0 +git tag v1.2.1 +git push origin v1.2.1 ``` 也可以在 GitHub Actions 页面手动运行 `Release` workflow,并输入 tag。 diff --git a/desktop/frontend/src/App.vue b/desktop/frontend/src/App.vue index d7d0b50..a8ca9e3 100644 --- a/desktop/frontend/src/App.vue +++ b/desktop/frontend/src/App.vue @@ -222,7 +222,7 @@ onUnmounted(() => {
{{ status.running ? 'Proxy Running' : 'Proxy Stopped' }} - v1.2.0 + v1.2.1
diff --git a/desktop/frontend/src/components/JsonTree.vue b/desktop/frontend/src/components/JsonTree.vue new file mode 100644 index 0000000..908cf50 --- /dev/null +++ b/desktop/frontend/src/components/JsonTree.vue @@ -0,0 +1,113 @@ + + + diff --git a/desktop/frontend/src/components/JsonViewer.vue b/desktop/frontend/src/components/JsonViewer.vue new file mode 100644 index 0000000..97f4f22 --- /dev/null +++ b/desktop/frontend/src/components/JsonViewer.vue @@ -0,0 +1,34 @@ + + + diff --git a/desktop/frontend/src/style.css b/desktop/frontend/src/style.css index c6264f0..2865082 100644 --- a/desktop/frontend/src/style.css +++ b/desktop/frontend/src/style.css @@ -877,8 +877,10 @@ button { .requests-panel .table-scroll { flex: 0 0 auto; - min-height: 112px; - max-height: min(360px, 42vh); + --request-row-height: 72px; + --request-head-height: 43px; + min-height: calc(var(--request-head-height) + var(--request-row-height)); + max-height: calc(var(--request-head-height) + var(--request-row-height) * 5); overflow: auto; } @@ -898,6 +900,7 @@ button { width: 100%; border-collapse: collapse; font-size: 13px; + background: rgba(255, 255, 255, 0.7); -webkit-user-select: text; user-select: text; } @@ -905,10 +908,10 @@ button { .data-table th, .data-table td { min-width: 0; - padding: 9px 14px; + padding: 8px 14px; border-bottom: 1px solid var(--line); text-align: left; - vertical-align: top; + vertical-align: middle; } .data-table th { @@ -922,15 +925,40 @@ button { } .data-table tbody tr { + height: var(--request-row-height, 64px); cursor: pointer; + background: rgba(255, 255, 255, 0.34); + transition: background-color 140ms ease, box-shadow 140ms ease; } .data-table tbody tr:hover { background: rgba(232, 240, 255, 0.58); } +.data-table tbody tr.selected { + background: rgba(219, 231, 255, 0.86); + box-shadow: inset 3px 0 0 var(--blue); +} + +:root[data-theme="dark"] .data-table { + background: rgba(15, 23, 42, 0.8); +} + +:root[data-theme="dark"] .data-table th { + background: rgba(15, 23, 42, 0.96); +} + +:root[data-theme="dark"] .data-table tbody tr { + background: rgba(20, 31, 48, 0.7); +} + :root[data-theme="dark"] .data-table tbody tr:hover { - background: rgba(82, 105, 139, 0.16); + background: rgba(45, 65, 96, 0.9); +} + +:root[data-theme="dark"] .data-table tbody tr.selected { + background: rgba(38, 65, 112, 0.96); + box-shadow: inset 3px 0 0 #67a1ff; } .chip, @@ -1265,6 +1293,10 @@ button:disabled { user-select: text; } +:root[data-theme="dark"] .detail-panel { + background: rgba(12, 18, 30, 0.96); +} + .detail-section { display: flex; flex: 0 0 auto; @@ -1295,7 +1327,8 @@ button:disabled { } .detail-panel pre, -.code-block { +.code-block, +.json-viewer { max-height: min(320px, 34vh); margin: 0 0 14px; overflow: auto; @@ -1315,15 +1348,145 @@ button:disabled { word-break: break-word; } -.detail-panel pre { +.json-viewer { + white-space: normal; +} + +.json-viewer pre { + margin: 0; + color: inherit; + font: inherit; + white-space: pre-wrap; +} + +.json-viewer.empty { + color: var(--muted); +} + +.json-node { + min-width: max-content; +} + +.json-line { + display: flex; + min-height: 22px; + align-items: flex-start; + gap: 2px; + white-space: pre; +} + +.json-toggle, +.json-toggle-spacer { + width: 20px; + height: 20px; + flex: 0 0 20px; +} + +.json-toggle { + display: inline-grid; + margin: 1px 2px 0 0; + padding: 0; + place-items: center; + color: var(--muted); + border: 0; + background: transparent; + cursor: pointer; +} + +.json-toggle:hover { + color: var(--blue); +} + +.json-toggle i { + font-size: 11px; +} + +.json-key { + color: #8250df; +} + +.json-string { + color: #116329; +} + +.json-number { + color: #0550ae; +} + +.json-boolean { + color: #cf222e; +} + +.json-null { + color: #6e7781; +} + +.json-punctuation { + color: #57606a; +} + +.json-summary { + margin: 0 3px; + padding: 0 7px; + color: var(--muted); + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: 999px; + background: rgba(148, 163, 184, 0.12); + font: inherit; + cursor: pointer; +} + +.json-summary:hover { + color: var(--blue); + border-color: rgba(44, 111, 231, 0.38); +} + +:root[data-theme="dark"] .json-key { + color: #c4b5fd; +} + +:root[data-theme="dark"] .json-string { + color: #86efac; +} + +:root[data-theme="dark"] .json-number { + color: #93c5fd; +} + +:root[data-theme="dark"] .json-boolean { + color: #fca5a5; +} + +:root[data-theme="dark"] .json-null, +:root[data-theme="dark"] .json-punctuation { + color: #9aa8bd; +} + +:root[data-theme="dark"] .json-summary { + color: #b7c3d6; + border-color: rgba(148, 163, 184, 0.24); + background: rgba(30, 41, 59, 0.78); +} + +.detail-panel pre, +.json-viewer { scrollbar-width: none; } -.detail-panel pre::-webkit-scrollbar { +.detail-panel pre::-webkit-scrollbar, +.json-viewer::-webkit-scrollbar { width: 0; height: 0; } +:root[data-theme="dark"] .detail-panel pre, +:root[data-theme="dark"] .code-block, +:root[data-theme="dark"] .json-viewer { + color: var(--text); + border-color: var(--line); + background: rgba(17, 24, 39, 0.94); +} + .log-row { diff --git a/desktop/frontend/src/views/Requests.vue b/desktop/frontend/src/views/Requests.vue index 2a6494c..3d19d25 100644 --- a/desktop/frontend/src/views/Requests.vue +++ b/desktop/frontend/src/views/Requests.vue @@ -2,6 +2,7 @@ import { computed, onMounted, onUnmounted, ref } from 'vue' import { ClearRequests, GetRequests } from '../../wailsjs/go/main/App.js' import { ClipboardSetText, EventsOff, EventsOn } from '../../wailsjs/runtime' +import JsonViewer from '../components/JsonViewer.vue' const emit = defineEmits(['notice']) @@ -137,7 +138,12 @@ onUnmounted(() => { - + {{ request.time }} {{ request.method }} @@ -162,7 +168,7 @@ onUnmounted(() => { -
{{ filtered[selected].reqBody || '空请求体' }}
+
@@ -173,7 +179,7 @@ onUnmounted(() => {
-
{{ filtered[selected].respBody || '空响应体' }}
+ diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index fb36632..7f329c1 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -8,6 +8,8 @@ import ( "fmt" "io" "net/http" + "os" + "strconv" "strings" "time" @@ -80,7 +82,7 @@ type modelResponse struct { func NewServer(addr string, svc *service.Service) *Server { s := &Server{ svc: svc, - sem: make(chan struct{}, 1), + sem: make(chan struct{}, maxConcurrentRequests()), } mux := http.NewServeMux() mux.HandleFunc("/", s.handleRoot) @@ -189,8 +191,8 @@ func (s *Server) handleAnthropicMessages(w http.ResponseWriter, r *http.Request) writeAnthropicError(w, http.StatusMethodNotAllowed, "invalid_request_error", "method not allowed") return } - if !s.tryAcquire() { - writeAnthropicError(w, http.StatusTooManyRequests, "rate_limit_error", "Lingma IPC proxy handles one request at a time.") + if !s.acquire(r.Context()) { + writeAnthropicError(w, http.StatusRequestTimeout, "timeout_error", "request was cancelled while waiting for a proxy execution slot") return } defer s.release() @@ -260,8 +262,8 @@ func (s *Server) handleOpenAIChatCompletions(w http.ResponseWriter, r *http.Requ writeOpenAIError(w, http.StatusMethodNotAllowed, "invalid_request_error", "method not allowed") return } - if !s.tryAcquire() { - writeOpenAIError(w, http.StatusTooManyRequests, "rate_limit_error", "Lingma IPC proxy handles one request at a time.") + if !s.acquire(r.Context()) { + writeOpenAIError(w, http.StatusRequestTimeout, "timeout_error", "request was cancelled while waiting for a proxy execution slot") return } defer s.release() @@ -971,11 +973,26 @@ func withCORS(next http.Handler) http.Handler { }) } -func (s *Server) tryAcquire() bool { +func maxConcurrentRequests() int { + raw := strings.TrimSpace(os.Getenv("LINGMA_PROXY_MAX_CONCURRENT")) + if raw == "" { + return 4 + } + n, err := strconv.Atoi(raw) + if err != nil || n < 1 { + return 4 + } + if n > 16 { + return 16 + } + return n +} + +func (s *Server) acquire(ctx context.Context) bool { select { case s.sem <- struct{}{}: return true - default: + case <-ctx.Done(): return false } } diff --git a/internal/service/service.go b/internal/service/service.go index cdb94b7..d351536 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -721,11 +721,7 @@ func resolveSessionMode(req ChatRequest, configured SessionMode) SessionMode { if configured != SessionModeAuto { return configured } - hasTools := len(req.Tools) > 0 && req.ToolChoice.Mode != "none" - if hasTools || strings.TrimSpace(req.System) != "" || len(filteredMessages(req.Messages)) > 1 { - return SessionModeFresh - } - return SessionModeReuse + return SessionModeFresh } func extractLastUserImages(messages []ChatMessage) []Image {