feat: add desktop app release packaging
185
.github/workflows/release.yml
vendored
@@ -7,16 +7,19 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Release tag, for example v0.1.0"
|
||||
description: "Release tag, for example v1.2.0"
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test
|
||||
runs-on: windows-latest
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -29,53 +32,173 @@ jobs:
|
||||
- name: Run tests
|
||||
run: go test ./...
|
||||
|
||||
build-release:
|
||||
name: Build And Publish Release
|
||||
runs-on: windows-latest
|
||||
build-cli-macos:
|
||||
name: Build CLI macOS
|
||||
runs-on: macos-latest
|
||||
needs: test
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
|
||||
EXE_NAME: lingma-ipc-proxy_${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}_windows_amd64.exe
|
||||
ARCHIVE_NAME: lingma-ipc-proxy_${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}_windows_amd64.zip
|
||||
CHECKSUM_NAME: lingma-ipc-proxy_${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}_sha256.txt
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Build binary
|
||||
shell: pwsh
|
||||
run: .\scripts\build.ps1 -Clean
|
||||
- name: Build CLI
|
||||
run: |
|
||||
mkdir -p dist
|
||||
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -trimpath -ldflags "-s -w" -o dist/lingma-ipc-proxy ./cmd/lingma-ipc-proxy
|
||||
tar -C dist -czf "lingma-ipc-proxy_${RELEASE_TAG}_darwin_arm64.tar.gz" lingma-ipc-proxy
|
||||
|
||||
- name: Prepare release assets
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cli-macos
|
||||
path: lingma-ipc-proxy_${{ env.RELEASE_TAG }}_darwin_arm64.tar.gz
|
||||
|
||||
build-cli-windows:
|
||||
name: Build CLI Windows
|
||||
runs-on: windows-latest
|
||||
needs: test
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Build CLI
|
||||
shell: pwsh
|
||||
run: |
|
||||
Copy-Item .\dist\lingma-ipc-proxy.exe .\$env:EXE_NAME -Force
|
||||
.\scripts\build.ps1 -Clean
|
||||
$asset = "lingma-ipc-proxy_${env:RELEASE_TAG}_windows_amd64.zip"
|
||||
Compress-Archive -Path .\dist\lingma-ipc-proxy.exe -DestinationPath $asset -Force
|
||||
|
||||
$exePath = Join-Path $PWD $env:EXE_NAME
|
||||
$archivePath = Join-Path $PWD $env:ARCHIVE_NAME
|
||||
$checksumPath = Join-Path $PWD $env:CHECKSUM_NAME
|
||||
Compress-Archive -Path $exePath -DestinationPath $archivePath -Force
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cli-windows
|
||||
path: lingma-ipc-proxy_${{ env.RELEASE_TAG }}_windows_amd64.zip
|
||||
|
||||
$exeHash = (Get-FileHash -Algorithm SHA256 $exePath).Hash.ToLowerInvariant()
|
||||
$zipHash = (Get-FileHash -Algorithm SHA256 $archivePath).Hash.ToLowerInvariant()
|
||||
@(
|
||||
"$exeHash $($env:EXE_NAME)"
|
||||
"$zipHash $($env:ARCHIVE_NAME)"
|
||||
) | Set-Content $checksumPath
|
||||
build-desktop-macos:
|
||||
name: Build Desktop macOS
|
||||
runs-on: macos-latest
|
||||
needs: test
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: npm
|
||||
cache-dependency-path: desktop/frontend/package-lock.json
|
||||
|
||||
- name: Install Wails
|
||||
run: go install github.com/wailsapp/wails/v2/cmd/wails@v2.12.0
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: npm ci --prefix desktop/frontend
|
||||
|
||||
- name: Build app
|
||||
run: |
|
||||
cd desktop
|
||||
wails build -platform darwin/arm64 -clean
|
||||
|
||||
- name: Package app
|
||||
run: |
|
||||
APP_PATH="$(find desktop/build/bin -maxdepth 1 -name '*.app' -print -quit)"
|
||||
test -n "$APP_PATH"
|
||||
test "$(basename "$APP_PATH")" = "Lingma IPC Proxy.app"
|
||||
ditto -c -k --sequesterRsrc --keepParent "$APP_PATH" "lingma-ipc-proxy-desktop_${RELEASE_TAG}_darwin_arm64.zip"
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: desktop-macos
|
||||
path: lingma-ipc-proxy-desktop_${{ env.RELEASE_TAG }}_darwin_arm64.zip
|
||||
|
||||
build-desktop-windows:
|
||||
name: Build Desktop Windows
|
||||
runs-on: windows-latest
|
||||
needs: test
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: npm
|
||||
cache-dependency-path: desktop/frontend/package-lock.json
|
||||
|
||||
- name: Install Wails
|
||||
shell: pwsh
|
||||
run: go install github.com/wailsapp/wails/v2/cmd/wails@v2.12.0
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: npm ci --prefix desktop/frontend
|
||||
|
||||
- name: Build app
|
||||
shell: pwsh
|
||||
run: |
|
||||
Push-Location desktop
|
||||
wails build -platform windows/amd64 -clean
|
||||
Pop-Location
|
||||
|
||||
- name: Package app
|
||||
shell: pwsh
|
||||
run: |
|
||||
$exe = Get-ChildItem .\desktop\build\bin -Filter *.exe | Select-Object -First 1
|
||||
if (-not $exe) { throw "Desktop exe was not produced" }
|
||||
if ($exe.Name -ne "LingmaProxy.exe") { throw "Unexpected desktop exe name: $($exe.Name)" }
|
||||
$asset = "lingma-ipc-proxy-desktop_${env:RELEASE_TAG}_windows_amd64.zip"
|
||||
Compress-Archive -Path $exe.FullName -DestinationPath $asset -Force
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: desktop-windows
|
||||
path: lingma-ipc-proxy-desktop_${{ env.RELEASE_TAG }}_windows_amd64.zip
|
||||
|
||||
publish:
|
||||
name: Publish Release
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build-cli-macos
|
||||
- build-cli-windows
|
||||
- build-desktop-macos
|
||||
- build-desktop-windows
|
||||
steps:
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
merge-multiple: true
|
||||
|
||||
- name: Generate checksums
|
||||
run: |
|
||||
cd artifacts
|
||||
sha256sum * > "lingma-ipc-proxy_${RELEASE_TAG}_sha256.txt"
|
||||
|
||||
- name: Create or update release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ env.RELEASE_TAG }}
|
||||
generate_release_notes: true
|
||||
files: |
|
||||
${{ env.EXE_NAME }}
|
||||
${{ env.ARCHIVE_NAME }}
|
||||
${{ env.CHECKSUM_NAME }}
|
||||
files: artifacts/*
|
||||
|
||||
10
.gitignore
vendored
@@ -1,5 +1,12 @@
|
||||
bin/
|
||||
dist/
|
||||
lingma-ipc-proxy
|
||||
desktop/build/bin/
|
||||
desktop/frontend/dist/
|
||||
desktop/frontend/node_modules/
|
||||
desktop/frontend/*.png
|
||||
*.app
|
||||
*.zip
|
||||
*.exe
|
||||
*.dll
|
||||
*.so
|
||||
@@ -11,3 +18,6 @@ coverage.*
|
||||
.vscode/
|
||||
nul
|
||||
LINUXDO_POST.md
|
||||
|
||||
# Local iteration doc (not for git)
|
||||
ITERATION.md
|
||||
|
||||
570
README.md
@@ -1,344 +1,308 @@
|
||||
# lingma-ipc-proxy
|
||||
# Lingma IPC Proxy
|
||||
|
||||
[English](./README.md) | [简体中文](./README.zh-CN.md)
|
||||
|
||||
A standalone Go backend that talks to Lingma over Lingma's local pipe or websocket transport and exposes:
|
||||
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.
|
||||
|
||||
- `GET /v1/models`
|
||||
- `POST /v1/messages`
|
||||
- `POST /v1/chat/completions`
|
||||
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.
|
||||
|
||||
Current scope:
|
||||
## Current Version
|
||||
|
||||
- supports both non-streaming and streaming responses
|
||||
- one request at a time
|
||||
- supports Windows named-pipe transport and local websocket transport
|
||||
- directly uses Lingma IPC, not DOM/CDP
|
||||
- OpenAI-compatible `tools` / `tool_choice` support (tool emulation via prompt engineering)
|
||||
- Anthropic-compatible `tools` / `tool_choice` support
|
||||
The current desktop line is `v1.2.0`.
|
||||
|
||||
## Run
|
||||
Release builds are produced by GitHub Actions for:
|
||||
|
||||
```powershell
|
||||
cd C:\Workspace\Personal\lingma-ipc-proxy
|
||||
go run .\cmd\lingma-ipc-proxy
|
||||
| Asset | Platform | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `lingma-ipc-proxy_<tag>_darwin_arm64.tar.gz` | macOS | CLI proxy |
|
||||
| `lingma-ipc-proxy_<tag>_windows_amd64.zip` | Windows | CLI proxy |
|
||||
| `lingma-ipc-proxy-desktop_<tag>_darwin_arm64.zip` | macOS | Desktop app |
|
||||
| `lingma-ipc-proxy-desktop_<tag>_windows_amd64.zip` | Windows | Desktop app |
|
||||
| `lingma-ipc-proxy_<tag>_sha256.txt` | all | Checksums |
|
||||
|
||||
## Desktop App
|
||||
|
||||
The desktop app wraps the proxy with a native-feeling control panel:
|
||||
|
||||
- Start, stop, and restart the proxy.
|
||||
- Inspect health, latency, recent requests, models, settings, and logs.
|
||||
- View full request and response bodies with internal scrolling and hidden scrollbars.
|
||||
- Copy endpoint URLs, model IDs, request logs, and response logs with visible feedback.
|
||||
- Detect Lingma IPC paths automatically on macOS and Windows, with manual fallback settings.
|
||||
- Follow system theme automatically, or switch light/dark mode manually.
|
||||
- Keep the proxy running when the window is closed; quit explicitly from the app/menu.
|
||||
|
||||
### Screenshots
|
||||
|
||||
Light mode:
|
||||
|
||||

|
||||
|
||||
Dark mode:
|
||||
|
||||

|
||||
|
||||
Narrow window layout:
|
||||
|
||||

|
||||
|
||||
## Supported APIs
|
||||
|
||||
| API | Endpoint | Support |
|
||||
| --- | --- | --- |
|
||||
| Health | `GET /` and `GET /health` | supported |
|
||||
| Models | `GET /v1/models` | supported |
|
||||
| OpenAI Chat Completions | `POST /v1/chat/completions` | streaming and non-streaming |
|
||||
| Anthropic Messages | `POST /v1/messages` | streaming and non-streaming |
|
||||
|
||||
## What This Fork Adds
|
||||
|
||||
Compared with the original protocol proof of concept, this repository focuses on making the proxy usable as a complete local product:
|
||||
|
||||
- **Function Calling / Tools** for both OpenAI and Anthropic clients.
|
||||
- **Tool result continuation** for multi-step agent loops.
|
||||
- **Image input** for OpenAI `image_url` and Anthropic image blocks.
|
||||
- **More request parameter compatibility** so stricter clients can connect without custom patches.
|
||||
- **Full request and response recording** in the desktop app for debugging 400/500 errors.
|
||||
- **macOS and Windows desktop app** with start/stop/restart, settings, logs, model discovery, themes, and window lifecycle handling.
|
||||
- **Cross-platform release packaging** for CLI and desktop builds.
|
||||
|
||||
### OpenAI Compatibility
|
||||
|
||||
The proxy accepts common OpenAI request fields:
|
||||
|
||||
- `model`, `messages`, `stream`
|
||||
- `temperature`, `top_p`, `stop`
|
||||
- `max_tokens`, `max_completion_tokens`
|
||||
- `presence_penalty`, `frequency_penalty`
|
||||
- `tools`, `tool_choice`, `parallel_tool_calls`
|
||||
- `response_format`, `seed`, `user`, `reasoning_effort`
|
||||
- image input through `image_url` data URLs or HTTP URLs
|
||||
|
||||
### Anthropic Compatibility
|
||||
|
||||
The proxy accepts common Anthropic request fields:
|
||||
|
||||
- `model`, `system`, `messages`, `stream`
|
||||
- `temperature`, `top_p`, `top_k`, `stop_sequences`
|
||||
- `max_tokens`, `metadata`
|
||||
- `tools`, `tool_choice`
|
||||
- image blocks through base64 sources
|
||||
- tool result continuation blocks
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Client["OpenAI / Anthropic Client"] --> HTTP["HTTP API Layer"]
|
||||
Desktop["Desktop App"] --> AppBridge["Wails Go Bridge"]
|
||||
AppBridge --> Service["Proxy Service"]
|
||||
HTTP --> Service
|
||||
Service --> Session["Session Manager"]
|
||||
Service --> Tools["Tool Emulation"]
|
||||
Service --> Models["Model Discovery"]
|
||||
Service --> Transport["Lingma Transport"]
|
||||
Transport --> Pipe["Windows Named Pipe"]
|
||||
Transport --> WS["macOS / Windows WebSocket"]
|
||||
Pipe --> Lingma["Tongyi Lingma IDE Plugin"]
|
||||
WS --> Lingma
|
||||
```
|
||||
|
||||
## Config File
|
||||
### Module Layout
|
||||
|
||||
The proxy can load a JSON config file so you do not need to carry a long command line every time.
|
||||
| Path | Responsibility |
|
||||
| --- | --- |
|
||||
| `cmd/lingma-ipc-proxy` | CLI entrypoint, config loading, signal handling |
|
||||
| `internal/httpapi` | OpenAI/Anthropic HTTP routes, streaming SSE responses, request recording |
|
||||
| `internal/service` | request orchestration, sessions, model discovery, proxy lifecycle |
|
||||
| `internal/lingmaipc` | Lingma JSON-RPC transport over Named Pipe and WebSocket |
|
||||
| `internal/toolemulation` | tool definition injection, action block parsing, tool result projection |
|
||||
| `desktop` | Wails desktop shell, native window commands, proxy control bridge |
|
||||
| `desktop/frontend` | Vue UI for dashboard, requests, models, settings, and logs |
|
||||
| `.github/workflows/release.yml` | CI release pipeline for macOS and Windows CLI/Desktop packages |
|
||||
|
||||
Default lookup:
|
||||
## Transport Detection
|
||||
|
||||
| Platform | Default transport | Detection |
|
||||
| --- | --- | --- |
|
||||
| macOS | WebSocket | reads Lingma `SharedClientCache` files under user application support paths |
|
||||
| Windows | Named Pipe / WebSocket | scans Lingma named pipes and shared cache hints |
|
||||
| Linux | WebSocket | manual `--ws-url` is recommended |
|
||||
|
||||
If auto detection fails, set the path manually in the desktop Settings page or pass CLI flags:
|
||||
|
||||
```bash
|
||||
lingma-ipc-proxy --transport websocket --ws-url ws://127.0.0.1:36510 --port 8095
|
||||
lingma-ipc-proxy --transport pipe --pipe-name '\\.\pipe\lingma-ipc'
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Desktop App
|
||||
|
||||
1. Install VS Code and the Tongyi Lingma extension.
|
||||
2. Log in to Tongyi Lingma and verify the Lingma panel can chat normally.
|
||||
3. Download the desktop asset from [Releases](https://github.com/Lutiancheng1/lingma-ipc-proxy/releases).
|
||||
4. Start `Lingma IPC Proxy`.
|
||||
5. Click `探测模型` after the proxy is running.
|
||||
6. Configure clients to use `http://127.0.0.1:8095`.
|
||||
|
||||
### CLI
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Lutiancheng1/lingma-ipc-proxy.git
|
||||
cd lingma-ipc-proxy
|
||||
go build -o ./dist/lingma-ipc-proxy ./cmd/lingma-ipc-proxy
|
||||
./dist/lingma-ipc-proxy --host 127.0.0.1 --port 8095 --session-mode auto
|
||||
```
|
||||
|
||||
Windows:
|
||||
|
||||
```powershell
|
||||
.\scripts\build.ps1
|
||||
.\dist\lingma-ipc-proxy.exe --host 127.0.0.1 --port 8095 --session-mode auto
|
||||
```
|
||||
|
||||
## Client Configuration
|
||||
|
||||
### Claude Code
|
||||
|
||||
```bash
|
||||
export ANTHROPIC_BASE_URL="http://127.0.0.1:8095"
|
||||
export ANTHROPIC_API_KEY="any"
|
||||
```
|
||||
|
||||
Then select a model in Claude Code:
|
||||
|
||||
```text
|
||||
/model Qwen3-Coder
|
||||
```
|
||||
|
||||
### Cline
|
||||
|
||||
- Provider: `OpenAI Compatible`
|
||||
- Base URL: `http://127.0.0.1:8095/v1`
|
||||
- API Key: `any`
|
||||
- Model ID: `Qwen3-Coder`
|
||||
|
||||
### Continue
|
||||
|
||||
```json
|
||||
{
|
||||
"models": [
|
||||
{
|
||||
"title": "Lingma Proxy",
|
||||
"provider": "openai",
|
||||
"model": "Qwen3-Coder",
|
||||
"apiKey": "any",
|
||||
"apiBase": "http://127.0.0.1:8095/v1"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Models
|
||||
|
||||
The proxy reports the models exposed by the Lingma plugin. The desktop app does not force a global model switch; the calling client should specify the `model` field. Clicking a model in the desktop app copies its model ID.
|
||||
|
||||
Observed model IDs include:
|
||||
|
||||
- `Auto`
|
||||
- `Kimi-K2.6`
|
||||
- `MiniMax-M2.7`
|
||||
- `Qwen3-Coder`
|
||||
- `Qwen3-Max`
|
||||
- `Qwen3-Thinking`
|
||||
- `Qwen3.6-Plus`
|
||||
|
||||
For tool-heavy coding workflows, `Qwen3-Coder` is the recommended first choice.
|
||||
|
||||
## Configuration
|
||||
|
||||
Default config file:
|
||||
|
||||
```text
|
||||
./lingma-ipc-proxy.json
|
||||
```
|
||||
|
||||
You can also point to an explicit file:
|
||||
|
||||
```powershell
|
||||
.\dist\lingma-ipc-proxy.exe --config .\config.example.json
|
||||
```
|
||||
|
||||
Resolution order:
|
||||
|
||||
- built-in defaults
|
||||
- JSON config file
|
||||
- environment variables
|
||||
- command-line flags
|
||||
|
||||
An example config is included at:
|
||||
|
||||
- `config.example.json`
|
||||
|
||||
A practical setup is to copy it to `lingma-ipc-proxy.json`, adjust the values once, and then start the proxy without a long flag list.
|
||||
|
||||
Recommended layout:
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"host": "127.0.0.1",
|
||||
"port": 8095,
|
||||
"transport": "auto",
|
||||
"mode": "chat",
|
||||
"session_mode": "reuse",
|
||||
"timeout": 120,
|
||||
"cwd": "C:/Workspace/Personal/lingma-ipc-proxy",
|
||||
"shell_type": "powershell",
|
||||
"current_file_path": "",
|
||||
"pipe": "",
|
||||
"websocket_url": ""
|
||||
}
|
||||
```
|
||||
|
||||
## macOS / Linux
|
||||
|
||||
This project also works on macOS and Linux via **WebSocket transport**. The Windows named-pipe transport is automatically skipped on non-Windows platforms.
|
||||
|
||||
### Run on macOS
|
||||
|
||||
```bash
|
||||
cd ~/OpenSources/lingma-ipc-proxy
|
||||
go run ./cmd/lingma-ipc-proxy --transport websocket --port 8095
|
||||
|
||||
# Or use auto-detect (will discover websocket port from Lingma's shared client cache)
|
||||
go run ./cmd/lingma-ipc-proxy --port 8095
|
||||
```
|
||||
|
||||
### Build on macOS / Linux
|
||||
|
||||
```bash
|
||||
cd ~/OpenSources/lingma-ipc-proxy
|
||||
go build -o ./dist/lingma-ipc-proxy ./cmd/lingma-ipc-proxy
|
||||
```
|
||||
|
||||
### macOS Config Example
|
||||
|
||||
```json
|
||||
{
|
||||
"host": "127.0.0.1",
|
||||
"port": 8095,
|
||||
"transport": "websocket",
|
||||
"mode": "agent",
|
||||
"shell_type": "zsh",
|
||||
"session_mode": "auto",
|
||||
"timeout": 120
|
||||
"timeout": 120,
|
||||
"cwd": "/Users/you/project",
|
||||
"current_file_path": ""
|
||||
}
|
||||
```
|
||||
|
||||
## Build
|
||||
Priority order:
|
||||
|
||||
Build a Windows executable:
|
||||
1. built-in defaults
|
||||
2. JSON config file
|
||||
3. environment variables
|
||||
4. command-line flags
|
||||
5. desktop Settings page updates
|
||||
|
||||
## Function Calling / Tool Calling
|
||||
|
||||
Lingma does not expose a native public OpenAI/Anthropic tool-call protocol, so this proxy emulates tool calling:
|
||||
|
||||
1. Normalize OpenAI or Anthropic tool definitions.
|
||||
2. Inject tool contracts into the Lingma prompt.
|
||||
3. Parse model action blocks from the response.
|
||||
4. Convert parsed actions back into OpenAI `tool_calls` or Anthropic `tool_use`.
|
||||
5. Feed tool results back into Lingma for continuation.
|
||||
|
||||
This is most reliable with `Qwen3-Coder`.
|
||||
|
||||
## Local Desktop Build
|
||||
|
||||
Install Wails:
|
||||
|
||||
```bash
|
||||
go install github.com/wailsapp/wails/v2/cmd/wails@v2.12.0
|
||||
```
|
||||
|
||||
Build macOS:
|
||||
|
||||
```bash
|
||||
npm ci --prefix desktop/frontend
|
||||
cd desktop
|
||||
wails build -platform darwin/arm64 -clean
|
||||
```
|
||||
|
||||
Build Windows on Windows:
|
||||
|
||||
```powershell
|
||||
cd C:\Workspace\Personal\lingma-ipc-proxy
|
||||
.\scripts\build.ps1
|
||||
npm ci --prefix desktop/frontend
|
||||
cd desktop
|
||||
wails build -platform windows/amd64 -clean
|
||||
```
|
||||
|
||||
Default output:
|
||||
The desktop bundle name is always `Lingma IPC Proxy`.
|
||||
|
||||
```text
|
||||
dist\lingma-ipc-proxy.exe
|
||||
```
|
||||
## Release Plan
|
||||
|
||||
## Release
|
||||
The release workflow is triggered by:
|
||||
|
||||
GitHub Actions can publish a GitHub Release automatically.
|
||||
- pushing a tag such as `v1.2.0`
|
||||
- manually running the `Release` workflow with a tag input
|
||||
|
||||
Trigger rules:
|
||||
Planned improvements:
|
||||
|
||||
- push a tag matching `v*`, for example `v0.1.0`
|
||||
- or run the `Release` workflow manually and pass a tag
|
||||
- macOS signing and notarization
|
||||
- Windows installer packaging
|
||||
- configurable log retention
|
||||
- request export/import
|
||||
- richer model metadata display
|
||||
- optional Linux desktop packaging after the Lingma transport story is stable
|
||||
|
||||
Example:
|
||||
## Acknowledgements
|
||||
|
||||
```powershell
|
||||
git tag v0.1.0
|
||||
git push origin v0.1.0
|
||||
```
|
||||
|
||||
Release assets:
|
||||
|
||||
- `lingma-ipc-proxy_<tag>_windows_amd64.exe`
|
||||
- `lingma-ipc-proxy_<tag>_windows_amd64.zip`
|
||||
- `lingma-ipc-proxy_<tag>_sha256.txt`
|
||||
|
||||
Direct Go build command:
|
||||
|
||||
```powershell
|
||||
$env:CGO_ENABLED = "0"
|
||||
$env:GOOS = "windows"
|
||||
$env:GOARCH = "amd64"
|
||||
go build -trimpath -ldflags "-s -w" -o .\dist\lingma-ipc-proxy.exe .\cmd\lingma-ipc-proxy
|
||||
```
|
||||
|
||||
Run the built binary:
|
||||
|
||||
```powershell
|
||||
.\dist\lingma-ipc-proxy.exe --host 127.0.0.1 --port 8095 --session-mode auto
|
||||
.\dist\lingma-ipc-proxy.exe --transport websocket --ws-url ws://127.0.0.1:36510 --port 8095
|
||||
```
|
||||
|
||||
## Windows Service
|
||||
|
||||
For this project, the correct deployment shape is a native local process, not Docker. The proxy talks to Lingma over local pipe or websocket transport, so it should run on the same host as Lingma itself.
|
||||
|
||||
### NSSM
|
||||
|
||||
Build first:
|
||||
|
||||
```powershell
|
||||
.\scripts\build.ps1
|
||||
```
|
||||
|
||||
Install with NSSM:
|
||||
|
||||
```powershell
|
||||
.\scripts\install-nssm-service.ps1 -NssmPath C:\Tools\nssm\nssm.exe
|
||||
```
|
||||
|
||||
This wraps:
|
||||
|
||||
```powershell
|
||||
nssm.exe install LingmaIpcProxy C:\Workspace\Personal\lingma-ipc-proxy\dist\lingma-ipc-proxy.exe --host 127.0.0.1 --port 8095 --session-mode auto
|
||||
nssm.exe set LingmaIpcProxy AppDirectory C:\Workspace\Personal\lingma-ipc-proxy
|
||||
nssm.exe start LingmaIpcProxy
|
||||
```
|
||||
|
||||
### WinSW
|
||||
|
||||
Prepare the executable:
|
||||
|
||||
```powershell
|
||||
.\scripts\build.ps1
|
||||
```
|
||||
|
||||
Put a WinSW binary at:
|
||||
|
||||
```text
|
||||
dist\WinSW-x64.exe
|
||||
```
|
||||
|
||||
Then generate the wrapper files:
|
||||
|
||||
```powershell
|
||||
.\scripts\install-winsw-service.ps1
|
||||
```
|
||||
|
||||
That script creates:
|
||||
|
||||
- `LingmaIpcProxy.exe`
|
||||
- `LingmaIpcProxy.xml`
|
||||
|
||||
Then install/start:
|
||||
|
||||
```powershell
|
||||
.\LingmaIpcProxy.exe install
|
||||
.\LingmaIpcProxy.exe start
|
||||
```
|
||||
|
||||
The WinSW XML template lives at:
|
||||
|
||||
- `scripts\lingma-ipc-proxy.xml.template`
|
||||
|
||||
## Flags
|
||||
|
||||
```powershell
|
||||
go run .\cmd\lingma-ipc-proxy --port 8095 --session-mode auto
|
||||
```
|
||||
|
||||
- `--host`
|
||||
- `--port`
|
||||
- `--transport`
|
||||
- `--pipe`
|
||||
- `--ws-url`
|
||||
- `--cwd`
|
||||
- `--current-file-path`
|
||||
- `--mode`
|
||||
- `--shell-type`
|
||||
- `--session-mode`
|
||||
- `reuse`: keep using the sticky Lingma session
|
||||
- `fresh`: create a temporary session for the request and delete it after completion
|
||||
- `auto`: single-turn requests reuse; requests with system/history use a temporary fresh session and delete it after completion
|
||||
- `--timeout`
|
||||
|
||||
## Environment
|
||||
|
||||
- `LINGMA_PROXY_TRANSPORT`
|
||||
- `LINGMA_IPC_PIPE`
|
||||
- `LINGMA_PROXY_WS_URL`
|
||||
- `LINGMA_PROXY_HOST`
|
||||
- `LINGMA_PROXY_PORT`
|
||||
- `LINGMA_PROXY_CWD`
|
||||
- `LINGMA_PROXY_CURRENT_FILE_PATH`
|
||||
- `LINGMA_PROXY_MODE`
|
||||
- `LINGMA_PROXY_SHELL_TYPE`
|
||||
- `LINGMA_PROXY_SESSION_MODE`
|
||||
- `LINGMA_PROXY_TIMEOUT_SECONDS`
|
||||
|
||||
## Examples
|
||||
|
||||
Anthropic non-streaming:
|
||||
|
||||
```powershell
|
||||
$body = @{
|
||||
model = "dashscope_qwen3_coder"
|
||||
messages = @(
|
||||
@{ role = "user"; content = "请只回复:ANTHROPIC_OK" }
|
||||
)
|
||||
stream = $false
|
||||
} | ConvertTo-Json -Depth 8
|
||||
|
||||
Invoke-RestMethod `
|
||||
-Method Post `
|
||||
-Uri http://127.0.0.1:8095/v1/messages `
|
||||
-ContentType "application/json" `
|
||||
-Body $body
|
||||
```
|
||||
|
||||
Anthropic streaming:
|
||||
|
||||
```powershell
|
||||
$body = @{
|
||||
model = "dashscope_qwen3_coder"
|
||||
messages = @(
|
||||
@{ role = "user"; content = "请只回复:ANTHROPIC_STREAM_OK" }
|
||||
)
|
||||
stream = $true
|
||||
} | ConvertTo-Json -Depth 8
|
||||
|
||||
curl.exe -N `
|
||||
-H "Content-Type: application/json" `
|
||||
-d $body `
|
||||
http://127.0.0.1:8095/v1/messages
|
||||
```
|
||||
|
||||
OpenAI non-streaming:
|
||||
|
||||
```powershell
|
||||
$body = @{
|
||||
model = "dashscope_qwen3_coder"
|
||||
messages = @(
|
||||
@{ role = "user"; content = "请只回复:OPENAI_OK" }
|
||||
)
|
||||
stream = $false
|
||||
} | ConvertTo-Json -Depth 8
|
||||
|
||||
Invoke-RestMethod `
|
||||
-Method Post `
|
||||
-Uri http://127.0.0.1:8095/v1/chat/completions `
|
||||
-ContentType "application/json" `
|
||||
-Body $body
|
||||
```
|
||||
|
||||
OpenAI streaming:
|
||||
|
||||
```powershell
|
||||
$body = @{
|
||||
model = "dashscope_qwen3_coder"
|
||||
messages = @(
|
||||
@{ role = "user"; content = "请只回复:OPENAI_STREAM_OK" }
|
||||
)
|
||||
stream = $true
|
||||
} | ConvertTo-Json -Depth 8
|
||||
|
||||
curl.exe -N `
|
||||
-H "Content-Type: application/json" `
|
||||
-d $body `
|
||||
http://127.0.0.1:8095/v1/chat/completions
|
||||
```
|
||||
|
||||
## Streaming shape
|
||||
|
||||
Anthropic streaming emits SSE events compatible with the `messages` API shape:
|
||||
|
||||
- `message_start`
|
||||
- `content_block_start`
|
||||
- `content_block_delta`
|
||||
- `content_block_stop`
|
||||
- `message_delta`
|
||||
- `message_stop`
|
||||
|
||||
OpenAI streaming emits `chat.completion.chunk` payloads as `data:` lines and ends with:
|
||||
|
||||
- `data: [DONE]`
|
||||
This project is based on the protocol insight and initial discovery work from [coolxll/lingma-ipc-proxy](https://github.com/coolxll/lingma-ipc-proxy). The core idea of connecting to Lingma's private local IPC protocol and exposing standard API endpoints came from that project. This fork extends the implementation with broader OpenAI/Anthropic compatibility, tool emulation, image handling, desktop app support, request/log inspection, cross-platform packaging, and release automation.
|
||||
|
||||
649
README.zh-CN.md
@@ -1,309 +1,442 @@
|
||||
# lingma-ipc-proxy
|
||||
# Lingma IPC Proxy
|
||||
|
||||
[English](./README.md) | [简体中文](./README.zh-CN.md)
|
||||
|
||||
`lingma-ipc-proxy` 是一个独立的 Go 后端,通过 Lingma 本地 pipe 或 websocket 传输与其通信,并对外暴露:
|
||||
**Lingma IPC Proxy** 是一个通义灵码 IDE 插件 API 适配层。它把 Lingma 插件的本地私有 IPC / WebSocket 能力转换成标准 **OpenAI 兼容接口** 和 **Anthropic 兼容接口**,让 Claude Code、Cline、Continue、OpenCode、自研 Agent 等第三方客户端可以直接调用 Lingma 后端模型。
|
||||
|
||||
- `GET /v1/models`
|
||||
- `POST /v1/messages`
|
||||
- `POST /v1/chat/completions`
|
||||
项目同时提供两种使用方式:
|
||||
|
||||
当前范围:
|
||||
- **CLI 代理服务**:适合后台常驻、脚本化和服务器式运行。
|
||||
- **跨平台桌面 App**:适合日常可视化管理,支持 macOS 和 Windows。
|
||||
|
||||
- 支持非流式与流式响应
|
||||
- 单次只处理一个请求
|
||||
- 支持 Windows named pipe 传输,也支持本地 websocket 传输
|
||||
- 直接走 Lingma IPC,不依赖 DOM/CDP
|
||||
## 当前版本
|
||||
|
||||
## 运行
|
||||
当前桌面端版本线:`v1.2.0`
|
||||
|
||||
GitHub Actions 会在 Release 中产出:
|
||||
|
||||
| 产物 | 平台 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| `lingma-ipc-proxy_<tag>_darwin_arm64.tar.gz` | macOS | CLI 代理 |
|
||||
| `lingma-ipc-proxy_<tag>_windows_amd64.zip` | Windows | CLI 代理 |
|
||||
| `lingma-ipc-proxy-desktop_<tag>_darwin_arm64.zip` | macOS | 桌面 App |
|
||||
| `lingma-ipc-proxy-desktop_<tag>_windows_amd64.zip` | Windows | 桌面 App |
|
||||
| `lingma-ipc-proxy_<tag>_sha256.txt` | 全平台 | 校验文件 |
|
||||
|
||||
## 功能概览
|
||||
|
||||
| 能力 | 状态 |
|
||||
| --- | --- |
|
||||
| OpenAI Chat Completions | 支持流式 / 非流式 |
|
||||
| Anthropic Messages | 支持流式 / 非流式 |
|
||||
| `GET /v1/models` | 支持 |
|
||||
| Function Calling / Tools | 支持,使用工具调用模拟实现 |
|
||||
| 多轮 Agent 工具循环 | 支持 |
|
||||
| 图片输入 | 支持 base64、data URL、HTTP URL |
|
||||
| 请求 / 响应完整日志 | 桌面端支持完整查看和复制 |
|
||||
| macOS WebSocket 自动探测 | 支持 |
|
||||
| Windows Named Pipe / WebSocket 探测 | 支持 |
|
||||
| 日间 / 夜间 / 跟随系统主题 | 桌面端支持 |
|
||||
| macOS 窗口生命周期 | 关闭隐藏、Dock 重新打开、Cmd+W、Cmd+M、Cmd+Q |
|
||||
| GitHub Release 打包 | macOS + Windows,CLI + Desktop |
|
||||
|
||||
## 桌面 App
|
||||
|
||||
桌面端是一个 Wails + Vue 实现的本地控制台,用来管理代理进程和观察真实请求。
|
||||
|
||||
主要页面:
|
||||
|
||||
- **仪表盘**:代理状态、监听地址、启动 / 停止 / 重启、健康延迟、模型摘要、配置摘要、最近请求。
|
||||
- **请求流**:查看 OpenAI / Anthropic 兼容接口的请求记录,支持搜索、筛选、清空、完整请求体 / 响应体查看和复制。
|
||||
- **模型**:探测 Lingma 插件暴露的可用模型,点击模型复制模型 ID。模型选择由调用方请求里的 `model` 字段决定,App 不再做无意义的全局切换。
|
||||
- **设置**:主机、端口、传输方式、超时、WebSocket 地址、Named Pipe、工作目录、当前文件、会话策略等。
|
||||
- **日志**:代理启动、模型同步、健康检查、配置保存、错误事件等。
|
||||
|
||||
### 截图
|
||||
|
||||
日间模式:
|
||||
|
||||

|
||||
|
||||
夜间模式:
|
||||
|
||||

|
||||
|
||||
窄窗口 / 小屏布局:
|
||||
|
||||

|
||||
|
||||
## 支持的协议和接口
|
||||
|
||||
### HTTP 端点
|
||||
|
||||
| 端点 | 方法 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `/` | GET | 健康检查 |
|
||||
| `/health` | GET | 健康检查 |
|
||||
| `/v1/models` | GET | 获取 Lingma 可用模型列表 |
|
||||
| `/v1/chat/completions` | POST | OpenAI Chat Completions 兼容接口 |
|
||||
| `/v1/messages` | POST | Anthropic Messages 兼容接口 |
|
||||
|
||||
## 我们自己增强的能力
|
||||
|
||||
相对最初的协议验证版本,本仓库重点把它完善成一个可日常使用的本地代理产品:
|
||||
|
||||
- **Function Calling / Tools 兼容**:同时兼容 OpenAI `tools/tool_choice` 和 Anthropic `tools/tool_choice`。
|
||||
- **工具结果接力**:支持多轮 Agent 工具调用,把工具结果继续回灌给 Lingma 生成最终回答。
|
||||
- **图片输入**:兼容 OpenAI `image_url` 和 Anthropic base64 image block。
|
||||
- **更完整的参数兼容**:接收 `temperature`、`top_p`、`stop`、`max_tokens`、`response_format`、`reasoning_effort` 等客户端常用字段。
|
||||
- **完整请求 / 响应观测**:桌面端可以查看完整请求体、响应体、状态码、耗时和错误日志,便于排查 Claude Code / Cline 里的 400、500 问题。
|
||||
- **跨平台桌面 App**:提供启动、停止、重启、模型探测、设置、日志、主题、窗口生命周期等完整桌面能力。
|
||||
- **跨平台 Release**:GitHub Actions 同时打包 macOS / Windows 的 CLI 和桌面 App。
|
||||
|
||||
### OpenAI 兼容内容
|
||||
|
||||
支持常见 OpenAI 请求字段:
|
||||
|
||||
- `model`
|
||||
- `messages`
|
||||
- `stream`
|
||||
- `temperature`
|
||||
- `top_p`
|
||||
- `stop`
|
||||
- `max_tokens`
|
||||
- `max_completion_tokens`
|
||||
- `presence_penalty`
|
||||
- `frequency_penalty`
|
||||
- `tools`
|
||||
- `tool_choice`
|
||||
- `parallel_tool_calls`
|
||||
- `response_format`
|
||||
- `seed`
|
||||
- `user`
|
||||
- `reasoning_effort`
|
||||
- `image_url`
|
||||
|
||||
说明:部分生成参数取决于 Lingma 后端是否实际采纳,代理层会尽量接收、归一化并保持客户端兼容。
|
||||
|
||||
### Anthropic 兼容内容
|
||||
|
||||
支持常见 Anthropic 请求字段:
|
||||
|
||||
- `model`
|
||||
- `system`
|
||||
- `messages`
|
||||
- `stream`
|
||||
- `temperature`
|
||||
- `top_p`
|
||||
- `top_k`
|
||||
- `stop_sequences`
|
||||
- `max_tokens`
|
||||
- `metadata`
|
||||
- `tools`
|
||||
- `tool_choice`
|
||||
- `tool_result`
|
||||
- base64 图片块
|
||||
|
||||
## 架构设计
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Client["第三方客户端<br/>Claude Code / Cline / Continue"] --> HTTP["HTTP API 层<br/>OpenAI / Anthropic"]
|
||||
Desktop["桌面 App<br/>Wails + Vue"] --> Bridge["Wails Go Bridge"]
|
||||
Bridge --> Service["代理服务层"]
|
||||
HTTP --> Service
|
||||
Service --> Session["会话管理"]
|
||||
Service --> Tooling["工具调用模拟"]
|
||||
Service --> Model["模型探测"]
|
||||
Service --> Recorder["请求 / 日志记录"]
|
||||
Service --> Transport["Lingma 传输层"]
|
||||
Transport --> Pipe["Windows Named Pipe"]
|
||||
Transport --> WS["WebSocket"]
|
||||
Pipe --> Lingma["通义灵码 IDE 插件"]
|
||||
WS --> Lingma
|
||||
```
|
||||
|
||||
### 目录结构
|
||||
|
||||
| 路径 | 职责 |
|
||||
| --- | --- |
|
||||
| `cmd/lingma-ipc-proxy` | CLI 入口,配置加载,HTTP 服务启动,系统信号处理 |
|
||||
| `internal/httpapi` | OpenAI / Anthropic 路由、请求解析、SSE 流式响应、请求记录 |
|
||||
| `internal/service` | 业务编排、会话生命周期、模型探测、代理运行状态 |
|
||||
| `internal/lingmaipc` | Lingma JSON-RPC 通信,Named Pipe / WebSocket 传输 |
|
||||
| `internal/toolemulation` | 工具定义注入、动作块解析、工具结果回灌 |
|
||||
| `desktop` | Wails 桌面壳、窗口命令、代理生命周期桥接 |
|
||||
| `desktop/frontend` | Vue 前端页面,包含仪表盘、请求流、模型、设置、日志 |
|
||||
| `docs/images` | README 截图素材 |
|
||||
| `.github/workflows/release.yml` | macOS / Windows CLI + Desktop release 打包 |
|
||||
|
||||
### 请求链路
|
||||
|
||||
1. 客户端请求 `http://127.0.0.1:8095/v1/chat/completions` 或 `/v1/messages`。
|
||||
2. HTTP 层识别 OpenAI / Anthropic 请求格式。
|
||||
3. Service 层归一化消息、图片、工具定义和参数。
|
||||
4. Session 管理层决定复用会话、创建新会话或使用自动策略。
|
||||
5. Transport 层连接 Lingma 插件的 Named Pipe 或 WebSocket。
|
||||
6. Lingma 返回增量事件或最终响应。
|
||||
7. HTTP 层转换成 OpenAI SSE、Anthropic SSE 或普通 JSON。
|
||||
8. 桌面端同步记录请求、响应、耗时、状态码和日志。
|
||||
|
||||
## Lingma 路径自动探测
|
||||
|
||||
| 平台 | 优先传输 | 探测方式 |
|
||||
| --- | --- | --- |
|
||||
| macOS | WebSocket | 扫描用户目录下 Lingma `SharedClientCache` 配置 |
|
||||
| Windows | Named Pipe / WebSocket | 扫描 Lingma 命名管道和共享缓存信息 |
|
||||
| Linux | WebSocket | 建议手动指定 `--ws-url` |
|
||||
|
||||
如果自动探测失败,桌面端会提供兜底说明。可以在设置里手动填写:
|
||||
|
||||
- macOS WebSocket 示例:`ws://127.0.0.1:36510`
|
||||
- Windows Named Pipe 示例:`\\.\pipe\lingma-ipc`
|
||||
- 代理监听地址示例:`http://127.0.0.1:8095`
|
||||
|
||||
CLI 也可以手动指定:
|
||||
|
||||
```bash
|
||||
lingma-ipc-proxy --transport websocket --ws-url ws://127.0.0.1:36510 --port 8095
|
||||
lingma-ipc-proxy --transport pipe --pipe-name '\\.\pipe\lingma-ipc'
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 前置条件
|
||||
|
||||
1. 安装 VS Code。
|
||||
2. 安装通义灵码插件:`Alibaba-Cloud.tongyi-lingma`。
|
||||
3. 登录通义灵码账号。
|
||||
4. 在 VS Code 中确认 Lingma 面板可以正常聊天。
|
||||
|
||||
### 使用桌面 App
|
||||
|
||||
1. 前往 [Releases](https://github.com/Lutiancheng1/lingma-ipc-proxy/releases) 下载桌面版。
|
||||
2. macOS 解压后打开 `Lingma IPC Proxy.app`。
|
||||
3. Windows 解压后运行桌面版 exe。
|
||||
4. 点击启动代理。
|
||||
5. 点击 `探测模型`。
|
||||
6. 在 Claude Code / Cline / Continue 中配置本地地址。
|
||||
|
||||
### 使用 CLI
|
||||
|
||||
macOS:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Lutiancheng1/lingma-ipc-proxy.git
|
||||
cd lingma-ipc-proxy
|
||||
go build -o ./dist/lingma-ipc-proxy ./cmd/lingma-ipc-proxy
|
||||
./dist/lingma-ipc-proxy --host 127.0.0.1 --port 8095 --session-mode auto
|
||||
```
|
||||
|
||||
Windows:
|
||||
|
||||
```powershell
|
||||
cd C:\Workspace\Personal\lingma-ipc-proxy
|
||||
go run .\cmd\lingma-ipc-proxy
|
||||
git clone https://github.com/Lutiancheng1/lingma-ipc-proxy.git
|
||||
cd lingma-ipc-proxy
|
||||
.\scripts\build.ps1
|
||||
.\dist\lingma-ipc-proxy.exe --host 127.0.0.1 --port 8095 --session-mode auto
|
||||
```
|
||||
|
||||
## 客户端配置
|
||||
|
||||
### Claude Code
|
||||
|
||||
```bash
|
||||
export ANTHROPIC_BASE_URL="http://127.0.0.1:8095"
|
||||
export ANTHROPIC_API_KEY="any"
|
||||
```
|
||||
|
||||
注意:`ANTHROPIC_BASE_URL` 不要带 `/v1`,Claude SDK 会自动追加。
|
||||
|
||||
然后在 Claude Code 中选择模型:
|
||||
|
||||
```text
|
||||
/model Qwen3-Coder
|
||||
```
|
||||
|
||||
### Cline
|
||||
|
||||
选择 `OpenAI Compatible`:
|
||||
|
||||
- Base URL:`http://127.0.0.1:8095/v1`
|
||||
- API Key:`any`
|
||||
- Model ID:`Qwen3-Coder`
|
||||
|
||||
### Continue
|
||||
|
||||
```json
|
||||
{
|
||||
"models": [
|
||||
{
|
||||
"title": "Lingma Proxy",
|
||||
"provider": "openai",
|
||||
"model": "Qwen3-Coder",
|
||||
"apiKey": "any",
|
||||
"apiBase": "http://127.0.0.1:8095/v1"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 模型说明
|
||||
|
||||
模型列表来自 Lingma 插件,不是代理内置静态列表。桌面端仅负责展示和复制模型 ID,真正使用哪个模型由调用方请求里的 `model` 字段决定。
|
||||
|
||||
当前常见模型:
|
||||
|
||||
| 模型 | 说明 |
|
||||
| --- | --- |
|
||||
| `Auto` | Lingma 自动路由模型,桌面端使用通用自动图标 |
|
||||
| `Qwen3-Coder` | 代码和工具调用优先推荐 |
|
||||
| `Qwen3-Max` | 通用能力较强 |
|
||||
| `Qwen3-Thinking` | 推理类模型 |
|
||||
| `Qwen3.6-Plus` | 通用模型 |
|
||||
| `Kimi-K2.6` | 长文本模型 |
|
||||
| `MiniMax-M2.7` | 通用模型 |
|
||||
|
||||
需要工具调用时,优先使用 `Qwen3-Coder`。
|
||||
|
||||
## 配置文件
|
||||
|
||||
代理现在支持 JSON 配置文件,这样就不用每次都带一长串启动参数。
|
||||
|
||||
默认会尝试读取:
|
||||
默认读取:
|
||||
|
||||
```text
|
||||
./lingma-ipc-proxy.json
|
||||
```
|
||||
|
||||
也可以显式指定:
|
||||
|
||||
```powershell
|
||||
.\dist\lingma-ipc-proxy.exe --config .\config.example.json
|
||||
```
|
||||
|
||||
参数解析优先级:
|
||||
|
||||
- 内置默认值
|
||||
- JSON 配置文件
|
||||
- 环境变量
|
||||
- 命令行参数
|
||||
|
||||
仓库里附带了一份示例配置:
|
||||
|
||||
- `config.example.json`
|
||||
|
||||
比较实用的方式是先复制成 `lingma-ipc-proxy.json`,改好一次,后面直接启动代理,不再重复拼长参数。
|
||||
|
||||
推荐结构:
|
||||
完整示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"host": "127.0.0.1",
|
||||
"port": 8095,
|
||||
"transport": "auto",
|
||||
"mode": "chat",
|
||||
"session_mode": "reuse",
|
||||
"mode": "agent",
|
||||
"shell_type": "zsh",
|
||||
"session_mode": "auto",
|
||||
"timeout": 120,
|
||||
"cwd": "C:/Workspace/Personal/lingma-ipc-proxy",
|
||||
"shell_type": "powershell",
|
||||
"current_file_path": "",
|
||||
"pipe": "",
|
||||
"websocket_url": ""
|
||||
"cwd": "/Users/tiancheng/project",
|
||||
"current_file_path": ""
|
||||
}
|
||||
```
|
||||
|
||||
## 构建
|
||||
配置优先级从低到高:
|
||||
|
||||
构建 Windows 可执行文件:
|
||||
1. 内置默认值
|
||||
2. JSON 配置文件
|
||||
3. 环境变量
|
||||
4. 命令行参数
|
||||
5. 桌面端设置页保存的配置
|
||||
|
||||
```powershell
|
||||
cd C:\Workspace\Personal\lingma-ipc-proxy
|
||||
.\scripts\build.ps1
|
||||
## 工具调用实现
|
||||
|
||||
Lingma 插件本身没有公开标准 OpenAI / Anthropic Tools 协议,所以本项目使用 **Tool Emulation**:
|
||||
|
||||
1. 接收 OpenAI `tools` / Anthropic `tools`。
|
||||
2. 将工具定义转成 Lingma 可理解的提示词上下文。
|
||||
3. 引导模型输出结构化 action block。
|
||||
4. 解析 action block。
|
||||
5. 重新编码成 OpenAI `tool_calls` 或 Anthropic `tool_use`。
|
||||
6. 将工具执行结果回灌给 Lingma,继续生成最终回答。
|
||||
|
||||
该方案依赖模型配合,目前 `Qwen3-Coder` 最稳定。
|
||||
|
||||
## 请求和日志观测
|
||||
|
||||
桌面端会记录:
|
||||
|
||||
- 请求时间
|
||||
- HTTP 方法
|
||||
- 路径
|
||||
- 状态码
|
||||
- 耗时
|
||||
- 请求体
|
||||
- 响应体
|
||||
- 错误原因
|
||||
- 代理运行日志
|
||||
|
||||
请求体和响应体不会再用无意义的展开 / 收起按钮截断展示;内容过长时会在详情区域内部滚动,并隐藏滚动条,便于小窗口下查看完整内容。
|
||||
|
||||
## 本地构建桌面端
|
||||
|
||||
安装 Wails:
|
||||
|
||||
```bash
|
||||
go install github.com/wailsapp/wails/v2/cmd/wails@v2.12.0
|
||||
```
|
||||
|
||||
默认输出:
|
||||
macOS:
|
||||
|
||||
```bash
|
||||
npm ci --prefix desktop/frontend
|
||||
cd desktop
|
||||
wails build -platform darwin/arm64 -clean
|
||||
```
|
||||
|
||||
Windows:
|
||||
|
||||
```powershell
|
||||
npm ci --prefix desktop/frontend
|
||||
cd desktop
|
||||
wails build -platform windows/amd64 -clean
|
||||
```
|
||||
|
||||
桌面端最终 App 名称统一为:
|
||||
|
||||
```text
|
||||
dist\lingma-ipc-proxy.exe
|
||||
Lingma IPC Proxy
|
||||
```
|
||||
|
||||
## 发布
|
||||
不会再生成 `lingma-proxy-desktop` 旧包名。
|
||||
|
||||
GitHub Actions 可以自动发布 GitHub Release。
|
||||
## GitHub Actions Release
|
||||
|
||||
触发方式:
|
||||
发布方式:
|
||||
|
||||
- 推送匹配 `v*` 的 tag,例如 `v0.1.0`
|
||||
- 或手动运行 `Release` workflow,并传入一个 tag
|
||||
|
||||
示例:
|
||||
|
||||
```powershell
|
||||
git tag v0.1.0
|
||||
git push origin v0.1.0
|
||||
```bash
|
||||
git tag v1.2.0
|
||||
git push origin v1.2.0
|
||||
```
|
||||
|
||||
发布产物:
|
||||
也可以在 GitHub Actions 页面手动运行 `Release` workflow,并输入 tag。
|
||||
|
||||
- `lingma-ipc-proxy_<tag>_windows_amd64.exe`
|
||||
- `lingma-ipc-proxy_<tag>_windows_amd64.zip`
|
||||
- `lingma-ipc-proxy_<tag>_sha256.txt`
|
||||
Release workflow 会执行:
|
||||
|
||||
等价的 Go 构建命令:
|
||||
1. `go test ./...`
|
||||
2. 构建 macOS CLI
|
||||
3. 构建 Windows CLI
|
||||
4. 构建 macOS 桌面 App
|
||||
5. 构建 Windows 桌面 App
|
||||
6. 生成 SHA256 校验文件
|
||||
7. 上传到 GitHub Release
|
||||
|
||||
```powershell
|
||||
$env:CGO_ENABLED = "0"
|
||||
$env:GOOS = "windows"
|
||||
$env:GOARCH = "amd64"
|
||||
go build -trimpath -ldflags "-s -w" -o .\dist\lingma-ipc-proxy.exe .\cmd\lingma-ipc-proxy
|
||||
```
|
||||
## 与上游项目的关系
|
||||
|
||||
运行构建后的二进制:
|
||||
我对比了上游仓库 [coolxll/lingma-ipc-proxy](https://github.com/coolxll/lingma-ipc-proxy)。上游项目的核心贡献是发现并验证了 Lingma 本地私有 IPC 协议可以被代理成标准 HTTP API,这是本项目的基础思路来源。
|
||||
|
||||
```powershell
|
||||
.\dist\lingma-ipc-proxy.exe --host 127.0.0.1 --port 8095 --session-mode auto
|
||||
.\dist\lingma-ipc-proxy.exe --transport websocket --ws-url ws://127.0.0.1:36510 --port 8095
|
||||
```
|
||||
本项目在这个思路上继续扩展了:
|
||||
|
||||
## Windows 服务
|
||||
- 更完整的 OpenAI / Anthropic 参数兼容
|
||||
- Tools / Function Calling 模拟
|
||||
- 图片输入处理
|
||||
- 会话策略和多轮工具调用
|
||||
- macOS / Windows 自动探测兜底
|
||||
- Wails 桌面 App
|
||||
- 请求流、日志、设置、模型页面
|
||||
- 日间 / 夜间 / 跟随系统主题
|
||||
- App 图标和模型图标
|
||||
- macOS / Windows CLI + Desktop release 打包
|
||||
|
||||
这个项目正确的部署形态是本机进程,不是 Docker。原因很直接:代理需要通过本地 pipe 或 websocket 与 Lingma 通信,所以必须和 Lingma 跑在同一台主机上。
|
||||
## 后续计划
|
||||
|
||||
### NSSM
|
||||
- macOS 签名与 notarization
|
||||
- Windows installer 安装包
|
||||
- 请求日志导出
|
||||
- 日志保留时长配置
|
||||
- 更丰富的模型元数据
|
||||
- 桌面端自动更新
|
||||
- Linux 桌面版可行性验证
|
||||
|
||||
先构建:
|
||||
## 致谢
|
||||
|
||||
```powershell
|
||||
.\scripts\build.ps1
|
||||
```
|
||||
|
||||
再用 NSSM 安装:
|
||||
|
||||
```powershell
|
||||
.\scripts\install-nssm-service.ps1 -NssmPath C:\Tools\nssm\nssm.exe
|
||||
```
|
||||
|
||||
它等价于执行:
|
||||
|
||||
```powershell
|
||||
nssm.exe install LingmaIpcProxy C:\Workspace\Personal\lingma-ipc-proxy\dist\lingma-ipc-proxy.exe --host 127.0.0.1 --port 8095 --session-mode auto
|
||||
nssm.exe set LingmaIpcProxy AppDirectory C:\Workspace\Personal\lingma-ipc-proxy
|
||||
nssm.exe start LingmaIpcProxy
|
||||
```
|
||||
|
||||
### WinSW
|
||||
|
||||
先准备可执行文件:
|
||||
|
||||
```powershell
|
||||
.\scripts\build.ps1
|
||||
```
|
||||
|
||||
把 WinSW 二进制放到:
|
||||
|
||||
```text
|
||||
dist\WinSW-x64.exe
|
||||
```
|
||||
|
||||
然后生成服务包装文件:
|
||||
|
||||
```powershell
|
||||
.\scripts\install-winsw-service.ps1
|
||||
```
|
||||
|
||||
脚本会生成:
|
||||
|
||||
- `LingmaIpcProxy.exe`
|
||||
- `LingmaIpcProxy.xml`
|
||||
|
||||
然后安装并启动:
|
||||
|
||||
```powershell
|
||||
.\LingmaIpcProxy.exe install
|
||||
.\LingmaIpcProxy.exe start
|
||||
```
|
||||
|
||||
WinSW 模板文件位置:
|
||||
|
||||
- `scripts\lingma-ipc-proxy.xml.template`
|
||||
|
||||
## 启动参数
|
||||
|
||||
```powershell
|
||||
go run .\cmd\lingma-ipc-proxy --port 8095 --session-mode auto
|
||||
```
|
||||
|
||||
- `--host`
|
||||
- `--port`
|
||||
- `--transport`
|
||||
- `--pipe`
|
||||
- `--ws-url`
|
||||
- `--cwd`
|
||||
- `--current-file-path`
|
||||
- `--mode`
|
||||
- `--shell-type`
|
||||
- `--session-mode`
|
||||
- `reuse`:持续复用 sticky Lingma 会话
|
||||
- `fresh`:为本次请求创建临时会话,结束后自动删除
|
||||
- `auto`:单轮请求复用会话;带 system/history 的请求走临时 fresh 会话并在结束后自动删除
|
||||
- `--timeout`
|
||||
|
||||
## 环境变量
|
||||
|
||||
- `LINGMA_PROXY_TRANSPORT`
|
||||
- `LINGMA_IPC_PIPE`
|
||||
- `LINGMA_PROXY_WS_URL`
|
||||
- `LINGMA_PROXY_HOST`
|
||||
- `LINGMA_PROXY_PORT`
|
||||
- `LINGMA_PROXY_CWD`
|
||||
- `LINGMA_PROXY_CURRENT_FILE_PATH`
|
||||
- `LINGMA_PROXY_MODE`
|
||||
- `LINGMA_PROXY_SHELL_TYPE`
|
||||
- `LINGMA_PROXY_SESSION_MODE`
|
||||
- `LINGMA_PROXY_TIMEOUT_SECONDS`
|
||||
|
||||
## 示例
|
||||
|
||||
Anthropic 非流式:
|
||||
|
||||
```powershell
|
||||
$body = @{
|
||||
model = "dashscope_qwen3_coder"
|
||||
messages = @(
|
||||
@{ role = "user"; content = "请只回复:ANTHROPIC_OK" }
|
||||
)
|
||||
stream = $false
|
||||
} | ConvertTo-Json -Depth 8
|
||||
|
||||
Invoke-RestMethod `
|
||||
-Method Post `
|
||||
-Uri http://127.0.0.1:8095/v1/messages `
|
||||
-ContentType "application/json" `
|
||||
-Body $body
|
||||
```
|
||||
|
||||
Anthropic 流式:
|
||||
|
||||
```powershell
|
||||
$body = @{
|
||||
model = "dashscope_qwen3_coder"
|
||||
messages = @(
|
||||
@{ role = "user"; content = "请只回复:ANTHROPIC_STREAM_OK" }
|
||||
)
|
||||
stream = $true
|
||||
} | ConvertTo-Json -Depth 8
|
||||
|
||||
curl.exe -N `
|
||||
-H "Content-Type: application/json" `
|
||||
-d $body `
|
||||
http://127.0.0.1:8095/v1/messages
|
||||
```
|
||||
|
||||
OpenAI 非流式:
|
||||
|
||||
```powershell
|
||||
$body = @{
|
||||
model = "dashscope_qwen3_coder"
|
||||
messages = @(
|
||||
@{ role = "user"; content = "请只回复:OPENAI_OK" }
|
||||
)
|
||||
stream = $false
|
||||
} | ConvertTo-Json -Depth 8
|
||||
|
||||
Invoke-RestMethod `
|
||||
-Method Post `
|
||||
-Uri http://127.0.0.1:8095/v1/chat/completions `
|
||||
-ContentType "application/json" `
|
||||
-Body $body
|
||||
```
|
||||
|
||||
OpenAI 流式:
|
||||
|
||||
```powershell
|
||||
$body = @{
|
||||
model = "dashscope_qwen3_coder"
|
||||
messages = @(
|
||||
@{ role = "user"; content = "请只回复:OPENAI_STREAM_OK" }
|
||||
)
|
||||
stream = $true
|
||||
} | ConvertTo-Json -Depth 8
|
||||
|
||||
curl.exe -N `
|
||||
-H "Content-Type: application/json" `
|
||||
-d $body `
|
||||
http://127.0.0.1:8095/v1/chat/completions
|
||||
```
|
||||
|
||||
## 流式返回形状
|
||||
|
||||
Anthropic 流式响应会输出与 `messages` API 兼容的 SSE 事件:
|
||||
|
||||
- `message_start`
|
||||
- `content_block_start`
|
||||
- `content_block_delta`
|
||||
- `content_block_stop`
|
||||
- `message_delta`
|
||||
- `message_stop`
|
||||
|
||||
OpenAI 流式响应会输出 `chat.completion.chunk` 形状的 `data:` 行,并以:
|
||||
|
||||
- `data: [DONE]`
|
||||
|
||||
结束。
|
||||
本项目的协议实现思路参考并继承自 [coolxll/lingma-ipc-proxy](https://github.com/coolxll/lingma-ipc-proxy) 的协议发现工作。Lingma 私有本地 IPC 可以被转换为标准 OpenAI / Anthropic API 这一核心思想是该项目首先验证出来的;本项目在此基础上补充了更完整的协议兼容、工具调用、图片处理、桌面 App、请求 / 日志观测、跨平台打包和 release 自动化。
|
||||
|
||||
35
SUMMARY.md
@@ -6,11 +6,13 @@
|
||||
|
||||
### 核心功能
|
||||
|
||||
- **API 兼容性**: 支持 OpenAI (`/v1/chat/completions`) 和 Anthropic (`/v1/messages`) 格式的 API
|
||||
- **完整 API 适配**: 完整支持 OpenAI (`/v1/chat/completions`) 和 Anthropic (`/v1/messages`) 协议
|
||||
- **流式与非流式响应**: 完整支持 SSE 流式输出和普通 JSON 响应
|
||||
- **双传输层**: 支持 Windows Named Pipe 和 WebSocket 两种传输方式
|
||||
- **直接 IPC 通信**: 直接与 Lingma 进程通信,不依赖 DOM/CDP
|
||||
- **工具调用模拟**: 通过 prompt 注入方式模拟工具调用能力
|
||||
- **工具调用**: 完整支持 `tools` / `tool_choice`,兼容多轮 Agent 循环
|
||||
- **多模态输入**: 支持图片输入(OpenAI `image_url` / Anthropic `image` source)
|
||||
- **参数兼容**: 完整接收 `temperature`、`top_p`、`stop`、`presence_penalty` 等标准参数
|
||||
|
||||
---
|
||||
|
||||
@@ -50,7 +52,7 @@ lingma-ipc-proxy/
|
||||
| `httpapi` | HTTP 服务、请求解析、响应格式化(OpenAI/Anthropic 双协议) |
|
||||
| `lingmaipc` | 底层 IPC 通信(Named Pipe/WebSocket)、JSON-RPC 协议 |
|
||||
| `service` | 业务逻辑编排、会话生命周期管理、模型列表获取 |
|
||||
| `toolemulation` | 工具调用模拟(通过 prompt 注入实现) |
|
||||
| `toolemulation` | 工具调用支持(定义注入、解析、重编码、多轮历史) |
|
||||
|
||||
---
|
||||
|
||||
@@ -85,13 +87,15 @@ lingma-ipc-proxy/
|
||||
- HTTP 层使用 `http.Flusher` 实时推送 SSE
|
||||
- 支持 Anthropic 和 OpenAI 两种流式格式
|
||||
|
||||
### 5. 工具调用模拟
|
||||
### 5. 工具调用支持
|
||||
|
||||
不依赖原生工具支持,通过 prompt 工程实现:
|
||||
- 注入工具定义到 system prompt
|
||||
- 要求模型输出 `\`\`\`json action` 代码块
|
||||
- 解析 Action Block 转换为 Tool Call
|
||||
- 支持工具结果回传继续对话
|
||||
完整实现 OpenAI / Anthropic 标准工具协议:
|
||||
- 注入工具定义到对话上下文
|
||||
- 解析模型动作输出,重编码为 `tool_calls` / `tool_use`
|
||||
- 维护多轮工具调用历史并重新投影
|
||||
- 包装工具结果为续写提示词
|
||||
- 拒答检测与自动重试纠偏
|
||||
- 支持 `parallel_tool_calls: false` 约束
|
||||
|
||||
---
|
||||
|
||||
@@ -175,15 +179,16 @@ require (
|
||||
- [x] 配置文件支持(JSON)
|
||||
- [x] 环境变量支持
|
||||
- [x] Windows 服务部署脚本
|
||||
- [x] 工具调用模拟(prompt 注入方式)
|
||||
- [x] 工具调用支持(完整 OpenAI / Anthropic 协议)
|
||||
- [x] 多轮 Agent 循环(tool history 投影 + 结果回灌)
|
||||
- [x] 图片输入支持(base64 / HTTP URL)
|
||||
- [x] API 参数兼容(temperature、top_p、stop 等)
|
||||
- [x] 跨平台支持(Windows / macOS / Linux)
|
||||
- [x] 基础测试覆盖
|
||||
|
||||
### 技术债务/待优化 ⚠️
|
||||
### 项目状态
|
||||
|
||||
- [ ] 工具模拟目前通过 prompt 注入,非原生支持
|
||||
- [ ] 单请求限流(channel buffer=1)可能成为瓶颈
|
||||
- [ ] 仅支持 Windows(Named Pipe 依赖)
|
||||
- [ ] 测试覆盖率可进一步提升
|
||||
**完整可用**。代理层已实现 OpenAI 和 Anthropic 双协议的完整适配,支持文本对话、工具调用、图片输入、流式响应等全部核心功能,可直接对接 Claude Code、Continue、Cline 等客户端使用。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ type fileConfig struct {
|
||||
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"`
|
||||
@@ -113,6 +114,7 @@ func loadConfig() (service.Config, string) {
|
||||
cwd := flag.String("cwd", cfg.Cwd, "Working directory used when creating Lingma sessions")
|
||||
currentFilePath := flag.String("current-file-path", cfg.CurrentFilePath, "Current file path sent through ACP meta")
|
||||
mode := flag.String("mode", cfg.Mode, "Lingma ACP mode value")
|
||||
model := flag.String("model", cfg.Model, "Default Lingma model when API request omits model")
|
||||
shellType := flag.String("shell-type", cfg.ShellType, "Shell type sent through ACP meta")
|
||||
timeoutSeconds := flag.Int("timeout", int(cfg.Timeout/time.Second), "Per-request timeout in seconds")
|
||||
sessionMode := flag.String("session-mode", string(cfg.SessionMode), "Session mode: auto, fresh, reuse")
|
||||
@@ -131,6 +133,7 @@ func loadConfig() (service.Config, string) {
|
||||
cfg.Cwd = strings.TrimSpace(*cwd)
|
||||
cfg.CurrentFilePath = strings.TrimSpace(*currentFilePath)
|
||||
cfg.Mode = strings.TrimSpace(*mode)
|
||||
cfg.Model = strings.TrimSpace(*model)
|
||||
cfg.ShellType = strings.TrimSpace(*shellType)
|
||||
cfg.SessionMode = parsedSessionMode
|
||||
cfg.Timeout = time.Duration(*timeoutSeconds) * time.Second
|
||||
@@ -195,6 +198,9 @@ func overlayFileConfig(dst *service.Config, src fileConfig) {
|
||||
if strings.TrimSpace(src.Mode) != "" {
|
||||
dst.Mode = strings.TrimSpace(src.Mode)
|
||||
}
|
||||
if strings.TrimSpace(src.Model) != "" {
|
||||
dst.Model = strings.TrimSpace(src.Model)
|
||||
}
|
||||
if strings.TrimSpace(src.ShellType) != "" {
|
||||
dst.ShellType = strings.TrimSpace(src.ShellType)
|
||||
}
|
||||
@@ -231,6 +237,9 @@ func overlayEnvConfig(dst *service.Config) {
|
||||
if value := strings.TrimSpace(os.Getenv("LINGMA_PROXY_MODE")); value != "" {
|
||||
dst.Mode = value
|
||||
}
|
||||
if value := strings.TrimSpace(os.Getenv("LINGMA_PROXY_MODEL")); value != "" {
|
||||
dst.Model = value
|
||||
}
|
||||
if value := strings.TrimSpace(os.Getenv("LINGMA_PROXY_SHELL_TYPE")); value != "" {
|
||||
dst.ShellType = value
|
||||
}
|
||||
|
||||
3
desktop/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
build/bin
|
||||
node_modules
|
||||
frontend/dist
|
||||
19
desktop/README.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# README
|
||||
|
||||
## About
|
||||
|
||||
This is the official Wails Vue-TS template.
|
||||
|
||||
You can configure the project by editing `wails.json`. More information about the project settings can be found
|
||||
here: https://wails.io/docs/reference/project-config
|
||||
|
||||
## Live Development
|
||||
|
||||
To run in live development mode, run `wails dev` in the project directory. This will run a Vite development
|
||||
server that will provide very fast hot reload of your frontend changes. If you want to develop in a browser
|
||||
and have access to your Go methods, there is also a dev server that runs on http://localhost:34115. Connect
|
||||
to this in your browser, and you can call your Go code from devtools.
|
||||
|
||||
## Building
|
||||
|
||||
To build a redistributable, production mode package, use `wails build`.
|
||||
627
desktop/app.go
Normal file
@@ -0,0 +1,627 @@
|
||||
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/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"`
|
||||
StatusCode int `json:"statusCode"`
|
||||
Duration string `json:"duration"`
|
||||
ReqBody string `json:"reqBody,omitempty"`
|
||||
RespBody string `json:"respBody,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
|
||||
}
|
||||
|
||||
// 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"`
|
||||
Models int `json:"models"`
|
||||
Model string `json:"model,omitempty"`
|
||||
StartedAt string `json:"startedAt,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()
|
||||
|
||||
// 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()
|
||||
}
|
||||
|
||||
// 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", "正在停止代理并退出应用")
|
||||
if err := a.StopProxy(); err != nil {
|
||||
runtime.LogWarningf(a.ctx, "stop proxy before exit failed: %v", err)
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
func (a *App) emitLog(level string, message string) {
|
||||
runtime.EventsEmit(a.ctx, "log", map[string]string{
|
||||
"level": level,
|
||||
"message": message,
|
||||
})
|
||||
}
|
||||
|
||||
// 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,
|
||||
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
|
||||
}
|
||||
|
||||
// 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-ipc-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,
|
||||
"transport": string(cfg.Transport),
|
||||
"pipe": cfg.Pipe,
|
||||
"websocket_url": cfg.WebSocketURL,
|
||||
"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,
|
||||
}
|
||||
|
||||
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()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
if a.running {
|
||||
return fmt.Errorf("proxy already running")
|
||||
}
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", a.cfg.Host, a.cfg.Port)
|
||||
svc := service.New(a.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("Lingma IPC warmup failed: %v. %s", err, transportFallbackHint()))
|
||||
} else {
|
||||
runtime.LogInfo(a.ctx, "Lingma IPC warmup completed")
|
||||
a.emitLog("info", "Lingma IPC warmup completed")
|
||||
}
|
||||
cancel()
|
||||
|
||||
server := httpapi.NewServer(addr, svc)
|
||||
server.OnRequest = func(method, path string, statusCode int, duration time.Duration, reqBody, respBody string) {
|
||||
a.mu.Lock()
|
||||
a.requests = append(a.requests, RequestRecord{
|
||||
Time: time.Now().Format("15:04:05"),
|
||||
Method: method,
|
||||
Path: path,
|
||||
StatusCode: statusCode,
|
||||
Duration: duration.Round(time.Millisecond).String(),
|
||||
ReqBody: reqBody,
|
||||
RespBody: respBody,
|
||||
})
|
||||
if len(a.requests) > 100 {
|
||||
a.requests = a.requests[len(a.requests)-100:]
|
||||
}
|
||||
a.mu.Unlock()
|
||||
runtime.EventsEmit(a.ctx, "requests:updated", a.GetRequests())
|
||||
}
|
||||
|
||||
// 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.server = server
|
||||
a.addr = addr
|
||||
a.running = true
|
||||
a.startedAt = time.Now()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// ClearLogs is a no-op backend helper (logs are kept in frontend memory)
|
||||
func (a *App) ClearLogs() {}
|
||||
|
||||
// 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.mu.Unlock()
|
||||
a.emitLog("info", "Request history cleared")
|
||||
}
|
||||
|
||||
// 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 defaultConfig() service.Config {
|
||||
cfg := service.Config{
|
||||
Host: "127.0.0.1",
|
||||
Port: 8095,
|
||||
Transport: lingmaipc.TransportAuto,
|
||||
Cwd: defaultCwd(),
|
||||
Mode: "agent",
|
||||
ShellType: defaultShellType(),
|
||||
SessionMode: service.SessionModeAuto,
|
||||
Timeout: 120 * time.Second,
|
||||
}
|
||||
|
||||
// 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"`
|
||||
Transport string `json:"transport"`
|
||||
Pipe string `json:"pipe"`
|
||||
WebSocketURL string `json:"websocket_url"`
|
||||
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"`
|
||||
}
|
||||
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.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.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
|
||||
}
|
||||
}
|
||||
break // loaded successfully
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
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-ipc-proxy.json"))
|
||||
}
|
||||
// 2. Current working directory
|
||||
if wd, err := os.Getwd(); err == nil {
|
||||
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-ipc-proxy.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 插件已启动并登录;如果自动探测失败,请到设置页手动填写:macOS WebSocket 示例 ws://127.0.0.1:36510/,Windows Named Pipe 示例 \\\\.\\pipe\\lingma-xxxx,或 Windows WebSocket 示例 ws://127.0.0.1:36510/。"
|
||||
}
|
||||
BIN
desktop/build/Lingma.icns
Normal file
35
desktop/build/README.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Build Directory
|
||||
|
||||
The build directory is used to house all the build files and assets for your application.
|
||||
|
||||
The structure is:
|
||||
|
||||
* bin - Output directory
|
||||
* darwin - macOS specific files
|
||||
* windows - Windows specific files
|
||||
|
||||
## Mac
|
||||
|
||||
The `darwin` directory holds files specific to Mac builds.
|
||||
These may be customised and used as part of the build. To return these files to the default state, simply delete them
|
||||
and
|
||||
build with `wails build`.
|
||||
|
||||
The directory contains the following files:
|
||||
|
||||
- `Info.plist` - the main plist file used for Mac builds. It is used when building using `wails build`.
|
||||
- `Info.dev.plist` - same as the main plist file but used when building using `wails dev`.
|
||||
|
||||
## Windows
|
||||
|
||||
The `windows` directory contains the manifest and rc files used when building with `wails build`.
|
||||
These may be customised for your application. To return these files to the default state, simply delete them and
|
||||
build with `wails build`.
|
||||
|
||||
- `icon.ico` - The icon used for the application. This is used when building using `wails build`. If you wish to
|
||||
use a different icon, simply replace this file with your own. If it is missing, a new `icon.ico` file
|
||||
will be created using the `appicon.png` file in the build directory.
|
||||
- `installer/*` - The files used to create the Windows installer. These are used when building using `wails build`.
|
||||
- `info.json` - Application details used for Windows builds. The data here will be used by the Windows installer,
|
||||
as well as the application itself (right click the exe -> properties -> details)
|
||||
- `wails.exe.manifest` - The main application manifest file.
|
||||
BIN
desktop/build/appicon.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
68
desktop/build/darwin/Info.dev.plist
Normal file
@@ -0,0 +1,68 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>{{.Info.ProductName}}</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>{{.OutputFilename}}</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.wails.{{.Name}}</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>{{.Info.ProductVersion}}</string>
|
||||
<key>CFBundleGetInfoString</key>
|
||||
<string>{{.Info.Comments}}</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>{{.Info.ProductVersion}}</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>iconfile</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>10.13.0</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<string>true</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>{{.Info.Copyright}}</string>
|
||||
{{if .Info.FileAssociations}}
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
{{range .Info.FileAssociations}}
|
||||
<dict>
|
||||
<key>CFBundleTypeExtensions</key>
|
||||
<array>
|
||||
<string>{{.Ext}}</string>
|
||||
</array>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>{{.Name}}</string>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>{{.Role}}</string>
|
||||
<key>CFBundleTypeIconFile</key>
|
||||
<string>{{.IconName}}</string>
|
||||
</dict>
|
||||
{{end}}
|
||||
</array>
|
||||
{{end}}
|
||||
{{if .Info.Protocols}}
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
{{range .Info.Protocols}}
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.wails.{{.Scheme}}</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>{{.Scheme}}</string>
|
||||
</array>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>{{.Role}}</string>
|
||||
</dict>
|
||||
{{end}}
|
||||
</array>
|
||||
{{end}}
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsLocalNetworking</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
63
desktop/build/darwin/Info.plist
Normal file
@@ -0,0 +1,63 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>{{.Info.ProductName}}</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>{{.OutputFilename}}</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.wails.{{.Name}}</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>{{.Info.ProductVersion}}</string>
|
||||
<key>CFBundleGetInfoString</key>
|
||||
<string>{{.Info.Comments}}</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>{{.Info.ProductVersion}}</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>iconfile</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>10.13.0</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<string>true</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>{{.Info.Copyright}}</string>
|
||||
{{if .Info.FileAssociations}}
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
{{range .Info.FileAssociations}}
|
||||
<dict>
|
||||
<key>CFBundleTypeExtensions</key>
|
||||
<array>
|
||||
<string>{{.Ext}}</string>
|
||||
</array>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>{{.Name}}</string>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>{{.Role}}</string>
|
||||
<key>CFBundleTypeIconFile</key>
|
||||
<string>{{.IconName}}</string>
|
||||
</dict>
|
||||
{{end}}
|
||||
</array>
|
||||
{{end}}
|
||||
{{if .Info.Protocols}}
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
{{range .Info.Protocols}}
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.wails.{{.Scheme}}</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>{{.Scheme}}</string>
|
||||
</array>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>{{.Role}}</string>
|
||||
</dict>
|
||||
{{end}}
|
||||
</array>
|
||||
{{end}}
|
||||
</dict>
|
||||
</plist>
|
||||
BIN
desktop/build/iconfile.icns
Normal file
BIN
desktop/build/windows/icon.ico
Normal file
|
After Width: | Height: | Size: 37 KiB |
15
desktop/build/windows/info.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"fixed": {
|
||||
"file_version": "{{.Info.ProductVersion}}"
|
||||
},
|
||||
"info": {
|
||||
"0000": {
|
||||
"ProductVersion": "{{.Info.ProductVersion}}",
|
||||
"CompanyName": "{{.Info.CompanyName}}",
|
||||
"FileDescription": "{{.Info.ProductName}}",
|
||||
"LegalCopyright": "{{.Info.Copyright}}",
|
||||
"ProductName": "{{.Info.ProductName}}",
|
||||
"Comments": "{{.Info.Comments}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
114
desktop/build/windows/installer/project.nsi
Normal file
@@ -0,0 +1,114 @@
|
||||
Unicode true
|
||||
|
||||
####
|
||||
## Please note: Template replacements don't work in this file. They are provided with default defines like
|
||||
## mentioned underneath.
|
||||
## If the keyword is not defined, "wails_tools.nsh" will populate them with the values from ProjectInfo.
|
||||
## If they are defined here, "wails_tools.nsh" will not touch them. This allows to use this project.nsi manually
|
||||
## from outside of Wails for debugging and development of the installer.
|
||||
##
|
||||
## For development first make a wails nsis build to populate the "wails_tools.nsh":
|
||||
## > wails build --target windows/amd64 --nsis
|
||||
## Then you can call makensis on this file with specifying the path to your binary:
|
||||
## For a AMD64 only installer:
|
||||
## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app.exe
|
||||
## For a ARM64 only installer:
|
||||
## > makensis -DARG_WAILS_ARM64_BINARY=..\..\bin\app.exe
|
||||
## For a installer with both architectures:
|
||||
## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app-amd64.exe -DARG_WAILS_ARM64_BINARY=..\..\bin\app-arm64.exe
|
||||
####
|
||||
## The following information is taken from the ProjectInfo file, but they can be overwritten here.
|
||||
####
|
||||
## !define INFO_PROJECTNAME "MyProject" # Default "{{.Name}}"
|
||||
## !define INFO_COMPANYNAME "MyCompany" # Default "{{.Info.CompanyName}}"
|
||||
## !define INFO_PRODUCTNAME "MyProduct" # Default "{{.Info.ProductName}}"
|
||||
## !define INFO_PRODUCTVERSION "1.0.0" # Default "{{.Info.ProductVersion}}"
|
||||
## !define INFO_COPYRIGHT "Copyright" # Default "{{.Info.Copyright}}"
|
||||
###
|
||||
## !define PRODUCT_EXECUTABLE "Application.exe" # Default "${INFO_PROJECTNAME}.exe"
|
||||
## !define UNINST_KEY_NAME "UninstKeyInRegistry" # Default "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
|
||||
####
|
||||
## !define REQUEST_EXECUTION_LEVEL "admin" # Default "admin" see also https://nsis.sourceforge.io/Docs/Chapter4.html
|
||||
####
|
||||
## Include the wails tools
|
||||
####
|
||||
!include "wails_tools.nsh"
|
||||
|
||||
# The version information for this two must consist of 4 parts
|
||||
VIProductVersion "${INFO_PRODUCTVERSION}.0"
|
||||
VIFileVersion "${INFO_PRODUCTVERSION}.0"
|
||||
|
||||
VIAddVersionKey "CompanyName" "${INFO_COMPANYNAME}"
|
||||
VIAddVersionKey "FileDescription" "${INFO_PRODUCTNAME} Installer"
|
||||
VIAddVersionKey "ProductVersion" "${INFO_PRODUCTVERSION}"
|
||||
VIAddVersionKey "FileVersion" "${INFO_PRODUCTVERSION}"
|
||||
VIAddVersionKey "LegalCopyright" "${INFO_COPYRIGHT}"
|
||||
VIAddVersionKey "ProductName" "${INFO_PRODUCTNAME}"
|
||||
|
||||
# Enable HiDPI support. https://nsis.sourceforge.io/Reference/ManifestDPIAware
|
||||
ManifestDPIAware true
|
||||
|
||||
!include "MUI.nsh"
|
||||
|
||||
!define MUI_ICON "..\icon.ico"
|
||||
!define MUI_UNICON "..\icon.ico"
|
||||
# !define MUI_WELCOMEFINISHPAGE_BITMAP "resources\leftimage.bmp" #Include this to add a bitmap on the left side of the Welcome Page. Must be a size of 164x314
|
||||
!define MUI_FINISHPAGE_NOAUTOCLOSE # Wait on the INSTFILES page so the user can take a look into the details of the installation steps
|
||||
!define MUI_ABORTWARNING # This will warn the user if they exit from the installer.
|
||||
|
||||
!insertmacro MUI_PAGE_WELCOME # Welcome to the installer page.
|
||||
# !insertmacro MUI_PAGE_LICENSE "resources\eula.txt" # Adds a EULA page to the installer
|
||||
!insertmacro MUI_PAGE_DIRECTORY # In which folder install page.
|
||||
!insertmacro MUI_PAGE_INSTFILES # Installing page.
|
||||
!insertmacro MUI_PAGE_FINISH # Finished installation page.
|
||||
|
||||
!insertmacro MUI_UNPAGE_INSTFILES # Uinstalling page
|
||||
|
||||
!insertmacro MUI_LANGUAGE "English" # Set the Language of the installer
|
||||
|
||||
## The following two statements can be used to sign the installer and the uninstaller. The path to the binaries are provided in %1
|
||||
#!uninstfinalize 'signtool --file "%1"'
|
||||
#!finalize 'signtool --file "%1"'
|
||||
|
||||
Name "${INFO_PRODUCTNAME}"
|
||||
OutFile "..\..\bin\${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the installer's file.
|
||||
InstallDir "$PROGRAMFILES64\${INFO_COMPANYNAME}\${INFO_PRODUCTNAME}" # Default installing folder ($PROGRAMFILES is Program Files folder).
|
||||
ShowInstDetails show # This will always show the installation details.
|
||||
|
||||
Function .onInit
|
||||
!insertmacro wails.checkArchitecture
|
||||
FunctionEnd
|
||||
|
||||
Section
|
||||
!insertmacro wails.setShellContext
|
||||
|
||||
!insertmacro wails.webview2runtime
|
||||
|
||||
SetOutPath $INSTDIR
|
||||
|
||||
!insertmacro wails.files
|
||||
|
||||
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
|
||||
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
|
||||
|
||||
!insertmacro wails.associateFiles
|
||||
!insertmacro wails.associateCustomProtocols
|
||||
|
||||
!insertmacro wails.writeUninstaller
|
||||
SectionEnd
|
||||
|
||||
Section "uninstall"
|
||||
!insertmacro wails.setShellContext
|
||||
|
||||
RMDir /r "$AppData\${PRODUCT_EXECUTABLE}" # Remove the WebView2 DataPath
|
||||
|
||||
RMDir /r $INSTDIR
|
||||
|
||||
Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk"
|
||||
Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk"
|
||||
|
||||
!insertmacro wails.unassociateFiles
|
||||
!insertmacro wails.unassociateCustomProtocols
|
||||
|
||||
!insertmacro wails.deleteUninstaller
|
||||
SectionEnd
|
||||
249
desktop/build/windows/installer/wails_tools.nsh
Normal file
@@ -0,0 +1,249 @@
|
||||
# DO NOT EDIT - Generated automatically by `wails build`
|
||||
|
||||
!include "x64.nsh"
|
||||
!include "WinVer.nsh"
|
||||
!include "FileFunc.nsh"
|
||||
|
||||
!ifndef INFO_PROJECTNAME
|
||||
!define INFO_PROJECTNAME "{{.Name}}"
|
||||
!endif
|
||||
!ifndef INFO_COMPANYNAME
|
||||
!define INFO_COMPANYNAME "{{.Info.CompanyName}}"
|
||||
!endif
|
||||
!ifndef INFO_PRODUCTNAME
|
||||
!define INFO_PRODUCTNAME "{{.Info.ProductName}}"
|
||||
!endif
|
||||
!ifndef INFO_PRODUCTVERSION
|
||||
!define INFO_PRODUCTVERSION "{{.Info.ProductVersion}}"
|
||||
!endif
|
||||
!ifndef INFO_COPYRIGHT
|
||||
!define INFO_COPYRIGHT "{{.Info.Copyright}}"
|
||||
!endif
|
||||
!ifndef PRODUCT_EXECUTABLE
|
||||
!define PRODUCT_EXECUTABLE "${INFO_PROJECTNAME}.exe"
|
||||
!endif
|
||||
!ifndef UNINST_KEY_NAME
|
||||
!define UNINST_KEY_NAME "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
|
||||
!endif
|
||||
!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINST_KEY_NAME}"
|
||||
|
||||
!ifndef REQUEST_EXECUTION_LEVEL
|
||||
!define REQUEST_EXECUTION_LEVEL "admin"
|
||||
!endif
|
||||
|
||||
RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}"
|
||||
|
||||
!ifdef ARG_WAILS_AMD64_BINARY
|
||||
!define SUPPORTS_AMD64
|
||||
!endif
|
||||
|
||||
!ifdef ARG_WAILS_ARM64_BINARY
|
||||
!define SUPPORTS_ARM64
|
||||
!endif
|
||||
|
||||
!ifdef SUPPORTS_AMD64
|
||||
!ifdef SUPPORTS_ARM64
|
||||
!define ARCH "amd64_arm64"
|
||||
!else
|
||||
!define ARCH "amd64"
|
||||
!endif
|
||||
!else
|
||||
!ifdef SUPPORTS_ARM64
|
||||
!define ARCH "arm64"
|
||||
!else
|
||||
!error "Wails: Undefined ARCH, please provide at least one of ARG_WAILS_AMD64_BINARY or ARG_WAILS_ARM64_BINARY"
|
||||
!endif
|
||||
!endif
|
||||
|
||||
!macro wails.checkArchitecture
|
||||
!ifndef WAILS_WIN10_REQUIRED
|
||||
!define WAILS_WIN10_REQUIRED "This product is only supported on Windows 10 (Server 2016) and later."
|
||||
!endif
|
||||
|
||||
!ifndef WAILS_ARCHITECTURE_NOT_SUPPORTED
|
||||
!define WAILS_ARCHITECTURE_NOT_SUPPORTED "This product can't be installed on the current Windows architecture. Supports: ${ARCH}"
|
||||
!endif
|
||||
|
||||
${If} ${AtLeastWin10}
|
||||
!ifdef SUPPORTS_AMD64
|
||||
${if} ${IsNativeAMD64}
|
||||
Goto ok
|
||||
${EndIf}
|
||||
!endif
|
||||
|
||||
!ifdef SUPPORTS_ARM64
|
||||
${if} ${IsNativeARM64}
|
||||
Goto ok
|
||||
${EndIf}
|
||||
!endif
|
||||
|
||||
IfSilent silentArch notSilentArch
|
||||
silentArch:
|
||||
SetErrorLevel 65
|
||||
Abort
|
||||
notSilentArch:
|
||||
MessageBox MB_OK "${WAILS_ARCHITECTURE_NOT_SUPPORTED}"
|
||||
Quit
|
||||
${else}
|
||||
IfSilent silentWin notSilentWin
|
||||
silentWin:
|
||||
SetErrorLevel 64
|
||||
Abort
|
||||
notSilentWin:
|
||||
MessageBox MB_OK "${WAILS_WIN10_REQUIRED}"
|
||||
Quit
|
||||
${EndIf}
|
||||
|
||||
ok:
|
||||
!macroend
|
||||
|
||||
!macro wails.files
|
||||
!ifdef SUPPORTS_AMD64
|
||||
${if} ${IsNativeAMD64}
|
||||
File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_AMD64_BINARY}"
|
||||
${EndIf}
|
||||
!endif
|
||||
|
||||
!ifdef SUPPORTS_ARM64
|
||||
${if} ${IsNativeARM64}
|
||||
File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_ARM64_BINARY}"
|
||||
${EndIf}
|
||||
!endif
|
||||
!macroend
|
||||
|
||||
!macro wails.writeUninstaller
|
||||
WriteUninstaller "$INSTDIR\uninstall.exe"
|
||||
|
||||
SetRegView 64
|
||||
WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}"
|
||||
WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}"
|
||||
WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}"
|
||||
WriteRegStr HKLM "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}"
|
||||
WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\""
|
||||
WriteRegStr HKLM "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S"
|
||||
|
||||
${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2
|
||||
IntFmt $0 "0x%08X" $0
|
||||
WriteRegDWORD HKLM "${UNINST_KEY}" "EstimatedSize" "$0"
|
||||
!macroend
|
||||
|
||||
!macro wails.deleteUninstaller
|
||||
Delete "$INSTDIR\uninstall.exe"
|
||||
|
||||
SetRegView 64
|
||||
DeleteRegKey HKLM "${UNINST_KEY}"
|
||||
!macroend
|
||||
|
||||
!macro wails.setShellContext
|
||||
${If} ${REQUEST_EXECUTION_LEVEL} == "admin"
|
||||
SetShellVarContext all
|
||||
${else}
|
||||
SetShellVarContext current
|
||||
${EndIf}
|
||||
!macroend
|
||||
|
||||
# Install webview2 by launching the bootstrapper
|
||||
# See https://docs.microsoft.com/en-us/microsoft-edge/webview2/concepts/distribution#online-only-deployment
|
||||
!macro wails.webview2runtime
|
||||
!ifndef WAILS_INSTALL_WEBVIEW_DETAILPRINT
|
||||
!define WAILS_INSTALL_WEBVIEW_DETAILPRINT "Installing: WebView2 Runtime"
|
||||
!endif
|
||||
|
||||
SetRegView 64
|
||||
# If the admin key exists and is not empty then webview2 is already installed
|
||||
ReadRegStr $0 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
|
||||
${If} $0 != ""
|
||||
Goto ok
|
||||
${EndIf}
|
||||
|
||||
${If} ${REQUEST_EXECUTION_LEVEL} == "user"
|
||||
# If the installer is run in user level, check the user specific key exists and is not empty then webview2 is already installed
|
||||
ReadRegStr $0 HKCU "Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
|
||||
${If} $0 != ""
|
||||
Goto ok
|
||||
${EndIf}
|
||||
${EndIf}
|
||||
|
||||
SetDetailsPrint both
|
||||
DetailPrint "${WAILS_INSTALL_WEBVIEW_DETAILPRINT}"
|
||||
SetDetailsPrint listonly
|
||||
|
||||
InitPluginsDir
|
||||
CreateDirectory "$pluginsdir\webview2bootstrapper"
|
||||
SetOutPath "$pluginsdir\webview2bootstrapper"
|
||||
File "tmp\MicrosoftEdgeWebview2Setup.exe"
|
||||
ExecWait '"$pluginsdir\webview2bootstrapper\MicrosoftEdgeWebview2Setup.exe" /silent /install'
|
||||
|
||||
SetDetailsPrint both
|
||||
ok:
|
||||
!macroend
|
||||
|
||||
# Copy of APP_ASSOCIATE and APP_UNASSOCIATE macros from here https://gist.github.com/nikku/281d0ef126dbc215dd58bfd5b3a5cd5b
|
||||
!macro APP_ASSOCIATE EXT FILECLASS DESCRIPTION ICON COMMANDTEXT COMMAND
|
||||
; Backup the previously associated file class
|
||||
ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" ""
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "${FILECLASS}_backup" "$R0"
|
||||
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "${FILECLASS}"
|
||||
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}" "" `${DESCRIPTION}`
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\DefaultIcon" "" `${ICON}`
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell" "" "open"
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open" "" `${COMMANDTEXT}`
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open\command" "" `${COMMAND}`
|
||||
!macroend
|
||||
|
||||
!macro APP_UNASSOCIATE EXT FILECLASS
|
||||
; Backup the previously associated file class
|
||||
ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" `${FILECLASS}_backup`
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "$R0"
|
||||
|
||||
DeleteRegKey SHELL_CONTEXT `Software\Classes\${FILECLASS}`
|
||||
!macroend
|
||||
|
||||
!macro wails.associateFiles
|
||||
; Create file associations
|
||||
{{range .Info.FileAssociations}}
|
||||
!insertmacro APP_ASSOCIATE "{{.Ext}}" "{{.Name}}" "{{.Description}}" "$INSTDIR\{{.IconName}}.ico" "Open with ${INFO_PRODUCTNAME}" "$INSTDIR\${PRODUCT_EXECUTABLE} $\"%1$\""
|
||||
|
||||
File "..\{{.IconName}}.ico"
|
||||
{{end}}
|
||||
!macroend
|
||||
|
||||
!macro wails.unassociateFiles
|
||||
; Delete app associations
|
||||
{{range .Info.FileAssociations}}
|
||||
!insertmacro APP_UNASSOCIATE "{{.Ext}}" "{{.Name}}"
|
||||
|
||||
Delete "$INSTDIR\{{.IconName}}.ico"
|
||||
{{end}}
|
||||
!macroend
|
||||
|
||||
!macro CUSTOM_PROTOCOL_ASSOCIATE PROTOCOL DESCRIPTION ICON COMMAND
|
||||
DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}"
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "" "${DESCRIPTION}"
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "URL Protocol" ""
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\DefaultIcon" "" "${ICON}"
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell" "" ""
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open" "" ""
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open\command" "" "${COMMAND}"
|
||||
!macroend
|
||||
|
||||
!macro CUSTOM_PROTOCOL_UNASSOCIATE PROTOCOL
|
||||
DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}"
|
||||
!macroend
|
||||
|
||||
!macro wails.associateCustomProtocols
|
||||
; Create custom protocols associations
|
||||
{{range .Info.Protocols}}
|
||||
!insertmacro CUSTOM_PROTOCOL_ASSOCIATE "{{.Scheme}}" "{{.Description}}" "$INSTDIR\${PRODUCT_EXECUTABLE},0" "$INSTDIR\${PRODUCT_EXECUTABLE} $\"%1$\""
|
||||
|
||||
{{end}}
|
||||
!macroend
|
||||
|
||||
!macro wails.unassociateCustomProtocols
|
||||
; Delete app custom protocol associations
|
||||
{{range .Info.Protocols}}
|
||||
!insertmacro CUSTOM_PROTOCOL_UNASSOCIATE "{{.Scheme}}"
|
||||
{{end}}
|
||||
!macroend
|
||||
15
desktop/build/windows/wails.exe.manifest
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
|
||||
<assemblyIdentity type="win32" name="com.wails.{{.Name}}" version="{{.Info.ProductVersion}}.0" processorArchitecture="*"/>
|
||||
<dependency>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
|
||||
</dependentAssembly>
|
||||
</dependency>
|
||||
<asmv3:application>
|
||||
<asmv3:windowsSettings>
|
||||
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware> <!-- fallback for Windows 7 and 8 -->
|
||||
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">permonitorv2,permonitor</dpiAwareness> <!-- falls back to per-monitor if per-monitor v2 is not supported -->
|
||||
</asmv3:windowsSettings>
|
||||
</asmv3:application>
|
||||
</assembly>
|
||||
23
desktop/frontend/README.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# Vue 3 + TypeScript + Vite
|
||||
|
||||
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue
|
||||
3 `<script setup>` SFCs, check out
|
||||
the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
- [VS Code](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar)
|
||||
|
||||
## Type Support For `.vue` Imports in TS
|
||||
|
||||
Since TypeScript cannot handle type information for `.vue` imports, they are shimmed to be a generic Vue component type
|
||||
by default. In most cases this is fine if you don't really care about component prop types outside of templates.
|
||||
However, if you wish to get actual prop types in `.vue` imports (for example to get props validation when using
|
||||
manual `h(...)` calls), you can enable Volar's Take Over mode by following these steps:
|
||||
|
||||
1. Run `Extensions: Show Built-in Extensions` from VS Code's command palette, look
|
||||
for `TypeScript and JavaScript Language Features`, then right click and select `Disable (Workspace)`. By default,
|
||||
Take Over mode will enable itself if the default TypeScript extension is disabled.
|
||||
2. Reload the VS Code window by running `Developer: Reload Window` from the command palette.
|
||||
|
||||
You can learn more about Take Over mode [here](https://github.com/johnsoncodehk/volar/discussions/471).
|
||||
13
desktop/frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<link rel="icon" type="image/png" href="/favicon.png"/>
|
||||
<title>lingma-proxy-desktop</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script src="./src/main.ts" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
1088
desktop/frontend/package-lock.json
generated
Normal file
22
desktop/frontend/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"bootstrap-icons": "^1.13.1",
|
||||
"vue": "^3.2.37"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/types": "^7.18.10",
|
||||
"@vitejs/plugin-vue": "^3.0.3",
|
||||
"typescript": "^4.6.4",
|
||||
"vite": "^3.0.7",
|
||||
"vue-tsc": "^1.8.27"
|
||||
}
|
||||
}
|
||||
1
desktop/frontend/package.json.md5
Executable file
@@ -0,0 +1 @@
|
||||
bd2b8442875d0d6e24cc3cec25d4d09b
|
||||
BIN
desktop/frontend/public/favicon.png
Normal file
|
After Width: | Height: | Size: 96 KiB |
268
desktop/frontend/src/App.vue
Normal file
@@ -0,0 +1,268 @@
|
||||
<script setup>
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import Dashboard from './views/Dashboard.vue'
|
||||
import Logs from './views/Logs.vue'
|
||||
import Models from './views/Models.vue'
|
||||
import Requests from './views/Requests.vue'
|
||||
import Settings from './views/Settings.vue'
|
||||
import { EventsOff, EventsOn } from '../wailsjs/runtime'
|
||||
import { GetStatus, HideWindow, MinimizeWindow } from '../wailsjs/go/main/App.js'
|
||||
import lingmaIcon from './assets/images/lingma-icon.png'
|
||||
|
||||
const currentTab = ref('dashboard')
|
||||
const logs = ref([])
|
||||
const status = ref({ running: false, addr: '', models: 0 })
|
||||
const toast = ref('')
|
||||
const themeMode = ref(localStorage.getItem('lingma-theme-mode') || 'system')
|
||||
const appliedTheme = ref('light')
|
||||
let systemThemeQuery = null
|
||||
let toastTimer = null
|
||||
|
||||
const navigation = [
|
||||
{ key: 'dashboard', label: '仪表盘', icon: 'bi-house-door' },
|
||||
{ key: 'requests', label: '请求流', icon: 'bi-file-earmark-text' },
|
||||
{ key: 'models', label: '模型', icon: 'bi-box' },
|
||||
{ key: 'settings', label: '设置', icon: 'bi-gear' },
|
||||
{ key: 'logs', label: '日志', icon: 'bi-terminal' },
|
||||
]
|
||||
|
||||
function addLog(level, message) {
|
||||
const time = new Date().toLocaleTimeString('zh-CN', { hour12: false })
|
||||
logs.value.unshift({ time, level, message })
|
||||
if (logs.value.length > 500) {
|
||||
logs.value = logs.value.slice(0, 500)
|
||||
}
|
||||
}
|
||||
|
||||
function showToast(message) {
|
||||
toast.value = message
|
||||
clearTimeout(toastTimer)
|
||||
toastTimer = setTimeout(() => {
|
||||
toast.value = ''
|
||||
}, 2200)
|
||||
}
|
||||
|
||||
function clearLocalLogs() {
|
||||
logs.value = []
|
||||
}
|
||||
|
||||
function setStatus(nextStatus) {
|
||||
status.value = nextStatus
|
||||
}
|
||||
|
||||
function handleNotice(message) {
|
||||
showToast(message)
|
||||
addLog('info', message)
|
||||
}
|
||||
|
||||
function resolveTheme() {
|
||||
if (themeMode.value === 'system') {
|
||||
return window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
}
|
||||
return themeMode.value
|
||||
}
|
||||
|
||||
function applyTheme() {
|
||||
appliedTheme.value = resolveTheme()
|
||||
document.documentElement.dataset.theme = appliedTheme.value
|
||||
localStorage.setItem('lingma-theme-mode', themeMode.value)
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
const modes = ['system', 'light', 'dark']
|
||||
const index = modes.indexOf(themeMode.value)
|
||||
themeMode.value = modes[(index + 1) % modes.length]
|
||||
applyTheme()
|
||||
}
|
||||
|
||||
function themeTitle() {
|
||||
if (themeMode.value === 'system') return `跟随系统(当前${appliedTheme.value === 'dark' ? '夜间' : '日间'})`
|
||||
return themeMode.value === 'dark' ? '夜间模式' : '日间模式'
|
||||
}
|
||||
|
||||
function themeIcon() {
|
||||
if (themeMode.value === 'system') return 'bi-circle-half'
|
||||
return themeMode.value === 'dark' ? 'bi-moon-stars' : 'bi-sun'
|
||||
}
|
||||
|
||||
async function refreshStatus() {
|
||||
try {
|
||||
status.value = await GetStatus()
|
||||
} catch (e) {
|
||||
addLog('error', '状态刷新失败:' + (e.message || String(e)))
|
||||
}
|
||||
}
|
||||
|
||||
async function copyEndpoint() {
|
||||
if (!status.value.addr) return
|
||||
const value = `http://${status.value.addr}`
|
||||
await navigator.clipboard?.writeText(value)
|
||||
handleNotice('已复制接口地址:' + value)
|
||||
}
|
||||
|
||||
function safeEventsOn(name, handler) {
|
||||
try {
|
||||
EventsOn(name, handler)
|
||||
} catch (e) {
|
||||
console.debug('Wails runtime event unavailable:', name)
|
||||
}
|
||||
}
|
||||
|
||||
function safeEventsOff(name) {
|
||||
try {
|
||||
EventsOff(name)
|
||||
} catch (e) {
|
||||
console.debug('Wails runtime event unavailable:', name)
|
||||
}
|
||||
}
|
||||
|
||||
function handleAppShortcut(event) {
|
||||
const key = event.key.toLowerCase()
|
||||
if ((event.metaKey || event.ctrlKey) && key === 'w') {
|
||||
event.preventDefault()
|
||||
HideWindow()
|
||||
}
|
||||
if ((event.metaKey || event.ctrlKey) && key === 'm') {
|
||||
event.preventDefault()
|
||||
MinimizeWindow()
|
||||
}
|
||||
// Fallback copy for WebView where native Edit menu is unavailable
|
||||
if ((event.metaKey || event.ctrlKey) && key === 'c') {
|
||||
const selection = window.getSelection()?.toString()
|
||||
if (selection) {
|
||||
event.preventDefault()
|
||||
navigator.clipboard?.writeText(selection).catch(() => {})
|
||||
}
|
||||
}
|
||||
// Fallback select-all for log/request content areas
|
||||
if ((event.metaKey || event.ctrlKey) && key === 'a') {
|
||||
const active = document.activeElement
|
||||
const isEditable = active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable)
|
||||
if (!isEditable) {
|
||||
const panel = document.querySelector('.log-list, .request-list, .detail-panel')
|
||||
if (panel) {
|
||||
event.preventDefault()
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(panel)
|
||||
const sel = window.getSelection()
|
||||
sel?.removeAllRanges()
|
||||
sel?.addRange(range)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', handleAppShortcut, true)
|
||||
systemThemeQuery = window.matchMedia?.('(prefers-color-scheme: dark)')
|
||||
systemThemeQuery?.addEventListener?.('change', applyTheme)
|
||||
applyTheme()
|
||||
refreshStatus()
|
||||
safeEventsOn('models:updated', (data) => {
|
||||
status.value.models = Array.isArray(data) ? data.length : status.value.models
|
||||
addLog('info', `模型列表已更新:${status.value.models} 个模型`)
|
||||
})
|
||||
safeEventsOn('log', (data) => {
|
||||
addLog(data.level || 'info', data.message || '')
|
||||
refreshStatus()
|
||||
})
|
||||
safeEventsOn('quit:confirm', (message) => {
|
||||
showToast(message || '再按一次退出快捷键将停止代理并退出应用')
|
||||
})
|
||||
safeEventsOn('status:updated', (nextStatus) => {
|
||||
status.value = nextStatus
|
||||
})
|
||||
safeEventsOn('requests:updated', () => {
|
||||
refreshStatus()
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', handleAppShortcut, true)
|
||||
clearTimeout(toastTimer)
|
||||
systemThemeQuery?.removeEventListener?.('change', applyTheme)
|
||||
safeEventsOff('models:updated')
|
||||
safeEventsOff('log')
|
||||
safeEventsOff('quit:confirm')
|
||||
safeEventsOff('status:updated')
|
||||
safeEventsOff('requests:updated')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-shell">
|
||||
<aside class="sidebar">
|
||||
<button class="brand" type="button" @click="currentTab = 'dashboard'">
|
||||
<span class="brand-mark">
|
||||
<img :src="lingmaIcon" alt="" />
|
||||
</span>
|
||||
<span>
|
||||
<strong>灵码代理</strong>
|
||||
<small>IPC Proxy</small>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<nav class="nav-list" aria-label="主导航">
|
||||
<button
|
||||
v-for="item in navigation"
|
||||
:key="item.key"
|
||||
class="nav-item"
|
||||
:class="{ active: currentTab === item.key }"
|
||||
type="button"
|
||||
@click="currentTab = item.key"
|
||||
>
|
||||
<span class="nav-icon">
|
||||
<i class="bi" :class="item.icon" aria-hidden="true"></i>
|
||||
</span>
|
||||
<span>{{ item.label }}</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-status">
|
||||
<span class="status-dot" :class="{ running: status.running }"></span>
|
||||
<div>
|
||||
<strong>{{ status.running ? 'Proxy Running' : 'Proxy Stopped' }}</strong>
|
||||
<small>v1.2.0</small>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section class="workspace">
|
||||
<header class="topbar">
|
||||
<span class="topbar-spacer" aria-hidden="true"></span>
|
||||
<div class="topbar-actions">
|
||||
<button class="icon-button" type="button" title="刷新状态" @click="refreshStatus">
|
||||
<i class="bi bi-arrow-clockwise" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button class="icon-button" type="button" title="复制接口地址" @click="copyEndpoint">
|
||||
<i class="bi bi-copy" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button class="icon-button" type="button" title="设置" @click="currentTab = 'settings'">
|
||||
<i class="bi bi-gear" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button class="icon-button" type="button" :title="themeTitle()" @click="toggleTheme">
|
||||
<i class="bi" :class="themeIcon()" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="view-stage">
|
||||
<Dashboard
|
||||
v-if="currentTab === 'dashboard'"
|
||||
:shell-status="status"
|
||||
@log="addLog"
|
||||
@status="setStatus"
|
||||
@notice="handleNotice"
|
||||
@open-settings="currentTab = 'settings'"
|
||||
@open-requests="currentTab = 'requests'"
|
||||
@open-models="currentTab = 'models'"
|
||||
/>
|
||||
<Requests v-else-if="currentTab === 'requests'" @notice="handleNotice" />
|
||||
<Models v-else-if="currentTab === 'models'" @log="addLog" @status="setStatus" @notice="handleNotice" />
|
||||
<Settings v-else-if="currentTab === 'settings'" @log="addLog" @status-refresh="refreshStatus" />
|
||||
<Logs v-else-if="currentTab === 'logs'" :logs="logs" @clear="clearLocalLogs" @notice="handleNotice" />
|
||||
</main>
|
||||
</section>
|
||||
<div v-if="toast" class="toast">{{ toast }}</div>
|
||||
</div>
|
||||
</template>
|
||||
93
desktop/frontend/src/assets/fonts/OFL.txt
Normal file
@@ -0,0 +1,93 @@
|
||||
Copyright 2016 The Nunito Project Authors (contact@sansoxygen.com),
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
BIN
desktop/frontend/src/assets/fonts/nunito-v16-latin-regular.woff2
Normal file
1
desktop/frontend/src/assets/icons/claude.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Claude</title><path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
1
desktop/frontend/src/assets/icons/gemma.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Gemma</title><path d="M12.34 5.953a8.233 8.233 0 01-.247-1.125V3.72a8.25 8.25 0 015.562 2.232H12.34zm-.69 0c.113-.373.199-.755.257-1.145V3.72a8.25 8.25 0 00-5.562 2.232h5.304zm-5.433.187h5.373a7.98 7.98 0 01-.267.696 8.41 8.41 0 01-1.76 2.65L6.216 6.14zm-.264-.187H2.977v.187h2.915a8.436 8.436 0 00-2.357 5.767H0v.186h3.535a8.436 8.436 0 002.357 5.767H2.977v.186h2.976v2.977h.187v-2.915a8.436 8.436 0 005.767 2.357V24h.186v-3.535a8.436 8.436 0 005.767-2.357v2.915h.186v-2.977h2.977v-.186h-2.915a8.436 8.436 0 002.357-5.767H24v-.186h-3.535a8.436 8.436 0 00-2.357-5.767h2.915v-.187h-2.977V2.977h-.186v2.915a8.436 8.436 0 00-5.767-2.357V0h-.186v3.535A8.436 8.436 0 006.14 5.892V2.977h-.187v2.976zm6.14 14.326a8.25 8.25 0 005.562-2.233H12.34c-.108.367-.19.743-.247 1.126v1.107zm-.186-1.087a8.015 8.015 0 00-.258-1.146H6.345a8.25 8.25 0 005.562 2.233v-1.087zm-8.186-7.285h1.107a8.23 8.23 0 001.125-.247V6.345a8.25 8.25 0 00-2.232 5.562zm1.087.186H3.72a8.25 8.25 0 002.232 5.562v-5.304a8.012 8.012 0 00-1.145-.258zm15.47-.186a8.25 8.25 0 00-2.232-5.562v5.315c.367.108.743.19 1.126.247h1.107zm-1.086.186c-.39.058-.772.144-1.146.258v5.304a8.25 8.25 0 002.233-5.562h-1.087zm-1.332 5.69V12.41a7.97 7.97 0 00-.696.267 8.409 8.409 0 00-2.65 1.76l3.346 3.346zm0-6.18v-5.45l-.012-.013h-5.451c.076.235.162.468.26.696a8.698 8.698 0 001.819 2.688 8.698 8.698 0 002.688 1.82c.228.097.46.183.696.259zM6.14 17.848V12.41c.235.078.468.167.696.267a8.403 8.403 0 012.688 1.799 8.404 8.404 0 011.799 2.688c.1.228.19.46.267.696H6.152l-.012-.012zm0-6.245V6.326l3.29 3.29a8.716 8.716 0 01-2.594 1.728 8.14 8.14 0 01-.696.259zm6.257 6.257h5.277l-3.29-3.29a8.716 8.716 0 00-1.728 2.594 8.135 8.135 0 00-.259.696zm-2.347-7.81a9.435 9.435 0 01-2.88 1.96 9.14 9.14 0 012.88 1.94 9.14 9.14 0 011.94 2.88 9.435 9.435 0 011.96-2.88 9.14 9.14 0 012.88-1.94 9.435 9.435 0 01-2.88-1.96 9.434 9.434 0 01-1.96-2.88 9.14 9.14 0 01-1.94 2.88z"></path></svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
1
desktop/frontend/src/assets/icons/kimi.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Kimi</title><path d="M21.846 0a1.923 1.923 0 110 3.846H20.15a.226.226 0 01-.227-.226V1.923C19.923.861 20.784 0 21.846 0z"></path><path d="M11.065 11.199l7.257-7.2c.137-.136.06-.41-.116-.41H14.3a.164.164 0 00-.117.051l-7.82 7.756c-.122.12-.302.013-.302-.179V3.82c0-.127-.083-.23-.185-.23H3.186c-.103 0-.186.103-.186.23V19.77c0 .128.083.23.186.23h2.69c.103 0 .186-.102.186-.23v-3.25c0-.069.025-.135.069-.178l2.424-2.406a.158.158 0 01.205-.023l6.484 4.772a7.677 7.677 0 003.453 1.283c.108.012.2-.095.2-.23v-3.06c0-.117-.07-.212-.164-.227a5.028 5.028 0 01-2.027-.807l-5.613-4.064c-.117-.078-.132-.279-.028-.381z"></path></svg>
|
||||
|
After Width: | Height: | Size: 786 B |
1
desktop/frontend/src/assets/icons/minimax.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Minimax</title><path d="M16.278 2c1.156 0 2.093.927 2.093 2.07v12.501a.74.74 0 00.744.709.74.74 0 00.743-.709V9.099a2.06 2.06 0 012.071-2.049A2.06 2.06 0 0124 9.1v6.561a.649.649 0 01-.652.645.649.649 0 01-.653-.645V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v7.472a2.037 2.037 0 01-2.048 2.026 2.037 2.037 0 01-2.048-2.026v-12.5a.785.785 0 00-.788-.753.785.785 0 00-.789.752l-.001 15.904A2.037 2.037 0 0113.441 22a2.037 2.037 0 01-2.048-2.026V18.04c0-.356.292-.645.652-.645.36 0 .652.289.652.645v1.934c0 .263.142.506.372.638.23.131.514.131.744 0a.734.734 0 00.372-.638V4.07c0-1.143.937-2.07 2.093-2.07zm-5.674 0c1.156 0 2.093.927 2.093 2.07v11.523a.648.648 0 01-.652.645.648.648 0 01-.652-.645V4.07a.785.785 0 00-.789-.78.785.785 0 00-.789.78v14.013a2.06 2.06 0 01-2.07 2.048 2.06 2.06 0 01-2.071-2.048V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v3.8a2.06 2.06 0 01-2.071 2.049A2.06 2.06 0 010 12.9v-1.378c0-.357.292-.646.652-.646.36 0 .653.29.653.646V12.9c0 .418.343.757.766.757s.766-.339.766-.757V9.099a2.06 2.06 0 012.07-2.048 2.06 2.06 0 012.071 2.048v8.984c0 .419.343.758.767.758.423 0 .766-.339.766-.758V4.07c0-1.143.937-2.07 2.093-2.07z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
desktop/frontend/src/assets/icons/openai.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenAI</title><path d="M9.205 8.658v-2.26c0-.19.072-.333.238-.428l4.543-2.616c.619-.357 1.356-.523 2.117-.523 2.854 0 4.662 2.212 4.662 4.566 0 .167 0 .357-.024.547l-4.71-2.759a.797.797 0 00-.856 0l-5.97 3.473zm10.609 8.8V12.06c0-.333-.143-.57-.429-.737l-5.97-3.473 1.95-1.118a.433.433 0 01.476 0l4.543 2.617c1.309.76 2.189 2.378 2.189 3.948 0 1.808-1.07 3.473-2.76 4.163zM7.802 12.703l-1.95-1.142c-.167-.095-.239-.238-.239-.428V5.899c0-2.545 1.95-4.472 4.591-4.472 1 0 1.927.333 2.712.928L8.23 5.067c-.285.166-.428.404-.428.737v6.898zM12 15.128l-2.795-1.57v-3.33L12 8.658l2.795 1.57v3.33L12 15.128zm1.796 7.23c-1 0-1.927-.332-2.712-.927l4.686-2.712c.285-.166.428-.404.428-.737v-6.898l1.974 1.142c.167.095.238.238.238.428v5.233c0 2.545-1.974 4.472-4.614 4.472zm-5.637-5.303l-4.544-2.617c-1.308-.761-2.188-2.378-2.188-3.948A4.482 4.482 0 014.21 6.327v5.423c0 .333.143.571.428.738l5.947 3.449-1.95 1.118a.432.432 0 01-.476 0zm-.262 3.9c-2.688 0-4.662-2.021-4.662-4.519 0-.19.024-.38.047-.57l4.686 2.71c.286.167.571.167.856 0l5.97-3.448v2.26c0 .19-.07.333-.237.428l-4.543 2.616c-.619.357-1.356.523-2.117.523zm5.899 2.83a5.947 5.947 0 005.827-4.756C22.287 18.339 24 15.84 24 13.296c0-1.665-.713-3.282-1.998-4.448.119-.5.19-.999.19-1.498 0-3.401-2.759-5.947-5.946-5.947-.642 0-1.26.095-1.88.31A5.962 5.962 0 0010.205 0a5.947 5.947 0 00-5.827 4.757C1.713 5.447 0 7.945 0 10.49c0 1.666.713 3.283 1.998 4.448-.119.5-.19 1-.19 1.499 0 3.401 2.759 5.946 5.946 5.946.642 0 1.26-.095 1.88-.309a5.96 5.96 0 004.162 1.713z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
1
desktop/frontend/src/assets/icons/qwen.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Qwen</title><path d="M12.604 1.34c.393.69.784 1.382 1.174 2.075a.18.18 0 00.157.091h5.552c.174 0 .322.11.446.327l1.454 2.57c.19.337.24.478.024.837-.26.43-.513.864-.76 1.3l-.367.658c-.106.196-.223.28-.04.512l2.652 4.637c.172.301.111.494-.043.77-.437.785-.882 1.564-1.335 2.34-.159.272-.352.375-.68.37-.777-.016-1.552-.01-2.327.016a.099.099 0 00-.081.05 575.097 575.097 0 01-2.705 4.74c-.169.293-.38.363-.725.364-.997.003-2.002.004-3.017.002a.537.537 0 01-.465-.271l-1.335-2.323a.09.09 0 00-.083-.049H4.982c-.285.03-.553-.001-.805-.092l-1.603-2.77a.543.543 0 01-.002-.54l1.207-2.12a.198.198 0 000-.197 550.951 550.951 0 01-1.875-3.272l-.79-1.395c-.16-.31-.173-.496.095-.965.465-.813.927-1.625 1.387-2.436.132-.234.304-.334.584-.335a338.3 338.3 0 012.589-.001.124.124 0 00.107-.063l2.806-4.895a.488.488 0 01.422-.246c.524-.001 1.053 0 1.583-.006L11.704 1c.341-.003.724.032.9.34zm-3.432.403a.06.06 0 00-.052.03L6.254 6.788a.157.157 0 01-.135.078H3.253c-.056 0-.07.025-.041.074l5.81 10.156c.025.042.013.062-.034.063l-2.795.015a.218.218 0 00-.2.116l-1.32 2.31c-.044.078-.021.118.068.118l5.716.008c.046 0 .08.02.104.061l1.403 2.454c.046.081.092.082.139 0l5.006-8.76.783-1.382a.055.055 0 01.096 0l1.424 2.53a.122.122 0 00.107.062l2.763-.02a.04.04 0 00.035-.02.041.041 0 000-.04l-2.9-5.086a.108.108 0 010-.113l.293-.507 1.12-1.977c.024-.041.012-.062-.035-.062H9.2c-.059 0-.073-.026-.043-.077l1.434-2.505a.107.107 0 000-.114L9.225 1.774a.06.06 0 00-.053-.031zm6.29 8.02c.046 0 .058.02.034.06l-.832 1.465-2.613 4.585a.056.056 0 01-.05.029.058.058 0 01-.05-.029L8.498 9.841c-.02-.034-.01-.052.028-.054l.216-.012 6.722-.012z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
BIN
desktop/frontend/src/assets/images/lingma-icon.png
Normal file
|
After Width: | Height: | Size: 96 KiB |
BIN
desktop/frontend/src/assets/images/logo-universal.png
Normal file
|
After Width: | Height: | Size: 136 KiB |
71
desktop/frontend/src/components/HelloWorld.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<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>
|
||||
6
desktop/frontend/src/main.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import {createApp} from 'vue'
|
||||
import App from './App.vue'
|
||||
import './style.css';
|
||||
import 'bootstrap-icons/font/bootstrap-icons.css';
|
||||
|
||||
createApp(App).mount('#app')
|
||||
24
desktop/frontend/src/modelIcons.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import autoIcon from 'bootstrap-icons/icons/shuffle.svg'
|
||||
import claudeIcon from './assets/icons/claude.svg'
|
||||
import gemmaIcon from './assets/icons/gemma.svg'
|
||||
import kimiIcon from './assets/icons/kimi.svg'
|
||||
import lingmaIcon from './assets/images/lingma-icon.png'
|
||||
import minimaxIcon from './assets/icons/minimax.svg'
|
||||
import openaiIcon from './assets/icons/openai.svg'
|
||||
import qwenIcon from './assets/icons/qwen.svg'
|
||||
|
||||
const ICONS = [
|
||||
{ match: ['auto', 'automatic', '自动'], src: autoIcon, color: '#2563eb' },
|
||||
{ match: ['qwen', 'qwq'], src: qwenIcon, color: '#5b6ee1' },
|
||||
{ match: ['kimi', 'moonshot'], src: kimiIcon, color: '#111827' },
|
||||
{ match: ['minimax', 'abab'], src: minimaxIcon, color: '#1677ff' },
|
||||
{ match: ['claude', 'anthropic'], src: claudeIcon, color: '#d97757' },
|
||||
{ match: ['gpt', 'openai'], src: openaiIcon, color: '#10a37f' },
|
||||
{ match: ['gemma', 'google'], src: gemmaIcon, color: '#4285f4' },
|
||||
]
|
||||
|
||||
export function modelIcon(model) {
|
||||
const text = `${model?.id || ''} ${model?.name || ''}`.toLowerCase()
|
||||
const matched = ICONS.find((item) => item.match.some((keyword) => text.includes(keyword)))
|
||||
return matched || { src: lingmaIcon, color: '#2563eb' }
|
||||
}
|
||||
1540
desktop/frontend/src/style.css
Normal file
402
desktop/frontend/src/views/Dashboard.vue
Normal file
@@ -0,0 +1,402 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import {
|
||||
GetModels,
|
||||
GetConfig,
|
||||
GetRequests,
|
||||
GetStatus,
|
||||
QuitApp,
|
||||
RefreshModels,
|
||||
StartProxy,
|
||||
StopProxy,
|
||||
} from '../../wailsjs/go/main/App.js'
|
||||
import { ClipboardSetText } from '../../wailsjs/runtime'
|
||||
import { modelIcon } from '../modelIcons'
|
||||
|
||||
const props = defineProps({
|
||||
shellStatus: {
|
||||
type: Object,
|
||||
default: () => ({ running: false, addr: '', models: 0 }),
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['log', 'status', 'notice', 'open-settings', 'open-requests', 'open-models'])
|
||||
|
||||
const status = ref(props.shellStatus)
|
||||
const models = ref([])
|
||||
const requests = ref([])
|
||||
const health = ref(null)
|
||||
const config = ref({})
|
||||
const loading = ref(false)
|
||||
const testing = ref(false)
|
||||
const now = ref(Date.now())
|
||||
let interval = null
|
||||
let clockInterval = null
|
||||
|
||||
const endpoint = computed(() => (status.value.addr ? `http://${status.value.addr}` : '未启动'))
|
||||
const isRunning = computed(() => Boolean(status.value.running))
|
||||
const runningDuration = computed(() => {
|
||||
if (!isRunning.value || !status.value.startedAt) return '未运行'
|
||||
const startedAt = new Date(status.value.startedAt).getTime()
|
||||
if (!Number.isFinite(startedAt)) return '运行中'
|
||||
const seconds = Math.max(0, Math.floor((now.value - startedAt) / 1000))
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
const rest = seconds % 60
|
||||
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(rest).padStart(2, '0')}`
|
||||
})
|
||||
const parsedDurations = computed(() => requests.value.map((request) => parseDurationMs(request.duration)).filter((value) => value > 0))
|
||||
const healthStats = computed(() => {
|
||||
const values = parsedDurations.value
|
||||
if (values.length === 0) {
|
||||
return { avg: 0, p50: 0, p95: 0, max: 0 }
|
||||
}
|
||||
const sorted = [...values].sort((a, b) => a - b)
|
||||
const avg = Math.round(values.reduce((sum, value) => sum + value, 0) / values.length)
|
||||
return {
|
||||
avg,
|
||||
p50: percentile(sorted, 0.5),
|
||||
p95: percentile(sorted, 0.95),
|
||||
max: sorted[sorted.length - 1],
|
||||
}
|
||||
})
|
||||
const chartBars = computed(() => {
|
||||
const values = parsedDurations.value.slice(0, 36).reverse()
|
||||
if (values.length === 0) return []
|
||||
const max = Math.max(...values)
|
||||
return values.map((value) => Math.max(12, Math.round((value / max) * 100)))
|
||||
})
|
||||
const displayRequests = computed(() => {
|
||||
if (requests.value.length > 0) return requests.value.slice(0, 7)
|
||||
return []
|
||||
})
|
||||
const displayModels = computed(() => {
|
||||
if (models.value.length > 0) {
|
||||
return models.value.slice(0, 5).map((model) => ({ ...model, online: true }))
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
function parseDurationMs(duration) {
|
||||
const text = String(duration || '').trim()
|
||||
if (!text) return 0
|
||||
if (text.endsWith('ms')) return Number.parseFloat(text)
|
||||
if (text.endsWith('s')) return Number.parseFloat(text) * 1000
|
||||
return Number.parseFloat(text) || 0
|
||||
}
|
||||
|
||||
function percentile(sorted, p) {
|
||||
if (sorted.length === 0) return 0
|
||||
const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(sorted.length * p) - 1))
|
||||
return Math.round(sorted[index])
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const nextStatus = await GetStatus()
|
||||
status.value = nextStatus
|
||||
emit('status', nextStatus)
|
||||
requests.value = await GetRequests()
|
||||
config.value = await GetConfig()
|
||||
if (nextStatus.running) {
|
||||
models.value = await GetModels()
|
||||
}
|
||||
} catch (e) {
|
||||
emit('log', 'error', '刷新仪表盘失败:' + (e.message || String(e)))
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshModels() {
|
||||
loading.value = true
|
||||
try {
|
||||
models.value = await RefreshModels()
|
||||
emit('log', 'info', `模型探测完成:${models.value.length} 个`)
|
||||
await refresh()
|
||||
} catch (e) {
|
||||
emit('log', 'error', '模型探测失败:' + (e.message || String(e)) + '。请确认 Lingma 插件已启动并登录;自动探测失败时可到设置页手动填写 WebSocket:ws://127.0.0.1:36510/,或 Windows Named Pipe:\\\\.\\pipe\\lingma-xxxx。')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copyModelName(model) {
|
||||
if (!model?.id) return
|
||||
try {
|
||||
await ClipboardSetText(model.id)
|
||||
emit('notice', `已复制模型 ID:${model.id}`)
|
||||
} catch (e) {
|
||||
try {
|
||||
await navigator.clipboard?.writeText(model.id)
|
||||
emit('notice', `已复制模型 ID:${model.id}`)
|
||||
} catch (fallbackError) {
|
||||
emit('log', 'error', '模型 ID 复制失败:' + (fallbackError.message || String(fallbackError)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleProxy() {
|
||||
loading.value = true
|
||||
try {
|
||||
if (isRunning.value) {
|
||||
await StopProxy()
|
||||
emit('log', 'info', '代理已停止')
|
||||
} else {
|
||||
await StartProxy()
|
||||
emit('log', 'info', '代理已启动')
|
||||
}
|
||||
await refresh()
|
||||
} catch (e) {
|
||||
emit('log', 'error', '代理切换失败:' + (e.message || String(e)))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function restartProxy() {
|
||||
if (!isRunning.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
await StopProxy()
|
||||
await StartProxy()
|
||||
emit('log', 'info', '代理已重启')
|
||||
await refresh()
|
||||
} catch (e) {
|
||||
emit('log', 'error', '代理重启失败:' + (e.message || String(e)))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function testConnection() {
|
||||
if (!isRunning.value || !status.value.addr) {
|
||||
emit('log', 'warn', '代理未运行,无法测试连接')
|
||||
return
|
||||
}
|
||||
testing.value = true
|
||||
try {
|
||||
const resp = await fetch(`${endpoint.value}/health`)
|
||||
const data = await resp.json()
|
||||
health.value = data
|
||||
emit('log', data.ok ? 'info' : 'warn', data.ok ? '健康检查通过' : '健康检查返回异常')
|
||||
} catch (e) {
|
||||
health.value = { ok: false, error: e.message || String(e) }
|
||||
emit('log', 'error', '健康检查失败:' + (e.message || String(e)))
|
||||
} finally {
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function quitApp() {
|
||||
if (confirm('确定退出应用?代理服务会一起停止。')) {
|
||||
await QuitApp()
|
||||
}
|
||||
}
|
||||
|
||||
function statusClass(code) {
|
||||
if (code >= 200 && code < 300) return 'ok'
|
||||
if (code >= 400) return 'err'
|
||||
return 'warn'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refresh()
|
||||
interval = setInterval(refresh, 2500)
|
||||
clockInterval = setInterval(() => {
|
||||
now.value = Date.now()
|
||||
}, 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
clearInterval(interval)
|
||||
clearInterval(clockInterval)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<section class="glass-panel status-strip">
|
||||
<div class="strip-cell">
|
||||
<span class="strip-dot" :class="{ stopped: !isRunning }"></span>
|
||||
<div>
|
||||
<strong>{{ isRunning ? 'Proxy Running' : 'Proxy Stopped' }}</strong>
|
||||
<span>{{ isRunning ? `运行 ${runningDuration}` : runningDuration }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="strip-cell">
|
||||
<label>Endpoint</label>
|
||||
<a href="#" @click.prevent>{{ endpoint }}</a>
|
||||
</div>
|
||||
<div class="strip-cell">
|
||||
<label>Transport</label>
|
||||
<strong>{{ health?.state?.transport || 'WebSocket' }}</strong>
|
||||
</div>
|
||||
<div class="strip-cell">
|
||||
<label>Session</label>
|
||||
<strong>{{ health?.state?.session_mode || 'Reuse' }}</strong>
|
||||
</div>
|
||||
<div class="strip-actions">
|
||||
<button :class="{ active: !isRunning }" type="button" :disabled="loading || isRunning" @click="toggleProxy">启动</button>
|
||||
<button :class="{ active: isRunning }" type="button" :disabled="loading || !isRunning" @click="toggleProxy">停止</button>
|
||||
<button type="button" :disabled="loading || !isRunning" @click="restartProxy">重启</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="dashboard-grid">
|
||||
<div class="glass-panel area-health">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2>Health <span class="muted">(Last 60s)</span></h2>
|
||||
<p>Latency (ms)</p>
|
||||
</div>
|
||||
<span class="status-chip ok">Healthy</span>
|
||||
</div>
|
||||
<div class="activity-chart" aria-label="延迟趋势图">
|
||||
<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>
|
||||
</div>
|
||||
<div class="health-stats">
|
||||
<div><strong>{{ healthStats.avg }}</strong><span>Avg (ms)</span></div>
|
||||
<div><strong>{{ healthStats.p50 }}</strong><span>P50 (ms)</span></div>
|
||||
<div><strong>{{ healthStats.p95 }}</strong><span>P95 (ms)</span></div>
|
||||
<div><strong style="color: #d97706">{{ healthStats.max }}</strong><span>Max (ms)</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-panel area-models model-card">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2>Models</h2>
|
||||
</div>
|
||||
<button class="secondary-button" type="button" :disabled="loading || !isRunning" @click="refreshModels">探测模型</button>
|
||||
</div>
|
||||
<div class="model-card-list hidden-scrollbar">
|
||||
<button
|
||||
v-for="model in displayModels"
|
||||
: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 class="model-name">{{ model.name || model.id }}</div>
|
||||
</div>
|
||||
<span class="status-chip" :class="model.online ? 'ok' : 'warn'">{{ model.online ? 'Online' : 'Offline' }}</span>
|
||||
</button>
|
||||
</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 class="glass-panel area-config">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2>Configuration</h2>
|
||||
</div>
|
||||
<span class="status-chip ok">Valid</span>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div>
|
||||
<div class="cell-main">Host</div>
|
||||
<div class="cell-sub">{{ config.Host || '127.0.0.1' }}</div>
|
||||
</div>
|
||||
<span class="status-chip ok"><i class="bi bi-check"></i></span>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div>
|
||||
<div class="cell-main">Port</div>
|
||||
<div class="cell-sub">{{ config.Port || 8095 }}</div>
|
||||
</div>
|
||||
<span class="status-chip ok"><i class="bi bi-check"></i></span>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div>
|
||||
<div class="cell-main">Transport</div>
|
||||
<div class="cell-sub">{{ config.Transport || 'WebSocket' }}</div>
|
||||
</div>
|
||||
<span class="status-chip ok"><i class="bi bi-check"></i></span>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div>
|
||||
<div class="cell-main">Session</div>
|
||||
<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 class="table-panel area-requests">
|
||||
<div class="table-toolbar">
|
||||
<div>
|
||||
<div class="panel-header" style="margin: 0">
|
||||
<h2>Recent Requests</h2>
|
||||
</div>
|
||||
</div>
|
||||
<button class="secondary-button" type="button" @click="emit('open-requests')">查看全部</button>
|
||||
</div>
|
||||
<div v-if="displayRequests.length > 0" class="table-scroll hidden-scrollbar">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Method</th>
|
||||
<th>Path</th>
|
||||
<th>Model</th>
|
||||
<th>Status</th>
|
||||
<th>Duration</th>
|
||||
<th>Size</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(request, index) in displayRequests" :key="index">
|
||||
<td>{{ request.time }}</td>
|
||||
<td>{{ request.method }}</td>
|
||||
<td>{{ request.path }}</td>
|
||||
<td>{{ request.model || 'Qwen3-Coder' }}</td>
|
||||
<td><span class="status-chip" :class="statusClass(request.statusCode)">{{ request.statusCode }}</span></td>
|
||||
<td>{{ request.duration }}</td>
|
||||
<td>{{ request.size || '2.1 KB' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
96
desktop/frontend/src/views/Logs.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { ClipboardSetText } from '../../wailsjs/runtime'
|
||||
|
||||
const props = defineProps({
|
||||
logs: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['clear', 'notice'])
|
||||
|
||||
const filter = ref('all')
|
||||
const search = ref('')
|
||||
|
||||
const filteredLogs = computed(() => {
|
||||
const q = search.value.trim().toLowerCase()
|
||||
return props.logs.filter((log) => {
|
||||
const matchesLevel = filter.value === 'all' || log.level === filter.value
|
||||
const matchesSearch = !q || `${log.time} ${log.level} ${log.message}`.toLowerCase().includes(q)
|
||||
return matchesLevel && matchesSearch
|
||||
})
|
||||
})
|
||||
|
||||
function levelClass(level) {
|
||||
return {
|
||||
info: 'level-info',
|
||||
warn: 'level-warn',
|
||||
error: 'level-error',
|
||||
}[level] || 'level-info'
|
||||
}
|
||||
|
||||
function levelLabel(level) {
|
||||
return {
|
||||
info: '信息',
|
||||
warn: '警告',
|
||||
error: '错误',
|
||||
}[level] || level
|
||||
}
|
||||
|
||||
function serializeLogs() {
|
||||
return filteredLogs.value.map((log) => `[${log.time}] ${levelLabel(log.level)} ${log.message}`).join('\n')
|
||||
}
|
||||
|
||||
async function copyLogs() {
|
||||
try {
|
||||
await ClipboardSetText(serializeLogs())
|
||||
emit('notice', `已复制 ${filteredLogs.value.length} 条日志`)
|
||||
} catch (e) {
|
||||
try {
|
||||
await navigator.clipboard?.writeText(serializeLogs())
|
||||
emit('notice', `已复制 ${filteredLogs.value.length} 条日志`)
|
||||
} catch (fallbackError) {
|
||||
console.debug('Copy logs failed:', fallbackError)
|
||||
emit('notice', '日志复制失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page logs-page">
|
||||
<div class="page-title">
|
||||
<div>
|
||||
<h1>日志</h1>
|
||||
<p>记录代理启动、模型同步、健康检查和配置保存事件。</p>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<button class="secondary-button" type="button" :disabled="filteredLogs.length === 0" @click="copyLogs">复制日志</button>
|
||||
<button class="danger-button" type="button" @click="emit('clear')">清空日志</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="table-panel logs-panel">
|
||||
<div class="table-toolbar">
|
||||
<div class="segmented">
|
||||
<button :class="{ active: filter === 'all' }" type="button" @click="filter = 'all'">全部</button>
|
||||
<button :class="{ active: filter === 'info' }" type="button" @click="filter = 'info'">信息</button>
|
||||
<button :class="{ active: filter === 'warn' }" type="button" @click="filter = 'warn'">警告</button>
|
||||
<button :class="{ active: filter === 'error' }" type="button" @click="filter = 'error'">错误</button>
|
||||
</div>
|
||||
<input v-model="search" class="search-input" type="search" placeholder="搜索日志内容" />
|
||||
</div>
|
||||
|
||||
<div v-if="filteredLogs.length > 0" class="log-list hidden-scrollbar">
|
||||
<div v-for="(log, index) in filteredLogs" :key="index" class="log-row">
|
||||
<span class="muted">{{ log.time }}</span>
|
||||
<strong :class="levelClass(log.level)">{{ levelLabel(log.level) }}</strong>
|
||||
<span>{{ log.message }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-state">暂无日志。</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
120
desktop/frontend/src/views/Models.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { GetModels, GetStatus, RefreshModels } from '../../wailsjs/go/main/App.js'
|
||||
import { ClipboardSetText } from '../../wailsjs/runtime'
|
||||
import { modelIcon } from '../modelIcons'
|
||||
|
||||
const emit = defineEmits(['log', 'status', 'notice'])
|
||||
|
||||
const models = ref([])
|
||||
const status = ref({ running: false, addr: '', models: 0 })
|
||||
const loading = ref(false)
|
||||
const query = ref('')
|
||||
|
||||
const filtered = computed(() => {
|
||||
const q = query.value.trim().toLowerCase()
|
||||
if (!q) return models.value
|
||||
return models.value.filter((model) => `${model.id} ${model.name}`.toLowerCase().includes(q))
|
||||
})
|
||||
|
||||
function modelTag(model) {
|
||||
const text = `${model.id} ${model.name}`.toLowerCase()
|
||||
if (text.includes('coder')) return '工具优先'
|
||||
if (text.includes('thinking')) return '推理'
|
||||
if (text.includes('kimi')) return '长文本'
|
||||
if (text.includes('minimax')) return '通用'
|
||||
return 'Lingma'
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true
|
||||
try {
|
||||
status.value = await GetStatus()
|
||||
models.value = status.value.running ? await RefreshModels() : await GetModels()
|
||||
emit('log', 'info', `模型列表刷新完成:${models.value.length} 个`)
|
||||
} catch (e) {
|
||||
emit('log', 'error', '模型列表刷新失败:' + (e.message || String(e)) + '。自动探测失败时请到设置页手动填写 WebSocket:ws://127.0.0.1:36510/,或 Windows Named Pipe:\\\\.\\pipe\\lingma-xxxx。')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copyModelName(model) {
|
||||
if (!model?.id) return
|
||||
try {
|
||||
await ClipboardSetText(model.id)
|
||||
emit('notice', `已复制模型 ID:${model.id}`)
|
||||
} catch (e) {
|
||||
try {
|
||||
await navigator.clipboard?.writeText(model.id)
|
||||
emit('notice', `已复制模型 ID:${model.id}`)
|
||||
} catch (fallbackError) {
|
||||
emit('log', 'error', '模型 ID 复制失败:' + (fallbackError.message || String(fallbackError)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(refresh)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-title">
|
||||
<div>
|
||||
<h1>模型</h1>
|
||||
<p>来自 Lingma 插件的可用模型列表,第三方客户端可以直接使用这些 ID。</p>
|
||||
</div>
|
||||
<button class="primary-button" type="button" :disabled="loading" @click="refresh">
|
||||
{{ loading ? '刷新中...' : '刷新模型' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section class="grid-3">
|
||||
<div class="metric">
|
||||
<label>代理状态</label>
|
||||
<strong>{{ status.running ? '运行中' : '未运行' }}</strong>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<label>接口地址</label>
|
||||
<strong>{{ status.addr || '未启动' }}</strong>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<label>模型数量</label>
|
||||
<strong>{{ models.length }}</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="glass-panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2>可用模型</h2>
|
||||
<p>推荐 Claude Code / Cline 优先选择 Qwen3-Coder。</p>
|
||||
</div>
|
||||
<input v-model="query" class="search-input" type="search" placeholder="搜索模型" style="max-width: 260px" />
|
||||
</div>
|
||||
|
||||
<div v-if="filtered.length > 0" class="models-list model-page-list hidden-scrollbar">
|
||||
<button
|
||||
v-for="model in filtered"
|
||||
:key="model.id"
|
||||
class="model-row model-list-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 class="model-name">{{ model.name || model.id }}</div>
|
||||
<div class="model-meta">{{ model.id }}</div>
|
||||
</div>
|
||||
<span class="status-chip" :class="modelTag(model) === '工具优先' ? 'ok' : 'warn'">{{ modelTag(model) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="empty-state">启动代理并刷新后会显示模型。</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
181
desktop/frontend/src/views/Requests.vue
Normal file
@@ -0,0 +1,181 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { ClearRequests, GetRequests } from '../../wailsjs/go/main/App.js'
|
||||
import { ClipboardSetText, EventsOff, EventsOn } from '../../wailsjs/runtime'
|
||||
|
||||
const emit = defineEmits(['notice'])
|
||||
|
||||
const requests = ref([])
|
||||
const selected = ref(null)
|
||||
const query = ref('')
|
||||
const activeStatus = ref('all')
|
||||
|
||||
const filtered = computed(() => {
|
||||
const q = query.value.trim().toLowerCase()
|
||||
return requests.value.filter((request) => {
|
||||
const matchesQuery = !q || `${request.method} ${request.path} ${request.statusCode}`.toLowerCase().includes(q)
|
||||
const code = Number(request.statusCode)
|
||||
const matchesStatus =
|
||||
activeStatus.value === 'all' ||
|
||||
(activeStatus.value === 'ok' && code >= 200 && code < 300) ||
|
||||
(activeStatus.value === 'err' && code >= 400) ||
|
||||
(activeStatus.value === 'warn' && code >= 300 && code < 400)
|
||||
return matchesQuery && matchesStatus
|
||||
})
|
||||
})
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
requests.value = await GetRequests()
|
||||
} catch (e) {
|
||||
console.debug('Wails GetRequests unavailable in browser preview')
|
||||
}
|
||||
}
|
||||
|
||||
async function clear() {
|
||||
try {
|
||||
await ClearRequests()
|
||||
} catch (e) {
|
||||
console.debug('Wails ClearRequests unavailable in browser preview')
|
||||
}
|
||||
requests.value = []
|
||||
selected.value = null
|
||||
}
|
||||
|
||||
function statusClass(code) {
|
||||
if (code >= 200 && code < 300) return 'ok'
|
||||
if (code >= 400) return 'err'
|
||||
return 'warn'
|
||||
}
|
||||
|
||||
function selectRow(index) {
|
||||
selected.value = selected.value === index ? null : index
|
||||
}
|
||||
|
||||
async function writeClipboard(text) {
|
||||
const value = text || ''
|
||||
try {
|
||||
await ClipboardSetText(value)
|
||||
return true
|
||||
} catch (e) {
|
||||
await navigator.clipboard?.writeText(value)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
async function copyText(text, label) {
|
||||
try {
|
||||
await writeClipboard(text)
|
||||
emit('notice', `已复制${label}`)
|
||||
} catch (e) {
|
||||
console.debug('Copy failed:', e)
|
||||
emit('notice', `${label}复制失败`)
|
||||
}
|
||||
}
|
||||
|
||||
function safeEventsOn(name, handler) {
|
||||
try {
|
||||
EventsOn(name, handler)
|
||||
} catch (e) {
|
||||
console.debug('Wails runtime event unavailable:', name)
|
||||
}
|
||||
}
|
||||
|
||||
function safeEventsOff(name) {
|
||||
try {
|
||||
EventsOff(name)
|
||||
} catch (e) {
|
||||
console.debug('Wails runtime event unavailable:', name)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refresh()
|
||||
safeEventsOn('requests:updated', (data) => {
|
||||
requests.value = data || []
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
safeEventsOff('requests:updated')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page requests-page">
|
||||
<div class="page-title">
|
||||
<div>
|
||||
<h1>请求流</h1>
|
||||
<p>查看客户端调用 OpenAI / Anthropic 兼容接口的请求与响应。</p>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<button class="secondary-button" type="button" @click="refresh">刷新</button>
|
||||
<button class="danger-button" type="button" @click="clear">清空</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="table-panel requests-panel">
|
||||
<div class="table-toolbar">
|
||||
<input v-model="query" class="search-input" type="search" placeholder="搜索路径、方法或状态码" />
|
||||
<div class="segmented">
|
||||
<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 === 'warn' }" type="button" @click="activeStatus = 'warn'">跳转</button>
|
||||
<button :class="{ active: activeStatus === 'err' }" type="button" @click="activeStatus = 'err'">错误</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="filtered.length > 0" class="table-scroll hidden-scrollbar">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>方法</th>
|
||||
<th>路径</th>
|
||||
<th>状态</th>
|
||||
<th>耗时</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(request, index) in filtered" :key="index" @click="selectRow(index)">
|
||||
<td>{{ request.time }}</td>
|
||||
<td><span class="method-chip">{{ request.method }}</span></td>
|
||||
<td>
|
||||
<div class="cell-main">{{ request.path }}</div>
|
||||
<div class="cell-sub">{{ request.reqBody ? '包含请求体' : '无请求体' }}</div>
|
||||
</td>
|
||||
<td><span class="status-chip" :class="statusClass(request.statusCode)">{{ request.statusCode }}</span></td>
|
||||
<td>{{ request.duration }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-else class="empty-state">暂无匹配请求。</div>
|
||||
|
||||
<div v-if="selected !== null && filtered[selected]" class="detail-panel hidden-scrollbar">
|
||||
<div class="detail-section">
|
||||
<div class="detail-toolbar">
|
||||
<h3>请求内容</h3>
|
||||
<div class="detail-actions">
|
||||
<button type="button" class="ghost-button" @click="copyText(filtered[selected].reqBody, '请求内容')">
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<pre>{{ filtered[selected].reqBody || '空请求体' }}</pre>
|
||||
</div>
|
||||
<div class="detail-section">
|
||||
<div class="detail-toolbar">
|
||||
<h3>响应内容</h3>
|
||||
<div class="detail-actions">
|
||||
<button type="button" class="ghost-button" @click="copyText(filtered[selected].respBody, '响应内容')">
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<pre>{{ filtered[selected].respBody || '空响应体' }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
218
desktop/frontend/src/views/Settings.vue
Normal file
@@ -0,0 +1,218 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { GetConfig, UpdateConfig } from '../../wailsjs/go/main/App.js'
|
||||
|
||||
const emit = defineEmits(['log', 'status-refresh'])
|
||||
|
||||
const config = ref({})
|
||||
const saving = ref(false)
|
||||
const openSelect = ref('')
|
||||
|
||||
const selectOptions = {
|
||||
Transport: [
|
||||
{ value: 'auto', label: '自动' },
|
||||
{ value: 'pipe', label: '命名管道' },
|
||||
{ value: 'websocket', label: 'WebSocket' },
|
||||
],
|
||||
Mode: [
|
||||
{ value: 'agent', label: 'Agent' },
|
||||
{ value: 'chat', label: 'Chat' },
|
||||
],
|
||||
ShellType: [
|
||||
{ value: 'zsh', label: 'zsh' },
|
||||
{ value: 'bash', label: 'bash' },
|
||||
{ value: 'powershell', label: 'PowerShell' },
|
||||
{ value: 'cmd', label: 'cmd' },
|
||||
],
|
||||
SessionMode: [
|
||||
{ value: 'auto', label: '自动' },
|
||||
{ value: 'reuse', label: '复用' },
|
||||
{ value: 'fresh', label: '每次新建' },
|
||||
],
|
||||
}
|
||||
|
||||
const selectLabel = computed(() => (field) => {
|
||||
const option = selectOptions[field]?.find((item) => item.value === config.value[field])
|
||||
return option?.label || '请选择'
|
||||
})
|
||||
|
||||
function toggleSelect(field) {
|
||||
openSelect.value = openSelect.value === field ? '' : field
|
||||
}
|
||||
|
||||
function chooseOption(field, value) {
|
||||
config.value[field] = value
|
||||
openSelect.value = ''
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
config.value = await GetConfig()
|
||||
} catch (e) {
|
||||
emit('log', 'error', '配置加载失败:' + (e.message || String(e)))
|
||||
}
|
||||
})
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
await UpdateConfig(config.value)
|
||||
emit('log', 'info', '配置已保存,代理已按需重启')
|
||||
emit('status-refresh')
|
||||
} catch (e) {
|
||||
emit('log', 'error', '配置保存失败:' + (e.message || String(e)))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-title">
|
||||
<div>
|
||||
<h1>设置</h1>
|
||||
<p>配置监听地址、Lingma 传输方式、会话复用和请求超时。</p>
|
||||
</div>
|
||||
<button class="primary-button" type="button" :disabled="saving" @click="save">
|
||||
{{ saving ? '保存中...' : '保存并重启' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section class="grid-2">
|
||||
<div class="glass-panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2>服务监听</h2>
|
||||
<p>第三方客户端连接本地代理使用这组地址。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<div class="field">
|
||||
<label>主机</label>
|
||||
<input v-model="config.Host" type="text" placeholder="127.0.0.1" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>端口</label>
|
||||
<input v-model.number="config.Port" type="number" placeholder="8095" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>传输方式</label>
|
||||
<div class="custom-select" :class="{ open: openSelect === 'Transport' }">
|
||||
<button type="button" @click="toggleSelect('Transport')">
|
||||
<span>{{ selectLabel('Transport') }}</span>
|
||||
<i class="bi bi-chevron-down" aria-hidden="true"></i>
|
||||
</button>
|
||||
<div v-if="openSelect === 'Transport'" class="select-menu">
|
||||
<button
|
||||
v-for="option in selectOptions.Transport"
|
||||
:key="option.value"
|
||||
:class="{ selected: option.value === config.Transport }"
|
||||
type="button"
|
||||
@click="chooseOption('Transport', option.value)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>超时秒数</label>
|
||||
<input v-model.number="config.Timeout" type="number" min="1" />
|
||||
</div>
|
||||
<div class="field span-2">
|
||||
<label>WebSocket 地址</label>
|
||||
<input v-model="config.WebSocketURL" type="text" placeholder="留空自动探测 Lingma WebSocket" />
|
||||
</div>
|
||||
<div class="field span-2">
|
||||
<label>命名管道</label>
|
||||
<input v-model="config.Pipe" type="text" placeholder="留空自动探测 Windows Named Pipe" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="hint-box">
|
||||
<strong>自动探测失败时</strong>
|
||||
<span>先确认 VS Code / Lingma 插件已启动并登录。macOS 通常填写 WebSocket,例如 <code>ws://127.0.0.1:36510/</code>;Windows 可填写命名管道,例如 <code>\\.\pipe\lingma-xxxx</code>,也可填写 WebSocket,例如 <code>ws://127.0.0.1:36510/</code>。</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2>会话与环境</h2>
|
||||
<p>影响 Lingma 会话上下文和工具执行环境。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<div class="field">
|
||||
<label>模式</label>
|
||||
<div class="custom-select" :class="{ open: openSelect === 'Mode' }">
|
||||
<button type="button" @click="toggleSelect('Mode')">
|
||||
<span>{{ selectLabel('Mode') }}</span>
|
||||
<i class="bi bi-chevron-down" aria-hidden="true"></i>
|
||||
</button>
|
||||
<div v-if="openSelect === 'Mode'" class="select-menu">
|
||||
<button
|
||||
v-for="option in selectOptions.Mode"
|
||||
:key="option.value"
|
||||
:class="{ selected: option.value === config.Mode }"
|
||||
type="button"
|
||||
@click="chooseOption('Mode', option.value)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Shell 类型</label>
|
||||
<div class="custom-select" :class="{ open: openSelect === 'ShellType' }">
|
||||
<button type="button" @click="toggleSelect('ShellType')">
|
||||
<span>{{ selectLabel('ShellType') }}</span>
|
||||
<i class="bi bi-chevron-down" aria-hidden="true"></i>
|
||||
</button>
|
||||
<div v-if="openSelect === 'ShellType'" class="select-menu">
|
||||
<button
|
||||
v-for="option in selectOptions.ShellType"
|
||||
:key="option.value"
|
||||
:class="{ selected: option.value === config.ShellType }"
|
||||
type="button"
|
||||
@click="chooseOption('ShellType', option.value)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>会话策略</label>
|
||||
<div class="custom-select" :class="{ open: openSelect === 'SessionMode' }">
|
||||
<button type="button" @click="toggleSelect('SessionMode')">
|
||||
<span>{{ selectLabel('SessionMode') }}</span>
|
||||
<i class="bi bi-chevron-down" aria-hidden="true"></i>
|
||||
</button>
|
||||
<div v-if="openSelect === 'SessionMode'" class="select-menu">
|
||||
<button
|
||||
v-for="option in selectOptions.SessionMode"
|
||||
:key="option.value"
|
||||
:class="{ selected: option.value === config.SessionMode }"
|
||||
type="button"
|
||||
@click="chooseOption('SessionMode', option.value)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>当前文件</label>
|
||||
<input v-model="config.CurrentFilePath" type="text" placeholder="可选" />
|
||||
</div>
|
||||
<div class="field span-2">
|
||||
<label>工作目录</label>
|
||||
<textarea v-model="config.Cwd" placeholder="Lingma 创建 session 时使用的 cwd"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
7
desktop/frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type {DefineComponent} from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
30
desktop/frontend/tsconfig.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"sourceMap": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"lib": [
|
||||
"ESNext",
|
||||
"DOM"
|
||||
],
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.d.ts",
|
||||
"src/**/*.tsx",
|
||||
"src/**/*.vue"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
11
desktop/frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": [
|
||||
"vite.config.ts"
|
||||
]
|
||||
}
|
||||
7
desktop/frontend/vite.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import {defineConfig} from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()]
|
||||
})
|
||||
36
desktop/frontend/wailsjs/go/main/App.d.ts
vendored
Executable file
@@ -0,0 +1,36 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
import {service} from '../models';
|
||||
import {main} from '../models';
|
||||
|
||||
export function ClearLogs():Promise<void>;
|
||||
|
||||
export function ClearRequests():Promise<void>;
|
||||
|
||||
export function GetConfig():Promise<service.Config>;
|
||||
|
||||
export function GetModels():Promise<Array<main.ModelInfo>>;
|
||||
|
||||
export function GetRequests():Promise<Array<main.RequestRecord>>;
|
||||
|
||||
export function GetStatus():Promise<main.ProxyStatus>;
|
||||
|
||||
export function HideWindow():Promise<void>;
|
||||
|
||||
export function MinimizeWindow():Promise<void>;
|
||||
|
||||
export function QuitApp():Promise<void>;
|
||||
|
||||
export function RefreshModels():Promise<Array<main.ModelInfo>>;
|
||||
|
||||
export function RequestQuitShortcut():Promise<void>;
|
||||
|
||||
export function SelectModel(arg1:string):Promise<main.ProxyStatus>;
|
||||
|
||||
export function ShowWindow():Promise<void>;
|
||||
|
||||
export function StartProxy():Promise<void>;
|
||||
|
||||
export function StopProxy():Promise<void>;
|
||||
|
||||
export function UpdateConfig(arg1:service.Config):Promise<void>;
|
||||
67
desktop/frontend/wailsjs/go/main/App.js
Executable file
@@ -0,0 +1,67 @@
|
||||
// @ts-check
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
export function ClearLogs() {
|
||||
return window['go']['main']['App']['ClearLogs']();
|
||||
}
|
||||
|
||||
export function ClearRequests() {
|
||||
return window['go']['main']['App']['ClearRequests']();
|
||||
}
|
||||
|
||||
export function GetConfig() {
|
||||
return window['go']['main']['App']['GetConfig']();
|
||||
}
|
||||
|
||||
export function GetModels() {
|
||||
return window['go']['main']['App']['GetModels']();
|
||||
}
|
||||
|
||||
export function GetRequests() {
|
||||
return window['go']['main']['App']['GetRequests']();
|
||||
}
|
||||
|
||||
export function GetStatus() {
|
||||
return window['go']['main']['App']['GetStatus']();
|
||||
}
|
||||
|
||||
export function HideWindow() {
|
||||
return window['go']['main']['App']['HideWindow']();
|
||||
}
|
||||
|
||||
export function MinimizeWindow() {
|
||||
return window['go']['main']['App']['MinimizeWindow']();
|
||||
}
|
||||
|
||||
export function QuitApp() {
|
||||
return window['go']['main']['App']['QuitApp']();
|
||||
}
|
||||
|
||||
export function RefreshModels() {
|
||||
return window['go']['main']['App']['RefreshModels']();
|
||||
}
|
||||
|
||||
export function RequestQuitShortcut() {
|
||||
return window['go']['main']['App']['RequestQuitShortcut']();
|
||||
}
|
||||
|
||||
export function SelectModel(arg1) {
|
||||
return window['go']['main']['App']['SelectModel'](arg1);
|
||||
}
|
||||
|
||||
export function ShowWindow() {
|
||||
return window['go']['main']['App']['ShowWindow']();
|
||||
}
|
||||
|
||||
export function StartProxy() {
|
||||
return window['go']['main']['App']['StartProxy']();
|
||||
}
|
||||
|
||||
export function StopProxy() {
|
||||
return window['go']['main']['App']['StopProxy']();
|
||||
}
|
||||
|
||||
export function UpdateConfig(arg1) {
|
||||
return window['go']['main']['App']['UpdateConfig'](arg1);
|
||||
}
|
||||
101
desktop/frontend/wailsjs/go/models.ts
Executable file
@@ -0,0 +1,101 @@
|
||||
export namespace main {
|
||||
|
||||
export class ModelInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new ModelInfo(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.id = source["id"];
|
||||
this.name = source["name"];
|
||||
}
|
||||
}
|
||||
export class ProxyStatus {
|
||||
running: boolean;
|
||||
addr: string;
|
||||
models: number;
|
||||
model?: string;
|
||||
startedAt?: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new ProxyStatus(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.running = source["running"];
|
||||
this.addr = source["addr"];
|
||||
this.models = source["models"];
|
||||
this.model = source["model"];
|
||||
this.startedAt = source["startedAt"];
|
||||
}
|
||||
}
|
||||
export class RequestRecord {
|
||||
time: string;
|
||||
method: string;
|
||||
path: string;
|
||||
statusCode: number;
|
||||
duration: string;
|
||||
reqBody?: string;
|
||||
respBody?: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new RequestRecord(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.time = source["time"];
|
||||
this.method = source["method"];
|
||||
this.path = source["path"];
|
||||
this.statusCode = source["statusCode"];
|
||||
this.duration = source["duration"];
|
||||
this.reqBody = source["reqBody"];
|
||||
this.respBody = source["respBody"];
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export namespace service {
|
||||
|
||||
export class Config {
|
||||
Host: string;
|
||||
Port: number;
|
||||
Transport: string;
|
||||
Pipe: string;
|
||||
WebSocketURL: string;
|
||||
Cwd: string;
|
||||
CurrentFilePath: string;
|
||||
Mode: string;
|
||||
Model: string;
|
||||
ShellType: string;
|
||||
SessionMode: string;
|
||||
Timeout: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new Config(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.Host = source["Host"];
|
||||
this.Port = source["Port"];
|
||||
this.Transport = source["Transport"];
|
||||
this.Pipe = source["Pipe"];
|
||||
this.WebSocketURL = source["WebSocketURL"];
|
||||
this.Cwd = source["Cwd"];
|
||||
this.CurrentFilePath = source["CurrentFilePath"];
|
||||
this.Mode = source["Mode"];
|
||||
this.Model = source["Model"];
|
||||
this.ShellType = source["ShellType"];
|
||||
this.SessionMode = source["SessionMode"];
|
||||
this.Timeout = source["Timeout"];
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
24
desktop/frontend/wailsjs/runtime/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@wailsapp/runtime",
|
||||
"version": "2.0.0",
|
||||
"description": "Wails Javascript runtime library",
|
||||
"main": "runtime.js",
|
||||
"types": "runtime.d.ts",
|
||||
"scripts": {
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/wailsapp/wails.git"
|
||||
},
|
||||
"keywords": [
|
||||
"Wails",
|
||||
"Javascript",
|
||||
"Go"
|
||||
],
|
||||
"author": "Lea Anthony <lea.anthony@gmail.com>",
|
||||
"license": "MIT",
|
||||
"bugs": {
|
||||
"url": "https://github.com/wailsapp/wails/issues"
|
||||
},
|
||||
"homepage": "https://github.com/wailsapp/wails#readme"
|
||||
}
|
||||
330
desktop/frontend/wailsjs/runtime/runtime.d.ts
vendored
Normal file
@@ -0,0 +1,330 @@
|
||||
/*
|
||||
_ __ _ __
|
||||
| | / /___ _(_) /____
|
||||
| | /| / / __ `/ / / ___/
|
||||
| |/ |/ / /_/ / / (__ )
|
||||
|__/|__/\__,_/_/_/____/
|
||||
The electron alternative for Go
|
||||
(c) Lea Anthony 2019-present
|
||||
*/
|
||||
|
||||
export interface Position {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface Size {
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
export interface Screen {
|
||||
isCurrent: boolean;
|
||||
isPrimary: boolean;
|
||||
width : number
|
||||
height : number
|
||||
}
|
||||
|
||||
// Environment information such as platform, buildtype, ...
|
||||
export interface EnvironmentInfo {
|
||||
buildType: string;
|
||||
platform: string;
|
||||
arch: string;
|
||||
}
|
||||
|
||||
// [EventsEmit](https://wails.io/docs/reference/runtime/events#eventsemit)
|
||||
// emits the given event. Optional data may be passed with the event.
|
||||
// This will trigger any event listeners.
|
||||
export function EventsEmit(eventName: string, ...data: any): void;
|
||||
|
||||
// [EventsOn](https://wails.io/docs/reference/runtime/events#eventson) sets up a listener for the given event name.
|
||||
export function EventsOn(eventName: string, callback: (...data: any) => void): () => void;
|
||||
|
||||
// [EventsOnMultiple](https://wails.io/docs/reference/runtime/events#eventsonmultiple)
|
||||
// sets up a listener for the given event name, but will only trigger a given number times.
|
||||
export function EventsOnMultiple(eventName: string, callback: (...data: any) => void, maxCallbacks: number): () => void;
|
||||
|
||||
// [EventsOnce](https://wails.io/docs/reference/runtime/events#eventsonce)
|
||||
// sets up a listener for the given event name, but will only trigger once.
|
||||
export function EventsOnce(eventName: string, callback: (...data: any) => void): () => void;
|
||||
|
||||
// [EventsOff](https://wails.io/docs/reference/runtime/events#eventsoff)
|
||||
// unregisters the listener for the given event name.
|
||||
export function EventsOff(eventName: string, ...additionalEventNames: string[]): void;
|
||||
|
||||
// [EventsOffAll](https://wails.io/docs/reference/runtime/events#eventsoffall)
|
||||
// unregisters all listeners.
|
||||
export function EventsOffAll(): void;
|
||||
|
||||
// [LogPrint](https://wails.io/docs/reference/runtime/log#logprint)
|
||||
// logs the given message as a raw message
|
||||
export function LogPrint(message: string): void;
|
||||
|
||||
// [LogTrace](https://wails.io/docs/reference/runtime/log#logtrace)
|
||||
// logs the given message at the `trace` log level.
|
||||
export function LogTrace(message: string): void;
|
||||
|
||||
// [LogDebug](https://wails.io/docs/reference/runtime/log#logdebug)
|
||||
// logs the given message at the `debug` log level.
|
||||
export function LogDebug(message: string): void;
|
||||
|
||||
// [LogError](https://wails.io/docs/reference/runtime/log#logerror)
|
||||
// logs the given message at the `error` log level.
|
||||
export function LogError(message: string): void;
|
||||
|
||||
// [LogFatal](https://wails.io/docs/reference/runtime/log#logfatal)
|
||||
// logs the given message at the `fatal` log level.
|
||||
// The application will quit after calling this method.
|
||||
export function LogFatal(message: string): void;
|
||||
|
||||
// [LogInfo](https://wails.io/docs/reference/runtime/log#loginfo)
|
||||
// logs the given message at the `info` log level.
|
||||
export function LogInfo(message: string): void;
|
||||
|
||||
// [LogWarning](https://wails.io/docs/reference/runtime/log#logwarning)
|
||||
// logs the given message at the `warning` log level.
|
||||
export function LogWarning(message: string): void;
|
||||
|
||||
// [WindowReload](https://wails.io/docs/reference/runtime/window#windowreload)
|
||||
// Forces a reload by the main application as well as connected browsers.
|
||||
export function WindowReload(): void;
|
||||
|
||||
// [WindowReloadApp](https://wails.io/docs/reference/runtime/window#windowreloadapp)
|
||||
// Reloads the application frontend.
|
||||
export function WindowReloadApp(): void;
|
||||
|
||||
// [WindowSetAlwaysOnTop](https://wails.io/docs/reference/runtime/window#windowsetalwaysontop)
|
||||
// Sets the window AlwaysOnTop or not on top.
|
||||
export function WindowSetAlwaysOnTop(b: boolean): void;
|
||||
|
||||
// [WindowSetSystemDefaultTheme](https://wails.io/docs/next/reference/runtime/window#windowsetsystemdefaulttheme)
|
||||
// *Windows only*
|
||||
// Sets window theme to system default (dark/light).
|
||||
export function WindowSetSystemDefaultTheme(): void;
|
||||
|
||||
// [WindowSetLightTheme](https://wails.io/docs/next/reference/runtime/window#windowsetlighttheme)
|
||||
// *Windows only*
|
||||
// Sets window to light theme.
|
||||
export function WindowSetLightTheme(): void;
|
||||
|
||||
// [WindowSetDarkTheme](https://wails.io/docs/next/reference/runtime/window#windowsetdarktheme)
|
||||
// *Windows only*
|
||||
// Sets window to dark theme.
|
||||
export function WindowSetDarkTheme(): void;
|
||||
|
||||
// [WindowCenter](https://wails.io/docs/reference/runtime/window#windowcenter)
|
||||
// Centers the window on the monitor the window is currently on.
|
||||
export function WindowCenter(): void;
|
||||
|
||||
// [WindowSetTitle](https://wails.io/docs/reference/runtime/window#windowsettitle)
|
||||
// Sets the text in the window title bar.
|
||||
export function WindowSetTitle(title: string): void;
|
||||
|
||||
// [WindowFullscreen](https://wails.io/docs/reference/runtime/window#windowfullscreen)
|
||||
// Makes the window full screen.
|
||||
export function WindowFullscreen(): void;
|
||||
|
||||
// [WindowUnfullscreen](https://wails.io/docs/reference/runtime/window#windowunfullscreen)
|
||||
// Restores the previous window dimensions and position prior to full screen.
|
||||
export function WindowUnfullscreen(): void;
|
||||
|
||||
// [WindowIsFullscreen](https://wails.io/docs/reference/runtime/window#windowisfullscreen)
|
||||
// Returns the state of the window, i.e. whether the window is in full screen mode or not.
|
||||
export function WindowIsFullscreen(): Promise<boolean>;
|
||||
|
||||
// [WindowSetSize](https://wails.io/docs/reference/runtime/window#windowsetsize)
|
||||
// Sets the width and height of the window.
|
||||
export function WindowSetSize(width: number, height: number): void;
|
||||
|
||||
// [WindowGetSize](https://wails.io/docs/reference/runtime/window#windowgetsize)
|
||||
// Gets the width and height of the window.
|
||||
export function WindowGetSize(): Promise<Size>;
|
||||
|
||||
// [WindowSetMaxSize](https://wails.io/docs/reference/runtime/window#windowsetmaxsize)
|
||||
// Sets the maximum window size. Will resize the window if the window is currently larger than the given dimensions.
|
||||
// Setting a size of 0,0 will disable this constraint.
|
||||
export function WindowSetMaxSize(width: number, height: number): void;
|
||||
|
||||
// [WindowSetMinSize](https://wails.io/docs/reference/runtime/window#windowsetminsize)
|
||||
// Sets the minimum window size. Will resize the window if the window is currently smaller than the given dimensions.
|
||||
// Setting a size of 0,0 will disable this constraint.
|
||||
export function WindowSetMinSize(width: number, height: number): void;
|
||||
|
||||
// [WindowSetPosition](https://wails.io/docs/reference/runtime/window#windowsetposition)
|
||||
// Sets the window position relative to the monitor the window is currently on.
|
||||
export function WindowSetPosition(x: number, y: number): void;
|
||||
|
||||
// [WindowGetPosition](https://wails.io/docs/reference/runtime/window#windowgetposition)
|
||||
// Gets the window position relative to the monitor the window is currently on.
|
||||
export function WindowGetPosition(): Promise<Position>;
|
||||
|
||||
// [WindowHide](https://wails.io/docs/reference/runtime/window#windowhide)
|
||||
// Hides the window.
|
||||
export function WindowHide(): void;
|
||||
|
||||
// [WindowShow](https://wails.io/docs/reference/runtime/window#windowshow)
|
||||
// Shows the window, if it is currently hidden.
|
||||
export function WindowShow(): void;
|
||||
|
||||
// [WindowMaximise](https://wails.io/docs/reference/runtime/window#windowmaximise)
|
||||
// Maximises the window to fill the screen.
|
||||
export function WindowMaximise(): void;
|
||||
|
||||
// [WindowToggleMaximise](https://wails.io/docs/reference/runtime/window#windowtogglemaximise)
|
||||
// Toggles between Maximised and UnMaximised.
|
||||
export function WindowToggleMaximise(): void;
|
||||
|
||||
// [WindowUnmaximise](https://wails.io/docs/reference/runtime/window#windowunmaximise)
|
||||
// Restores the window to the dimensions and position prior to maximising.
|
||||
export function WindowUnmaximise(): void;
|
||||
|
||||
// [WindowIsMaximised](https://wails.io/docs/reference/runtime/window#windowismaximised)
|
||||
// Returns the state of the window, i.e. whether the window is maximised or not.
|
||||
export function WindowIsMaximised(): Promise<boolean>;
|
||||
|
||||
// [WindowMinimise](https://wails.io/docs/reference/runtime/window#windowminimise)
|
||||
// Minimises the window.
|
||||
export function WindowMinimise(): void;
|
||||
|
||||
// [WindowUnminimise](https://wails.io/docs/reference/runtime/window#windowunminimise)
|
||||
// Restores the window to the dimensions and position prior to minimising.
|
||||
export function WindowUnminimise(): void;
|
||||
|
||||
// [WindowIsMinimised](https://wails.io/docs/reference/runtime/window#windowisminimised)
|
||||
// Returns the state of the window, i.e. whether the window is minimised or not.
|
||||
export function WindowIsMinimised(): Promise<boolean>;
|
||||
|
||||
// [WindowIsNormal](https://wails.io/docs/reference/runtime/window#windowisnormal)
|
||||
// Returns the state of the window, i.e. whether the window is normal or not.
|
||||
export function WindowIsNormal(): Promise<boolean>;
|
||||
|
||||
// [WindowSetBackgroundColour](https://wails.io/docs/reference/runtime/window#windowsetbackgroundcolour)
|
||||
// Sets the background colour of the window to the given RGBA colour definition. This colour will show through for all transparent pixels.
|
||||
export function WindowSetBackgroundColour(R: number, G: number, B: number, A: number): void;
|
||||
|
||||
// [ScreenGetAll](https://wails.io/docs/reference/runtime/window#screengetall)
|
||||
// Gets the all screens. Call this anew each time you want to refresh data from the underlying windowing system.
|
||||
export function ScreenGetAll(): Promise<Screen[]>;
|
||||
|
||||
// [BrowserOpenURL](https://wails.io/docs/reference/runtime/browser#browseropenurl)
|
||||
// Opens the given URL in the system browser.
|
||||
export function BrowserOpenURL(url: string): void;
|
||||
|
||||
// [Environment](https://wails.io/docs/reference/runtime/intro#environment)
|
||||
// Returns information about the environment
|
||||
export function Environment(): Promise<EnvironmentInfo>;
|
||||
|
||||
// [Quit](https://wails.io/docs/reference/runtime/intro#quit)
|
||||
// Quits the application.
|
||||
export function Quit(): void;
|
||||
|
||||
// [Hide](https://wails.io/docs/reference/runtime/intro#hide)
|
||||
// Hides the application.
|
||||
export function Hide(): void;
|
||||
|
||||
// [Show](https://wails.io/docs/reference/runtime/intro#show)
|
||||
// Shows the application.
|
||||
export function Show(): void;
|
||||
|
||||
// [ClipboardGetText](https://wails.io/docs/reference/runtime/clipboard#clipboardgettext)
|
||||
// Returns the current text stored on clipboard
|
||||
export function ClipboardGetText(): Promise<string>;
|
||||
|
||||
// [ClipboardSetText](https://wails.io/docs/reference/runtime/clipboard#clipboardsettext)
|
||||
// Sets a text on the clipboard
|
||||
export function ClipboardSetText(text: string): Promise<boolean>;
|
||||
|
||||
// [OnFileDrop](https://wails.io/docs/reference/runtime/draganddrop#onfiledrop)
|
||||
// OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
|
||||
export function OnFileDrop(callback: (x: number, y: number ,paths: string[]) => void, useDropTarget: boolean) :void
|
||||
|
||||
// [OnFileDropOff](https://wails.io/docs/reference/runtime/draganddrop#dragandddropoff)
|
||||
// OnFileDropOff removes the drag and drop listeners and handlers.
|
||||
export function OnFileDropOff() :void
|
||||
|
||||
// Check if the file path resolver is available
|
||||
export function CanResolveFilePaths(): boolean;
|
||||
|
||||
// Resolves file paths for an array of files
|
||||
export function ResolveFilePaths(files: File[]): void
|
||||
|
||||
// Notification types
|
||||
export interface NotificationOptions {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle?: string; // macOS and Linux only
|
||||
body?: string;
|
||||
categoryId?: string;
|
||||
data?: { [key: string]: any };
|
||||
}
|
||||
|
||||
export interface NotificationAction {
|
||||
id?: string;
|
||||
title?: string;
|
||||
destructive?: boolean; // macOS-specific
|
||||
}
|
||||
|
||||
export interface NotificationCategory {
|
||||
id?: string;
|
||||
actions?: NotificationAction[];
|
||||
hasReplyField?: boolean;
|
||||
replyPlaceholder?: string;
|
||||
replyButtonTitle?: string;
|
||||
}
|
||||
|
||||
// [InitializeNotifications](https://wails.io/docs/reference/runtime/notification#initializenotifications)
|
||||
// Initializes the notification service for the application.
|
||||
// This must be called before sending any notifications.
|
||||
export function InitializeNotifications(): Promise<void>;
|
||||
|
||||
// [CleanupNotifications](https://wails.io/docs/reference/runtime/notification#cleanupnotifications)
|
||||
// Cleans up notification resources and releases any held connections.
|
||||
export function CleanupNotifications(): Promise<void>;
|
||||
|
||||
// [IsNotificationAvailable](https://wails.io/docs/reference/runtime/notification#isnotificationavailable)
|
||||
// Checks if notifications are available on the current platform.
|
||||
export function IsNotificationAvailable(): Promise<boolean>;
|
||||
|
||||
// [RequestNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#requestnotificationauthorization)
|
||||
// Requests notification authorization from the user (macOS only).
|
||||
export function RequestNotificationAuthorization(): Promise<boolean>;
|
||||
|
||||
// [CheckNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#checknotificationauthorization)
|
||||
// Checks the current notification authorization status (macOS only).
|
||||
export function CheckNotificationAuthorization(): Promise<boolean>;
|
||||
|
||||
// [SendNotification](https://wails.io/docs/reference/runtime/notification#sendnotification)
|
||||
// Sends a basic notification with the given options.
|
||||
export function SendNotification(options: NotificationOptions): Promise<void>;
|
||||
|
||||
// [SendNotificationWithActions](https://wails.io/docs/reference/runtime/notification#sendnotificationwithactions)
|
||||
// Sends a notification with action buttons. Requires a registered category.
|
||||
export function SendNotificationWithActions(options: NotificationOptions): Promise<void>;
|
||||
|
||||
// [RegisterNotificationCategory](https://wails.io/docs/reference/runtime/notification#registernotificationcategory)
|
||||
// Registers a notification category that can be used with SendNotificationWithActions.
|
||||
export function RegisterNotificationCategory(category: NotificationCategory): Promise<void>;
|
||||
|
||||
// [RemoveNotificationCategory](https://wails.io/docs/reference/runtime/notification#removenotificationcategory)
|
||||
// Removes a previously registered notification category.
|
||||
export function RemoveNotificationCategory(categoryId: string): Promise<void>;
|
||||
|
||||
// [RemoveAllPendingNotifications](https://wails.io/docs/reference/runtime/notification#removeallpendingnotifications)
|
||||
// Removes all pending notifications from the notification center.
|
||||
export function RemoveAllPendingNotifications(): Promise<void>;
|
||||
|
||||
// [RemovePendingNotification](https://wails.io/docs/reference/runtime/notification#removependingnotification)
|
||||
// Removes a specific pending notification by its identifier.
|
||||
export function RemovePendingNotification(identifier: string): Promise<void>;
|
||||
|
||||
// [RemoveAllDeliveredNotifications](https://wails.io/docs/reference/runtime/notification#removealldeliverednotifications)
|
||||
// Removes all delivered notifications from the notification center.
|
||||
export function RemoveAllDeliveredNotifications(): Promise<void>;
|
||||
|
||||
// [RemoveDeliveredNotification](https://wails.io/docs/reference/runtime/notification#removedeliverednotification)
|
||||
// Removes a specific delivered notification by its identifier.
|
||||
export function RemoveDeliveredNotification(identifier: string): Promise<void>;
|
||||
|
||||
// [RemoveNotification](https://wails.io/docs/reference/runtime/notification#removenotification)
|
||||
// Removes a notification by its identifier (cross-platform convenience function).
|
||||
export function RemoveNotification(identifier: string): Promise<void>;
|
||||
298
desktop/frontend/wailsjs/runtime/runtime.js
Normal file
@@ -0,0 +1,298 @@
|
||||
/*
|
||||
_ __ _ __
|
||||
| | / /___ _(_) /____
|
||||
| | /| / / __ `/ / / ___/
|
||||
| |/ |/ / /_/ / / (__ )
|
||||
|__/|__/\__,_/_/_/____/
|
||||
The electron alternative for Go
|
||||
(c) Lea Anthony 2019-present
|
||||
*/
|
||||
|
||||
export function LogPrint(message) {
|
||||
window.runtime.LogPrint(message);
|
||||
}
|
||||
|
||||
export function LogTrace(message) {
|
||||
window.runtime.LogTrace(message);
|
||||
}
|
||||
|
||||
export function LogDebug(message) {
|
||||
window.runtime.LogDebug(message);
|
||||
}
|
||||
|
||||
export function LogInfo(message) {
|
||||
window.runtime.LogInfo(message);
|
||||
}
|
||||
|
||||
export function LogWarning(message) {
|
||||
window.runtime.LogWarning(message);
|
||||
}
|
||||
|
||||
export function LogError(message) {
|
||||
window.runtime.LogError(message);
|
||||
}
|
||||
|
||||
export function LogFatal(message) {
|
||||
window.runtime.LogFatal(message);
|
||||
}
|
||||
|
||||
export function EventsOnMultiple(eventName, callback, maxCallbacks) {
|
||||
return window.runtime.EventsOnMultiple(eventName, callback, maxCallbacks);
|
||||
}
|
||||
|
||||
export function EventsOn(eventName, callback) {
|
||||
return EventsOnMultiple(eventName, callback, -1);
|
||||
}
|
||||
|
||||
export function EventsOff(eventName, ...additionalEventNames) {
|
||||
return window.runtime.EventsOff(eventName, ...additionalEventNames);
|
||||
}
|
||||
|
||||
export function EventsOffAll() {
|
||||
return window.runtime.EventsOffAll();
|
||||
}
|
||||
|
||||
export function EventsOnce(eventName, callback) {
|
||||
return EventsOnMultiple(eventName, callback, 1);
|
||||
}
|
||||
|
||||
export function EventsEmit(eventName) {
|
||||
let args = [eventName].slice.call(arguments);
|
||||
return window.runtime.EventsEmit.apply(null, args);
|
||||
}
|
||||
|
||||
export function WindowReload() {
|
||||
window.runtime.WindowReload();
|
||||
}
|
||||
|
||||
export function WindowReloadApp() {
|
||||
window.runtime.WindowReloadApp();
|
||||
}
|
||||
|
||||
export function WindowSetAlwaysOnTop(b) {
|
||||
window.runtime.WindowSetAlwaysOnTop(b);
|
||||
}
|
||||
|
||||
export function WindowSetSystemDefaultTheme() {
|
||||
window.runtime.WindowSetSystemDefaultTheme();
|
||||
}
|
||||
|
||||
export function WindowSetLightTheme() {
|
||||
window.runtime.WindowSetLightTheme();
|
||||
}
|
||||
|
||||
export function WindowSetDarkTheme() {
|
||||
window.runtime.WindowSetDarkTheme();
|
||||
}
|
||||
|
||||
export function WindowCenter() {
|
||||
window.runtime.WindowCenter();
|
||||
}
|
||||
|
||||
export function WindowSetTitle(title) {
|
||||
window.runtime.WindowSetTitle(title);
|
||||
}
|
||||
|
||||
export function WindowFullscreen() {
|
||||
window.runtime.WindowFullscreen();
|
||||
}
|
||||
|
||||
export function WindowUnfullscreen() {
|
||||
window.runtime.WindowUnfullscreen();
|
||||
}
|
||||
|
||||
export function WindowIsFullscreen() {
|
||||
return window.runtime.WindowIsFullscreen();
|
||||
}
|
||||
|
||||
export function WindowGetSize() {
|
||||
return window.runtime.WindowGetSize();
|
||||
}
|
||||
|
||||
export function WindowSetSize(width, height) {
|
||||
window.runtime.WindowSetSize(width, height);
|
||||
}
|
||||
|
||||
export function WindowSetMaxSize(width, height) {
|
||||
window.runtime.WindowSetMaxSize(width, height);
|
||||
}
|
||||
|
||||
export function WindowSetMinSize(width, height) {
|
||||
window.runtime.WindowSetMinSize(width, height);
|
||||
}
|
||||
|
||||
export function WindowSetPosition(x, y) {
|
||||
window.runtime.WindowSetPosition(x, y);
|
||||
}
|
||||
|
||||
export function WindowGetPosition() {
|
||||
return window.runtime.WindowGetPosition();
|
||||
}
|
||||
|
||||
export function WindowHide() {
|
||||
window.runtime.WindowHide();
|
||||
}
|
||||
|
||||
export function WindowShow() {
|
||||
window.runtime.WindowShow();
|
||||
}
|
||||
|
||||
export function WindowMaximise() {
|
||||
window.runtime.WindowMaximise();
|
||||
}
|
||||
|
||||
export function WindowToggleMaximise() {
|
||||
window.runtime.WindowToggleMaximise();
|
||||
}
|
||||
|
||||
export function WindowUnmaximise() {
|
||||
window.runtime.WindowUnmaximise();
|
||||
}
|
||||
|
||||
export function WindowIsMaximised() {
|
||||
return window.runtime.WindowIsMaximised();
|
||||
}
|
||||
|
||||
export function WindowMinimise() {
|
||||
window.runtime.WindowMinimise();
|
||||
}
|
||||
|
||||
export function WindowUnminimise() {
|
||||
window.runtime.WindowUnminimise();
|
||||
}
|
||||
|
||||
export function WindowSetBackgroundColour(R, G, B, A) {
|
||||
window.runtime.WindowSetBackgroundColour(R, G, B, A);
|
||||
}
|
||||
|
||||
export function ScreenGetAll() {
|
||||
return window.runtime.ScreenGetAll();
|
||||
}
|
||||
|
||||
export function WindowIsMinimised() {
|
||||
return window.runtime.WindowIsMinimised();
|
||||
}
|
||||
|
||||
export function WindowIsNormal() {
|
||||
return window.runtime.WindowIsNormal();
|
||||
}
|
||||
|
||||
export function BrowserOpenURL(url) {
|
||||
window.runtime.BrowserOpenURL(url);
|
||||
}
|
||||
|
||||
export function Environment() {
|
||||
return window.runtime.Environment();
|
||||
}
|
||||
|
||||
export function Quit() {
|
||||
window.runtime.Quit();
|
||||
}
|
||||
|
||||
export function Hide() {
|
||||
window.runtime.Hide();
|
||||
}
|
||||
|
||||
export function Show() {
|
||||
window.runtime.Show();
|
||||
}
|
||||
|
||||
export function ClipboardGetText() {
|
||||
return window.runtime.ClipboardGetText();
|
||||
}
|
||||
|
||||
export function ClipboardSetText(text) {
|
||||
return window.runtime.ClipboardSetText(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
|
||||
*
|
||||
* @export
|
||||
* @callback OnFileDropCallback
|
||||
* @param {number} x - x coordinate of the drop
|
||||
* @param {number} y - y coordinate of the drop
|
||||
* @param {string[]} paths - A list of file paths.
|
||||
*/
|
||||
|
||||
/**
|
||||
* OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
|
||||
*
|
||||
* @export
|
||||
* @param {OnFileDropCallback} callback - Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
|
||||
* @param {boolean} [useDropTarget=true] - Only call the callback when the drop finished on an element that has the drop target style. (--wails-drop-target)
|
||||
*/
|
||||
export function OnFileDrop(callback, useDropTarget) {
|
||||
return window.runtime.OnFileDrop(callback, useDropTarget);
|
||||
}
|
||||
|
||||
/**
|
||||
* OnFileDropOff removes the drag and drop listeners and handlers.
|
||||
*/
|
||||
export function OnFileDropOff() {
|
||||
return window.runtime.OnFileDropOff();
|
||||
}
|
||||
|
||||
export function CanResolveFilePaths() {
|
||||
return window.runtime.CanResolveFilePaths();
|
||||
}
|
||||
|
||||
export function ResolveFilePaths(files) {
|
||||
return window.runtime.ResolveFilePaths(files);
|
||||
}
|
||||
|
||||
export function InitializeNotifications() {
|
||||
return window.runtime.InitializeNotifications();
|
||||
}
|
||||
|
||||
export function CleanupNotifications() {
|
||||
return window.runtime.CleanupNotifications();
|
||||
}
|
||||
|
||||
export function IsNotificationAvailable() {
|
||||
return window.runtime.IsNotificationAvailable();
|
||||
}
|
||||
|
||||
export function RequestNotificationAuthorization() {
|
||||
return window.runtime.RequestNotificationAuthorization();
|
||||
}
|
||||
|
||||
export function CheckNotificationAuthorization() {
|
||||
return window.runtime.CheckNotificationAuthorization();
|
||||
}
|
||||
|
||||
export function SendNotification(options) {
|
||||
return window.runtime.SendNotification(options);
|
||||
}
|
||||
|
||||
export function SendNotificationWithActions(options) {
|
||||
return window.runtime.SendNotificationWithActions(options);
|
||||
}
|
||||
|
||||
export function RegisterNotificationCategory(category) {
|
||||
return window.runtime.RegisterNotificationCategory(category);
|
||||
}
|
||||
|
||||
export function RemoveNotificationCategory(categoryId) {
|
||||
return window.runtime.RemoveNotificationCategory(categoryId);
|
||||
}
|
||||
|
||||
export function RemoveAllPendingNotifications() {
|
||||
return window.runtime.RemoveAllPendingNotifications();
|
||||
}
|
||||
|
||||
export function RemovePendingNotification(identifier) {
|
||||
return window.runtime.RemovePendingNotification(identifier);
|
||||
}
|
||||
|
||||
export function RemoveAllDeliveredNotifications() {
|
||||
return window.runtime.RemoveAllDeliveredNotifications();
|
||||
}
|
||||
|
||||
export function RemoveDeliveredNotification(identifier) {
|
||||
return window.runtime.RemoveDeliveredNotification(identifier);
|
||||
}
|
||||
|
||||
export function RemoveNotification(identifier) {
|
||||
return window.runtime.RemoveNotification(identifier);
|
||||
}
|
||||
79
desktop/go.sum
Normal file
@@ -0,0 +1,79 @@
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
|
||||
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
|
||||
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
|
||||
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
||||
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
|
||||
github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
|
||||
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
|
||||
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
|
||||
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
|
||||
github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
|
||||
github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
|
||||
github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
|
||||
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
|
||||
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
|
||||
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
|
||||
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
|
||||
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||
github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58=
|
||||
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
|
||||
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
|
||||
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
|
||||
github.com/wailsapp/wails/v2 v2.6.0 h1:EyH0zR/EO6dDiqNy8qU5spaXDfkluiq77xrkabPYD4c=
|
||||
github.com/wailsapp/wails/v2 v2.6.0/go.mod h1:WBG9KKWuw0FKfoepBrr/vRlyTmHaMibWesK3yz6nNiM=
|
||||
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
|
||||
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
||||
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
100
desktop/main.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
goruntime "runtime"
|
||||
|
||||
"github.com/wailsapp/wails/v2"
|
||||
"github.com/wailsapp/wails/v2/pkg/menu"
|
||||
"github.com/wailsapp/wails/v2/pkg/menu/keys"
|
||||
"github.com/wailsapp/wails/v2/pkg/options"
|
||||
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
|
||||
"github.com/wailsapp/wails/v2/pkg/options/mac"
|
||||
)
|
||||
|
||||
//go:embed all:frontend/dist
|
||||
var assets embed.FS
|
||||
|
||||
func main() {
|
||||
app := NewApp()
|
||||
|
||||
err := wails.Run(&options.App{
|
||||
Title: "Lingma IPC Proxy",
|
||||
Width: 1100,
|
||||
Height: 750,
|
||||
MinWidth: 900,
|
||||
MinHeight: 600,
|
||||
HideWindowOnClose: true,
|
||||
AssetServer: &assetserver.Options{
|
||||
Assets: assets,
|
||||
},
|
||||
BackgroundColour: &options.RGBA{R: 15, G: 23, B: 42, A: 1},
|
||||
Menu: appMenu(app),
|
||||
OnStartup: app.startup,
|
||||
OnBeforeClose: app.beforeClose,
|
||||
OnDomReady: app.onDomReady,
|
||||
SingleInstanceLock: &options.SingleInstanceLock{
|
||||
UniqueId: "lingma-ipc-proxy-desktop",
|
||||
OnSecondInstanceLaunch: app.onSecondInstanceLaunch,
|
||||
},
|
||||
Bind: []interface{}{
|
||||
app,
|
||||
},
|
||||
Frameless: false,
|
||||
Mac: &mac.Options{
|
||||
TitleBar: &mac.TitleBar{
|
||||
TitlebarAppearsTransparent: false,
|
||||
HideTitle: false,
|
||||
HideTitleBar: false,
|
||||
FullSizeContent: false,
|
||||
UseToolbar: false,
|
||||
HideToolbarSeparator: true,
|
||||
},
|
||||
About: &mac.AboutInfo{
|
||||
Title: "Lingma IPC Proxy",
|
||||
Message: "A desktop GUI for lingma-ipc-proxy",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
println("Error:", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func appMenu(app *App) *menu.Menu {
|
||||
quitAccelerator := keys.OptionOrAlt("f4")
|
||||
closeWindowAccelerator := keys.CmdOrCtrl("w")
|
||||
minimizeWindowAccelerator := keys.CmdOrCtrl("m")
|
||||
if goruntime.GOOS == "darwin" {
|
||||
quitAccelerator = keys.CmdOrCtrl("q")
|
||||
closeWindowAccelerator = keys.CmdOrCtrl("w")
|
||||
minimizeWindowAccelerator = keys.CmdOrCtrl("m")
|
||||
}
|
||||
|
||||
appMenu := menu.NewMenu()
|
||||
appMenu.AddText("关闭窗口", closeWindowAccelerator, func(_ *menu.CallbackData) {
|
||||
app.HideWindow()
|
||||
})
|
||||
appMenu.AddText("最小化窗口", minimizeWindowAccelerator, func(_ *menu.CallbackData) {
|
||||
app.MinimizeWindow()
|
||||
})
|
||||
appMenu.AddSeparator()
|
||||
appMenu.AddText("退出 Lingma IPC Proxy", quitAccelerator, func(_ *menu.CallbackData) {
|
||||
app.RequestQuitShortcut()
|
||||
})
|
||||
|
||||
editMenu := menu.NewMenu()
|
||||
editMenu.AddText("撤销", keys.CmdOrCtrl("z"), func(_ *menu.CallbackData) {})
|
||||
editMenu.AddText("重做", keys.CmdOrCtrl("shift+z"), func(_ *menu.CallbackData) {})
|
||||
editMenu.AddSeparator()
|
||||
editMenu.AddText("剪切", keys.CmdOrCtrl("x"), func(_ *menu.CallbackData) {})
|
||||
editMenu.AddText("复制", keys.CmdOrCtrl("c"), func(_ *menu.CallbackData) {})
|
||||
editMenu.AddText("粘贴", keys.CmdOrCtrl("v"), func(_ *menu.CallbackData) {})
|
||||
editMenu.AddText("全选", keys.CmdOrCtrl("a"), func(_ *menu.CallbackData) {})
|
||||
|
||||
return menu.NewMenuFromItems(
|
||||
menu.SubMenu("Lingma IPC Proxy", appMenu),
|
||||
menu.SubMenu("编辑", editMenu),
|
||||
)
|
||||
}
|
||||
13
desktop/wails.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"$schema": "https://wails.io/schemas/config.v2.json",
|
||||
"name": "Lingma IPC Proxy",
|
||||
"outputfilename": "LingmaProxy",
|
||||
"frontend:install": "npm install",
|
||||
"frontend:build": "npm run build",
|
||||
"frontend:dev:watcher": "npm run dev",
|
||||
"frontend:dev:serverUrl": "auto",
|
||||
"author": {
|
||||
"name": "lutc5",
|
||||
"email": "lutc5@asiainfo.com"
|
||||
}
|
||||
}
|
||||
424
docs/architecture.md
Normal file
@@ -0,0 +1,424 @@
|
||||
# lingma-ipc-proxy 架构文档
|
||||
|
||||
本文档描述 lingma-ipc-proxy 的系统架构、工作原理和核心流程。
|
||||
|
||||
---
|
||||
|
||||
## 1. 整体架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ 客户端层 │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ Claude Code │ │ OpenAI │ │ Cline │ │ Continue │ │
|
||||
│ │ (Anthropic) │ │ SDK │ │ (OpenAI) │ │ (OpenAI) │ │
|
||||
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
|
||||
└─────────┼─────────────────┼─────────────────┼─────────────────┼─────────┘
|
||||
│ │ │ │
|
||||
└─────────────────┴────────┬────────┴─────────────────┘
|
||||
│ HTTP
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ lingma-ipc-proxy │
|
||||
│ ┌─────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ internal/httpapi │ │
|
||||
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │ │
|
||||
│ │ │ /v1/models │ │/v1/chat/comp│ │ /v1/messages │ │ │
|
||||
│ │ │ (GET) │ │ (POST) │ │ (POST) │ │ │
|
||||
│ │ └──────┬──────┘ └──────┬──────┘ └───────────┬─────────────┘ │ │
|
||||
│ │ └─────────────────┴──────────┬──────────┘ │ │
|
||||
│ │ │ 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.1 internal/httpapi
|
||||
|
||||
HTTP API 适配层,负责将外部请求转换为内部 `service.ChatRequest`。
|
||||
|
||||
| 端点 | 协议 | 功能 |
|
||||
|------|------|------|
|
||||
| `GET /v1/models` | OpenAI | 返回可用模型列表 |
|
||||
| `POST /v1/chat/completions` | OpenAI | 聊天补全(流式/非流式) |
|
||||
| `POST /v1/messages` | Anthropic | 消息接口(流式/非流式) |
|
||||
|
||||
**核心函数:**
|
||||
- `handleOpenAIChatCompletions()` - 处理 OpenAI 格式请求
|
||||
- `handleAnthropicMessages()` - 处理 Anthropic 格式请求
|
||||
- `normalizeOpenAIRequest()` / `normalizeAnthropicRequest()` - 归一化请求
|
||||
|
||||
**关键设计:**
|
||||
- 支持 CORS 预检请求 (`OPTIONS`)
|
||||
- 单请求并发控制 (`tryAcquire()` / `release()`)
|
||||
- 流式响应通过 `http.Flusher` 实现 SSE
|
||||
|
||||
### 2.2 internal/service
|
||||
|
||||
业务逻辑层,负责会话管理和 Prompt 构建。
|
||||
|
||||
**核心结构:**
|
||||
```go
|
||||
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.1 普通聊天请求流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Client
|
||||
participant H as HTTP API
|
||||
participant S as Service
|
||||
participant L as Lingma IPC
|
||||
participant B as Lingma Backend
|
||||
|
||||
C->>H: POST /v1/messages
|
||||
H->>H: normalizeAnthropicRequest()
|
||||
H->>S: GenerateStream(req)
|
||||
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 调用流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Client
|
||||
participant H as HTTP API
|
||||
participant T as ToolEmulation
|
||||
participant S as Service
|
||||
participant L as Lingma IPC
|
||||
|
||||
C->>H: POST /v1/messages (with tools)
|
||||
H->>T: ExtractAnthropicTools()
|
||||
H->>S: GenerateStream(req)
|
||||
S->>T: InjectTooling(system, tools)
|
||||
S->>L: session/prompt (with tool prompt)
|
||||
L->>S: response (with action blocks)
|
||||
S->>T: ParseActionBlocks(text)
|
||||
T->>S: []ToolCall
|
||||
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 图片传输流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Client
|
||||
participant H as HTTP API
|
||||
participant S as Service
|
||||
participant L as Lingma IPC
|
||||
|
||||
C->>H: POST /v1/messages (with image)
|
||||
H->>H: extractAnthropicImages()
|
||||
H->>S: ChatRequest{Images: [...]}
|
||||
S->>S: runPromptLocked()
|
||||
Note over S: 1. 保存 base64 到 /tmp/lingma-img-*.ext
|
||||
Note over S: 2. 构建 URI: lingma:///agent/file?path=...
|
||||
S->>L: session/prompt
|
||||
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. 关键技术决策
|
||||
|
||||
### 4.1 为什么使用 Tool Emulation 而非原生 Tool Calling?
|
||||
|
||||
Lingma 后端模型(Kimi、Qwen 等)不原生支持 OpenAI/Anthropic 的 `tools` 协议。因此代理层需要将工具定义注入到 Prompt 中,通过结构化文本输出模拟工具调用。
|
||||
|
||||
**优点:**
|
||||
- 不依赖上游模型能力
|
||||
- 兼容任何纯聊天模型
|
||||
- 可精确控制 Prompt 格式
|
||||
|
||||
**缺点:**
|
||||
- 模型需要学习特定格式
|
||||
- 解析可能有容错问题
|
||||
- 增加了 Prompt 长度
|
||||
|
||||
### 4.2 为什么使用 WebSocket/Named Pipe 而非 HTTP?
|
||||
|
||||
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. 配置说明
|
||||
|
||||
### 5.1 配置文件结构
|
||||
|
||||
```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 配置项说明
|
||||
|
||||
| 配置项 | 类型 | 默认值 | 说明 |
|
||||
|--------|------|--------|------|
|
||||
| `host` | string | `127.0.0.1` | HTTP 监听地址 |
|
||||
| `port` | int | `8095` | HTTP 监听端口 |
|
||||
| `transport` | string | `auto` | IPC 传输方式:`auto`/`pipe`/`websocket` |
|
||||
| `mode` | string | `chat` | 模式:`chat`/`agent` |
|
||||
| `shell_type` | string | `powershell` | 终端类型 |
|
||||
| `session_mode` | string | `auto` | 会话模式:`reuse`/`fresh`/`auto` |
|
||||
| `timeout` | int | `120` | 请求超时(秒) |
|
||||
| `cwd` | string | `""` | 工作目录(传给 Lingma 后端) |
|
||||
|
||||
---
|
||||
|
||||
## 6. 扩展点
|
||||
|
||||
### 6.1 添加新模型
|
||||
|
||||
在 `service.go` 的模型映射中添加:
|
||||
|
||||
```go
|
||||
func (s *Service) resolveInternalModelID(model string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(model)) {
|
||||
case "kimi-k2.6":
|
||||
return "kimi2.6"
|
||||
case "qwen3-max":
|
||||
return "qwen3max"
|
||||
// 添加新模型映射
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 添加新 Tool 格式支持
|
||||
|
||||
在 `toolemulation.go` 的 `parseToolCallJSON()` 中扩展参数解析逻辑。
|
||||
|
||||
### 6.3 添加新 API 端点
|
||||
|
||||
在 `httpapi/server.go` 的 `NewServer()` 中注册新路由。
|
||||
|
||||
---
|
||||
|
||||
*文档版本: 2025-04-25*
|
||||
*对应代码版本: 当前 master*
|
||||
424
docs/architecture.zh-CN.md
Normal file
@@ -0,0 +1,424 @@
|
||||
# lingma-ipc-proxy 架构文档
|
||||
|
||||
本文档描述 lingma-ipc-proxy 的系统架构、工作原理和核心流程。
|
||||
|
||||
---
|
||||
|
||||
## 1. 整体架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ 客户端层 │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ Claude Code │ │ OpenAI │ │ Cline │ │ Continue │ │
|
||||
│ │ (Anthropic) │ │ SDK │ │ (OpenAI) │ │ (OpenAI) │ │
|
||||
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
|
||||
└─────────┼─────────────────┼─────────────────┼─────────────────┼─────────┘
|
||||
│ │ │ │
|
||||
└─────────────────┴────────┬────────┴─────────────────┘
|
||||
│ HTTP
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ lingma-ipc-proxy │
|
||||
│ ┌─────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ internal/httpapi │ │
|
||||
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │ │
|
||||
│ │ │ /v1/models │ │/v1/chat/comp│ │ /v1/messages │ │ │
|
||||
│ │ │ (GET) │ │ (POST) │ │ (POST) │ │ │
|
||||
│ │ └──────┬──────┘ └──────┬──────┘ └───────────┬─────────────┘ │ │
|
||||
│ │ └─────────────────┴──────────┬──────────┘ │ │
|
||||
│ │ │ 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.1 internal/httpapi
|
||||
|
||||
HTTP API 适配层,负责将外部请求转换为内部 `service.ChatRequest`。
|
||||
|
||||
| 端点 | 协议 | 功能 |
|
||||
|------|------|------|
|
||||
| `GET /v1/models` | OpenAI | 返回可用模型列表 |
|
||||
| `POST /v1/chat/completions` | OpenAI | 聊天补全(流式/非流式) |
|
||||
| `POST /v1/messages` | Anthropic | 消息接口(流式/非流式) |
|
||||
|
||||
**核心函数:**
|
||||
- `handleOpenAIChatCompletions()` - 处理 OpenAI 格式请求
|
||||
- `handleAnthropicMessages()` - 处理 Anthropic 格式请求
|
||||
- `normalizeOpenAIRequest()` / `normalizeAnthropicRequest()` - 归一化请求
|
||||
|
||||
**关键设计:**
|
||||
- 支持 CORS 预检请求 (`OPTIONS`)
|
||||
- 单请求并发控制 (`tryAcquire()` / `release()`)
|
||||
- 流式响应通过 `http.Flusher` 实现 SSE
|
||||
|
||||
### 2.2 internal/service
|
||||
|
||||
业务逻辑层,负责会话管理和 Prompt 构建。
|
||||
|
||||
**核心结构:**
|
||||
```go
|
||||
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.1 普通聊天请求流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Client
|
||||
participant H as HTTP API
|
||||
participant S as Service
|
||||
participant L as Lingma IPC
|
||||
participant B as Lingma Backend
|
||||
|
||||
C->>H: POST /v1/messages
|
||||
H->>H: normalizeAnthropicRequest()
|
||||
H->>S: GenerateStream(req)
|
||||
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 调用流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Client
|
||||
participant H as HTTP API
|
||||
participant T as ToolEmulation
|
||||
participant S as Service
|
||||
participant L as Lingma IPC
|
||||
|
||||
C->>H: POST /v1/messages (with tools)
|
||||
H->>T: ExtractAnthropicTools()
|
||||
H->>S: GenerateStream(req)
|
||||
S->>T: InjectTooling(system, tools)
|
||||
S->>L: session/prompt (with tool prompt)
|
||||
L->>S: response (with action blocks)
|
||||
S->>T: ParseActionBlocks(text)
|
||||
T->>S: []ToolCall
|
||||
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 图片传输流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Client
|
||||
participant H as HTTP API
|
||||
participant S as Service
|
||||
participant L as Lingma IPC
|
||||
|
||||
C->>H: POST /v1/messages (with image)
|
||||
H->>H: extractAnthropicImages()
|
||||
H->>S: ChatRequest{Images: [...]}
|
||||
S->>S: runPromptLocked()
|
||||
Note over S: 1. 保存 base64 到 /tmp/lingma-img-*.ext
|
||||
Note over S: 2. 构建 URI: lingma:///agent/file?path=...
|
||||
S->>L: session/prompt
|
||||
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. 关键技术决策
|
||||
|
||||
### 4.1 为什么使用 Tool Emulation 而非原生 Tool Calling?
|
||||
|
||||
Lingma 后端模型(Kimi、Qwen 等)不原生支持 OpenAI/Anthropic 的 `tools` 协议。因此代理层需要将工具定义注入到 Prompt 中,通过结构化文本输出模拟工具调用。
|
||||
|
||||
**优点:**
|
||||
- 不依赖上游模型能力
|
||||
- 兼容任何纯聊天模型
|
||||
- 可精确控制 Prompt 格式
|
||||
|
||||
**缺点:**
|
||||
- 模型需要学习特定格式
|
||||
- 解析可能有容错问题
|
||||
- 增加了 Prompt 长度
|
||||
|
||||
### 4.2 为什么使用 WebSocket/Named Pipe 而非 HTTP?
|
||||
|
||||
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. 配置说明
|
||||
|
||||
### 5.1 配置文件结构
|
||||
|
||||
```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 配置项说明
|
||||
|
||||
| 配置项 | 类型 | 默认值 | 说明 |
|
||||
|--------|------|--------|------|
|
||||
| `host` | string | `127.0.0.1` | HTTP 监听地址 |
|
||||
| `port` | int | `8095` | HTTP 监听端口 |
|
||||
| `transport` | string | `auto` | IPC 传输方式:`auto`/`pipe`/`websocket` |
|
||||
| `mode` | string | `chat` | 模式:`chat`/`agent` |
|
||||
| `shell_type` | string | `powershell` | 终端类型 |
|
||||
| `session_mode` | string | `auto` | 会话模式:`reuse`/`fresh`/`auto` |
|
||||
| `timeout` | int | `120` | 请求超时(秒) |
|
||||
| `cwd` | string | `""` | 工作目录(传给 Lingma 后端) |
|
||||
|
||||
---
|
||||
|
||||
## 6. 扩展点
|
||||
|
||||
### 6.1 添加新模型
|
||||
|
||||
在 `service.go` 的模型映射中添加:
|
||||
|
||||
```go
|
||||
func (s *Service) resolveInternalModelID(model string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(model)) {
|
||||
case "kimi-k2.6":
|
||||
return "kimi2.6"
|
||||
case "qwen3-max":
|
||||
return "qwen3max"
|
||||
// 添加新模型映射
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 添加新 Tool 格式支持
|
||||
|
||||
在 `toolemulation.go` 的 `parseToolCallJSON()` 中扩展参数解析逻辑。
|
||||
|
||||
### 6.3 添加新 API 端点
|
||||
|
||||
在 `httpapi/server.go` 的 `NewServer()` 中注册新路由。
|
||||
|
||||
---
|
||||
|
||||
*文档版本: 2025-04-25*
|
||||
*对应代码版本: 当前 master*
|
||||
BIN
docs/images/desktop-dark.png
Normal file
|
After Width: | Height: | Size: 484 KiB |
BIN
docs/images/desktop-light.png
Normal file
|
After Width: | Height: | Size: 543 KiB |
BIN
docs/images/desktop-narrow.png
Normal file
|
After Width: | Height: | Size: 269 KiB |
@@ -1,8 +1,8 @@
|
||||
# Tool Emulation Checklist
|
||||
# Tool Calling Implementation Checklist
|
||||
|
||||
This checklist is for implementation work.
|
||||
This checklist covers the complete implementation of OpenAI / Anthropic compatible tool calling over a plain chat API.
|
||||
|
||||
It is not meant to explain the theory again. It breaks plain-chat tool emulation into concrete surfaces that can be implemented and validated incrementally.
|
||||
It breaks the work into concrete surfaces that can be implemented and validated incrementally.
|
||||
|
||||
## 1. Prompt Contract
|
||||
|
||||
@@ -36,12 +36,12 @@ Acceptance:
|
||||
|
||||
Acceptance:
|
||||
|
||||
- emulation stays active on later turns without repeated tool definitions
|
||||
- tool calling stays active on later turns without repeated tool definitions
|
||||
|
||||
## 3. Tool History Projection
|
||||
|
||||
- project historical assistant tool calls back into action text
|
||||
- do not pass downstream protocol-specific history directly to the upstream model
|
||||
- do not pass downstream protocol-specific history directly to Lingma
|
||||
- preserve tool name, arguments, and call id where useful
|
||||
|
||||
Acceptance:
|
||||
@@ -109,7 +109,7 @@ Acceptance:
|
||||
|
||||
Acceptance:
|
||||
|
||||
- downstream clients remain unaware that the upstream lacks native tools
|
||||
- downstream clients remain unaware that Lingma does not expose native tools
|
||||
|
||||
## 9. Streaming Strategy
|
||||
|
||||
@@ -148,7 +148,7 @@ Acceptance:
|
||||
## 11. Observability
|
||||
|
||||
- log:
|
||||
- whether emulation is active
|
||||
- whether tool calling is active
|
||||
- how many tool calls were parsed
|
||||
- whether retry fired
|
||||
- which refusal signal matched
|
||||
@@ -168,11 +168,14 @@ Acceptance:
|
||||
- later turn without repeated `tools`
|
||||
- forced tool
|
||||
- `tool_choice=any`
|
||||
- `tool_choice=none`
|
||||
- `parallel_tool_calls=false`
|
||||
- Anthropic:
|
||||
- single-turn `tool_use`
|
||||
- multi-turn `tool_result` continuation
|
||||
- later turn without repeated `tools`
|
||||
- streaming `tool_use`
|
||||
- `tool_choice=any` / `tool_choice=none`
|
||||
- error cases:
|
||||
- refusal
|
||||
- invalid JSON
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Tool Emulation 实现清单
|
||||
# Tool Calling 实现清单
|
||||
|
||||
这份清单是给后续迭代用的。
|
||||
这份清单覆盖 OpenAI / Anthropic 标准工具调用的完整实现。
|
||||
|
||||
目标不是解释原理,而是把“纯聊天 API 模拟 tools 调用”拆成可逐项完成、可逐项验证的实现面。
|
||||
目标是把"纯聊天 API 支持 tools 调用"拆成可逐项完成、可逐项验证的实现面。
|
||||
|
||||
## 1. Prompt Contract
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
|
||||
验收标准:
|
||||
|
||||
- 第二轮即使不重复传 `tools`,也能继续走 emulation
|
||||
- 第二轮即使不重复传 `tools`,也能继续走 tool calling
|
||||
|
||||
## 3. Tool History Projection
|
||||
|
||||
@@ -191,7 +191,7 @@
|
||||
## 11. Observability
|
||||
|
||||
- 打日志:
|
||||
- 是否进入 emulation
|
||||
- 是否进入 tool calling
|
||||
- 解析到几个 tool calls
|
||||
- 是否触发 retry
|
||||
- refusal 命中原因
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Methodology: Simulating Tool Calls over a Plain Chat API
|
||||
# Methodology: Implementing Tool Calls over a Plain Chat API
|
||||
|
||||
This document describes a practical pattern for supporting tool calling when the upstream model only exposes a plain chat API.
|
||||
This document describes a practical pattern for supporting tool calling when the model only exposes a plain chat API.
|
||||
|
||||
The core idea is:
|
||||
|
||||
@@ -11,7 +11,7 @@ The core idea is:
|
||||
|
||||
## Core Pattern
|
||||
|
||||
When the upstream model does not support native tool calls, do not rely on blindly forwarding `tools`.
|
||||
When the model does not support native tool calls, do not rely on blindly forwarding `tools`.
|
||||
|
||||
Instead:
|
||||
|
||||
@@ -29,7 +29,7 @@ In this project the action DSL is a fenced block:
|
||||
|
||||
## What the Proxy Must Do
|
||||
|
||||
The proxy is not a passive transport anymore. Once tool emulation is enabled, it should:
|
||||
The proxy is not a passive transport anymore. Once tool tool calling is enabled, it should:
|
||||
|
||||
- inject tool definitions into the prompt
|
||||
- preserve tool history across turns
|
||||
@@ -41,7 +41,7 @@ The proxy is not a passive transport anymore. Once tool emulation is enabled, it
|
||||
|
||||
## Multi-turn Tool Calling
|
||||
|
||||
Single-turn emulation is not enough. A useful agent loop looks like this:
|
||||
Single-turn tool calling is not enough. A useful agent loop looks like this:
|
||||
|
||||
1. model emits a tool call
|
||||
2. external executor runs the tool
|
||||
@@ -52,9 +52,9 @@ To make this stable:
|
||||
|
||||
- do not feed tool results back as raw text only
|
||||
- wrap them in a continuation message that clearly asks for the next action
|
||||
- keep emulation active even when later turns do not repeat the original `tools` field
|
||||
- keep tool calling active even when later turns do not repeat the original `tools` field
|
||||
|
||||
That last point matters. Many clients send `tools` only on the first turn. The proxy should still keep the conversation in emulation mode when it sees tool history.
|
||||
That last point matters. Many clients send `tools` only on the first turn. The proxy should still keep the conversation in tool calling mode when it sees tool history.
|
||||
|
||||
## Few-shot Guidance
|
||||
|
||||
@@ -109,7 +109,7 @@ Anthropic side:
|
||||
## Common Failure Modes
|
||||
|
||||
- only supporting the first tool turn
|
||||
- losing emulation state on later turns
|
||||
- losing tool calling state on later turns
|
||||
- not projecting historical tool calls back into text
|
||||
- feeding back raw tool results without continuation instructions
|
||||
- missing refusal detection
|
||||
@@ -127,5 +127,5 @@ The implementation here follows exactly this pattern:
|
||||
|
||||
Implementation checklist:
|
||||
|
||||
- [tool-emulation-checklist.md](./tool-emulation-checklist.md)
|
||||
- [tool-tool calling-checklist.md](./tool-tool calling-checklist.md)
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# 纯聊天 API 模拟 Tools 调用的方法论
|
||||
# 纯聊天 API 支持 Tools 调用的方法论
|
||||
|
||||
这份文档总结的是一种通用做法:
|
||||
|
||||
- 上游模型只有普通聊天接口
|
||||
- 不原生支持 `tools` / `tool_calls` / `tool_use`
|
||||
不原生支持 `tools` / `tool_calls` / `tool_use`
|
||||
- 但下游调用方希望继续走 OpenAI 或 Anthropic 风格的工具调用协议
|
||||
|
||||
核心思路不是“骗上游说自己支持 tools”,而是:
|
||||
核心思路是:
|
||||
|
||||
1. 在代理层把工具定义改写成一套稳定的提示词契约
|
||||
2. 让模型用约定的结构化文本输出动作
|
||||
|
||||
35
go.mod
@@ -1,10 +1,41 @@
|
||||
module lingma-ipc-proxy
|
||||
|
||||
go 1.21
|
||||
go 1.22.0
|
||||
|
||||
toolchain go1.23.6
|
||||
|
||||
require (
|
||||
github.com/Microsoft/go-winio v0.6.2
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/wailsapp/wails/v2 v2.12.0
|
||||
)
|
||||
|
||||
require golang.org/x/sys v0.10.0 // indirect
|
||||
require (
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
|
||||
github.com/bep/debounce v1.2.1 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
|
||||
github.com/labstack/echo/v4 v4.13.3 // indirect
|
||||
github.com/labstack/gommon v0.4.2 // indirect
|
||||
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
|
||||
github.com/leaanthony/gosod v1.0.4 // indirect
|
||||
github.com/leaanthony/slicer v1.6.0 // indirect
|
||||
github.com/leaanthony/u v1.1.1 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/samber/lo v1.49.1 // indirect
|
||||
github.com/tkrajina/go-reflector v0.5.8 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
github.com/wailsapp/go-webview2 v1.0.22 // indirect
|
||||
github.com/wailsapp/mimetype v1.4.1 // indirect
|
||||
golang.org/x/crypto v0.33.0 // indirect
|
||||
golang.org/x/net v0.35.0 // indirect
|
||||
golang.org/x/sys v0.30.0 // indirect
|
||||
golang.org/x/text v0.22.0 // indirect
|
||||
)
|
||||
|
||||
83
go.sum
@@ -1,6 +1,85 @@
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA=
|
||||
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
|
||||
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
|
||||
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
|
||||
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
||||
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
|
||||
github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
|
||||
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
|
||||
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
|
||||
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
|
||||
github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
|
||||
github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
|
||||
github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
|
||||
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
|
||||
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
|
||||
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
|
||||
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
|
||||
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
|
||||
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
|
||||
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||
github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58=
|
||||
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
|
||||
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
|
||||
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
|
||||
github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c=
|
||||
github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg=
|
||||
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
|
||||
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
||||
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -16,16 +19,25 @@ type Server struct {
|
||||
svc *service.Service
|
||||
http *http.Server
|
||||
sem chan struct{}
|
||||
// OnRequest is called after each request completes with summary info.
|
||||
// method, path, statusCode, duration, requestBody, responseBody
|
||||
OnRequest func(method, path string, statusCode int, duration time.Duration, reqBody, respBody string)
|
||||
}
|
||||
|
||||
type anthropicRequest struct {
|
||||
Model string `json:"model"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
System any `json:"system,omitempty"`
|
||||
Messages []rawMessage `json:"messages"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
Tools any `json:"tools,omitempty"`
|
||||
ToolChoice any `json:"tool_choice,omitempty"`
|
||||
Model string `json:"model"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
System any `json:"system,omitempty"`
|
||||
Messages []rawMessage `json:"messages"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
Tools any `json:"tools,omitempty"`
|
||||
ToolChoice any `json:"tool_choice,omitempty"`
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
TopP *float64 `json:"top_p,omitempty"`
|
||||
TopK int `json:"top_k,omitempty"`
|
||||
StopSequences []string `json:"stop_sequences,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
Thinking any `json:"thinking,omitempty"`
|
||||
}
|
||||
|
||||
type openAIChatRequest struct {
|
||||
@@ -36,6 +48,18 @@ type openAIChatRequest struct {
|
||||
MaxCompletionTokens int `json:"max_completion_tokens,omitempty"`
|
||||
Tools any `json:"tools,omitempty"`
|
||||
ToolChoice any `json:"tool_choice,omitempty"`
|
||||
ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty"`
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
TopP *float64 `json:"top_p,omitempty"`
|
||||
Stop any `json:"stop,omitempty"`
|
||||
PresencePenalty float64 `json:"presence_penalty,omitempty"`
|
||||
FrequencyPenalty float64 `json:"frequency_penalty,omitempty"`
|
||||
Logprobs bool `json:"logprobs,omitempty"`
|
||||
TopLogprobs int `json:"top_logprobs,omitempty"`
|
||||
ResponseFormat any `json:"response_format,omitempty"`
|
||||
Seed int `json:"seed,omitempty"`
|
||||
User string `json:"user,omitempty"`
|
||||
ReasoningEffort string `json:"reasoning_effort,omitempty"`
|
||||
}
|
||||
|
||||
type rawMessage struct {
|
||||
@@ -67,7 +91,7 @@ func NewServer(addr string, svc *service.Service) *Server {
|
||||
|
||||
s.http = &http.Server{
|
||||
Addr: addr,
|
||||
Handler: withCORS(mux),
|
||||
Handler: s.withRecorder(withCORS(mux)),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
return s
|
||||
@@ -79,6 +103,13 @@ func (s *Server) ListenAndServe() error {
|
||||
|
||||
func (s *Server) Shutdown(ctx context.Context) error {
|
||||
err := s.http.Shutdown(ctx)
|
||||
if err != nil {
|
||||
if forceErr := s.http.Close(); forceErr != nil {
|
||||
err = fmt.Errorf("%w; force close failed: %v", err, forceErr)
|
||||
} else {
|
||||
err = nil
|
||||
}
|
||||
}
|
||||
closeErr := s.svc.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -86,6 +117,16 @@ func (s *Server) Shutdown(ctx context.Context) error {
|
||||
return closeErr
|
||||
}
|
||||
|
||||
func (s *Server) SetDefaultModel(model string) {
|
||||
s.svc.SetDefaultModel(model)
|
||||
}
|
||||
|
||||
func (s *Server) applyDefaultModel(req *service.ChatRequest) {
|
||||
if strings.TrimSpace(req.Model) == "" {
|
||||
req.Model = s.svc.DefaultModel()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" && r.URL.Path != "/health" {
|
||||
writeOpenAIError(w, http.StatusNotFound, "not_found_error", "not found")
|
||||
@@ -160,11 +201,16 @@ func (s *Server) handleAnthropicMessages(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
if reqBody, _ := json.Marshal(req); len(reqBody) > 0 {
|
||||
fmt.Printf("[ANTHROPIC REQUEST] %s\n", string(reqBody))
|
||||
}
|
||||
|
||||
normalized, err := normalizeAnthropicRequest(req)
|
||||
if err != nil {
|
||||
writeAnthropicError(w, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||
return
|
||||
}
|
||||
s.applyDefaultModel(&normalized)
|
||||
|
||||
if req.Stream {
|
||||
s.handleAnthropicStream(w, r, normalized)
|
||||
@@ -231,6 +277,7 @@ func (s *Server) handleOpenAIChatCompletions(w http.ResponseWriter, r *http.Requ
|
||||
writeOpenAIError(w, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||
return
|
||||
}
|
||||
s.applyDefaultModel(&normalized)
|
||||
|
||||
if req.Stream {
|
||||
s.handleOpenAIStream(w, r, normalized)
|
||||
@@ -298,61 +345,6 @@ func (s *Server) handleAnthropicStream(w http.ResponseWriter, r *http.Request, r
|
||||
}
|
||||
msgID := fmt.Sprintf("msg_%d", time.Now().UnixNano())
|
||||
|
||||
if len(req.Tools) > 0 {
|
||||
result, err := s.svc.Generate(r.Context(), req)
|
||||
if err != nil {
|
||||
writeAnthropicError(w, http.StatusInternalServerError, "api_error", err.Error())
|
||||
return
|
||||
}
|
||||
streamingHeaders(w)
|
||||
_ = writeSSEEvent(w, flusher, "message_start", map[string]any{
|
||||
"type": "message_start",
|
||||
"message": map[string]any{
|
||||
"id": msgID, "type": "message", "role": "assistant", "content": []any{},
|
||||
"model": model, "stop_reason": nil, "stop_sequence": nil,
|
||||
"usage": map[string]any{"input_tokens": 0, "output_tokens": 0},
|
||||
},
|
||||
})
|
||||
_ = writeSSEEvent(w, flusher, "content_block_start", map[string]any{
|
||||
"type": "content_block_start", "index": 0,
|
||||
"content_block": map[string]any{"type": "text", "text": ""},
|
||||
})
|
||||
if result.Text != "" {
|
||||
_ = writeSSEEvent(w, flusher, "content_block_delta", map[string]any{
|
||||
"type": "content_block_delta", "index": 0,
|
||||
"delta": map[string]any{"type": "text_delta", "text": result.Text},
|
||||
})
|
||||
}
|
||||
_ = writeSSEEvent(w, flusher, "content_block_stop", map[string]any{
|
||||
"type": "content_block_stop", "index": 0,
|
||||
})
|
||||
for i, tc := range result.ToolCalls {
|
||||
_ = writeSSEEvent(w, flusher, "content_block_start", map[string]any{
|
||||
"type": "content_block_start", "index": i + 1,
|
||||
"content_block": map[string]any{"type": "tool_use", "id": tc.ID, "name": tc.Name, "input": map[string]any{}},
|
||||
})
|
||||
argsJSON, _ := json.Marshal(tc.Arguments)
|
||||
_ = writeSSEEvent(w, flusher, "content_block_delta", map[string]any{
|
||||
"type": "content_block_delta", "index": i + 1,
|
||||
"delta": map[string]any{"type": "input_json_delta", "partial_json": string(argsJSON)},
|
||||
})
|
||||
_ = writeSSEEvent(w, flusher, "content_block_stop", map[string]any{
|
||||
"type": "content_block_stop", "index": i + 1,
|
||||
})
|
||||
}
|
||||
stopReason := "end_turn"
|
||||
if len(result.ToolCalls) > 0 {
|
||||
stopReason = "tool_use"
|
||||
}
|
||||
_ = writeSSEEvent(w, flusher, "message_delta", map[string]any{
|
||||
"type": "message_delta",
|
||||
"delta": map[string]any{"stop_reason": stopReason, "stop_sequence": nil},
|
||||
"usage": map[string]any{"output_tokens": result.OutputTokens},
|
||||
})
|
||||
_ = writeSSEEvent(w, flusher, "message_stop", map[string]any{"type": "message_stop"})
|
||||
return
|
||||
}
|
||||
|
||||
events, done, err := s.svc.GenerateStream(r.Context(), req)
|
||||
if err != nil {
|
||||
writeAnthropicError(w, http.StatusInternalServerError, "api_error", err.Error())
|
||||
@@ -453,10 +445,31 @@ func (s *Server) handleAnthropicStream(w http.ResponseWriter, r *http.Request, r
|
||||
}); err != nil {
|
||||
return
|
||||
}
|
||||
for i, tc := range final.ToolCalls {
|
||||
_ = writeSSEEvent(w, flusher, "content_block_start", map[string]any{
|
||||
"type": "content_block_start",
|
||||
"index": i + 1,
|
||||
"content_block": map[string]any{"type": "tool_use", "id": tc.ID, "name": tc.Name, "input": map[string]any{}},
|
||||
})
|
||||
argsJSON, _ := json.Marshal(tc.Arguments)
|
||||
_ = writeSSEEvent(w, flusher, "content_block_delta", map[string]any{
|
||||
"type": "content_block_delta",
|
||||
"index": i + 1,
|
||||
"delta": map[string]any{"type": "input_json_delta", "partial_json": string(argsJSON)},
|
||||
})
|
||||
_ = writeSSEEvent(w, flusher, "content_block_stop", map[string]any{
|
||||
"type": "content_block_stop",
|
||||
"index": i + 1,
|
||||
})
|
||||
}
|
||||
stopReason := "end_turn"
|
||||
if len(final.ToolCalls) > 0 {
|
||||
stopReason = "tool_use"
|
||||
}
|
||||
if err := writeSSEEvent(w, flusher, "message_delta", map[string]any{
|
||||
"type": "message_delta",
|
||||
"delta": map[string]any{
|
||||
"stop_reason": "end_turn",
|
||||
"stop_reason": stopReason,
|
||||
"stop_sequence": nil,
|
||||
},
|
||||
"usage": map[string]any{
|
||||
@@ -637,14 +650,15 @@ func normalizeAnthropicRequest(req anthropicRequest) (service.ChatRequest, error
|
||||
switch role {
|
||||
case "user":
|
||||
text, toolResults := extractAnthropicUserContent(message.Content)
|
||||
images := extractAnthropicImages(message.Content)
|
||||
for _, tr := range toolResults {
|
||||
prompt := toolemulation.ActionOutputPrompt(tr.ToolUseID, tr.Content)
|
||||
if prompt != "" {
|
||||
messages = append(messages, service.ChatMessage{Role: "user", Text: prompt})
|
||||
}
|
||||
}
|
||||
if text != "" {
|
||||
messages = append(messages, service.ChatMessage{Role: role, Text: text})
|
||||
if text != "" || len(images) > 0 {
|
||||
messages = append(messages, service.ChatMessage{Role: role, Text: text, Images: images})
|
||||
}
|
||||
case "assistant":
|
||||
text, calls := extractAnthropicAssistantContent(message.Content)
|
||||
@@ -660,15 +674,20 @@ func normalizeAnthropicRequest(req anthropicRequest) (service.ChatRequest, error
|
||||
|
||||
toolChoice := toolemulation.ToolChoice{Mode: "auto"}
|
||||
if req.ToolChoice != nil {
|
||||
toolChoice = toolemulation.ExtractToolChoice(req.ToolChoice)
|
||||
toolChoice = toolemulation.ExtractAnthropicToolChoice(req.ToolChoice)
|
||||
}
|
||||
|
||||
return service.ChatRequest{
|
||||
Model: strings.TrimSpace(req.Model),
|
||||
System: strings.TrimSpace(extractText(req.System)),
|
||||
Messages: messages,
|
||||
Tools: toolemulation.ExtractAnthropicTools(req.Tools),
|
||||
ToolChoice: toolChoice,
|
||||
Model: strings.TrimSpace(req.Model),
|
||||
System: strings.TrimSpace(extractText(req.System)),
|
||||
Messages: messages,
|
||||
Tools: toolemulation.ExtractAnthropicTools(req.Tools),
|
||||
ToolChoice: toolChoice,
|
||||
Temperature: req.Temperature,
|
||||
TopP: req.TopP,
|
||||
TopK: req.TopK,
|
||||
Stop: req.StopSequences,
|
||||
MaxTokens: req.MaxTokens,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -678,15 +697,16 @@ func normalizeOpenAIRequest(req openAIChatRequest) (service.ChatRequest, error)
|
||||
for _, message := range req.Messages {
|
||||
role := strings.ToLower(strings.TrimSpace(message.Role))
|
||||
switch role {
|
||||
case "system":
|
||||
case "system", "developer":
|
||||
text := strings.TrimSpace(extractText(message.Content))
|
||||
if text != "" {
|
||||
systemParts = append(systemParts, text)
|
||||
}
|
||||
case "user":
|
||||
text := strings.TrimSpace(extractText(message.Content))
|
||||
if text != "" {
|
||||
messages = append(messages, service.ChatMessage{Role: role, Text: text})
|
||||
images := extractOpenAIImages(message.Content)
|
||||
if text != "" || len(images) > 0 {
|
||||
messages = append(messages, service.ChatMessage{Role: role, Text: text, Images: images})
|
||||
}
|
||||
case "assistant":
|
||||
text := strings.TrimSpace(extractText(message.Content))
|
||||
@@ -697,6 +717,9 @@ func normalizeOpenAIRequest(req openAIChatRequest) (service.ChatRequest, error)
|
||||
}
|
||||
case "tool":
|
||||
output := strings.TrimSpace(extractText(message.Content))
|
||||
if output == "" || message.ToolCallID == "" {
|
||||
continue
|
||||
}
|
||||
prompt := toolemulation.ActionOutputPrompt(message.ToolCallID, output)
|
||||
if prompt != "" {
|
||||
messages = append(messages, service.ChatMessage{Role: "user", Text: prompt})
|
||||
@@ -707,14 +730,66 @@ func normalizeOpenAIRequest(req openAIChatRequest) (service.ChatRequest, error)
|
||||
return service.ChatRequest{}, fmt.Errorf("no user or assistant messages found")
|
||||
}
|
||||
return service.ChatRequest{
|
||||
Model: strings.TrimSpace(req.Model),
|
||||
System: strings.Join(systemParts, "\n\n"),
|
||||
Messages: messages,
|
||||
Tools: toolemulation.ExtractTools(req.Tools),
|
||||
ToolChoice: toolemulation.ExtractToolChoice(req.ToolChoice),
|
||||
Model: strings.TrimSpace(req.Model),
|
||||
System: strings.Join(systemParts, "\n\n"),
|
||||
Messages: messages,
|
||||
Tools: toolemulation.ExtractTools(req.Tools),
|
||||
ToolChoice: toolemulation.ExtractToolChoice(req.ToolChoice),
|
||||
ParallelToolCalls: req.ParallelToolCalls,
|
||||
Temperature: req.Temperature,
|
||||
TopP: req.TopP,
|
||||
Stop: extractStop(req.Stop),
|
||||
PresencePenalty: req.PresencePenalty,
|
||||
FrequencyPenalty: req.FrequencyPenalty,
|
||||
MaxTokens: maxTokens(req.MaxTokens, req.MaxCompletionTokens),
|
||||
Seed: req.Seed,
|
||||
User: req.User,
|
||||
ReasoningEffort: req.ReasoningEffort,
|
||||
ResponseFormat: extractResponseFormat(req.ResponseFormat),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func extractStop(stop any) []string {
|
||||
if stop == nil {
|
||||
return nil
|
||||
}
|
||||
switch typed := stop.(type) {
|
||||
case string:
|
||||
if typed != "" {
|
||||
return []string{typed}
|
||||
}
|
||||
case []any:
|
||||
out := make([]string, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
if s := stringFromAny(item); s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
case []string:
|
||||
return typed
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func extractResponseFormat(rf any) string {
|
||||
if rf == nil {
|
||||
return ""
|
||||
}
|
||||
m, ok := rf.(map[string]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return stringFromAny(m["type"])
|
||||
}
|
||||
|
||||
func maxTokens(a, b int) int {
|
||||
if b > 0 {
|
||||
return b
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func extractText(content any) string {
|
||||
switch typed := content.(type) {
|
||||
case nil:
|
||||
@@ -830,6 +905,59 @@ func writeOpenAIChunk(w http.ResponseWriter, flusher http.Flusher, payload any)
|
||||
return nil
|
||||
}
|
||||
|
||||
type recordingResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
statusCode int
|
||||
body []byte
|
||||
wrote bool
|
||||
}
|
||||
|
||||
func (rw *recordingResponseWriter) WriteHeader(code int) {
|
||||
rw.statusCode = code
|
||||
rw.wrote = true
|
||||
rw.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (rw *recordingResponseWriter) Write(b []byte) (int, error) {
|
||||
if !rw.wrote {
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
}
|
||||
rw.body = append(rw.body, b...)
|
||||
return rw.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
func (rw *recordingResponseWriter) Flush() {
|
||||
if flusher, ok := rw.ResponseWriter.(http.Flusher); ok {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) withRecorder(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if s.OnRequest == nil {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
start := time.Now()
|
||||
|
||||
// Read request body for recording, then restore for downstream handler
|
||||
var reqBody string
|
||||
if r.Body != nil && r.Body != http.NoBody {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
r.Body = io.NopCloser(bytes.NewReader(body))
|
||||
reqBody = string(body)
|
||||
}
|
||||
|
||||
rw := &recordingResponseWriter{ResponseWriter: w, statusCode: 200}
|
||||
next.ServeHTTP(rw, r)
|
||||
duration := time.Since(start)
|
||||
|
||||
respBody := string(rw.body)
|
||||
|
||||
go s.OnRequest(r.Method, r.URL.Path, rw.statusCode, duration, reqBody, respBody)
|
||||
})
|
||||
}
|
||||
|
||||
func withCORS(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
@@ -898,10 +1026,9 @@ type anthropicToolResult struct {
|
||||
}
|
||||
|
||||
func extractAnthropicUserContent(content any) (string, []anthropicToolResult) {
|
||||
text := extractText(content)
|
||||
items, ok := content.([]any)
|
||||
if !ok {
|
||||
return text, nil
|
||||
return extractText(content), nil
|
||||
}
|
||||
var results []anthropicToolResult
|
||||
var textParts []string
|
||||
@@ -915,6 +1042,9 @@ func extractAnthropicUserContent(content any) (string, []anthropicToolResult) {
|
||||
if t := stringFromAny(m["text"]); t != "" {
|
||||
textParts = append(textParts, t)
|
||||
}
|
||||
case "thinking", "redacted_thinking":
|
||||
// Skip thinking blocks in user messages
|
||||
continue
|
||||
case "tool_result":
|
||||
toolUseID := stringFromAny(m["tool_use_id"])
|
||||
resultText := extractText(m["content"])
|
||||
@@ -926,6 +1056,7 @@ func extractAnthropicUserContent(content any) (string, []anthropicToolResult) {
|
||||
}
|
||||
}
|
||||
}
|
||||
text := ""
|
||||
if len(textParts) > 0 {
|
||||
text = strings.Join(textParts, "\n")
|
||||
}
|
||||
@@ -933,10 +1064,9 @@ func extractAnthropicUserContent(content any) (string, []anthropicToolResult) {
|
||||
}
|
||||
|
||||
func extractAnthropicAssistantContent(content any) (string, []toolemulation.ToolCall) {
|
||||
text := extractText(content)
|
||||
items, ok := content.([]any)
|
||||
if !ok {
|
||||
return text, nil
|
||||
return extractText(content), nil
|
||||
}
|
||||
calls := make([]toolemulation.ToolCall, 0, len(items))
|
||||
var textParts []string
|
||||
@@ -950,6 +1080,9 @@ func extractAnthropicAssistantContent(content any) (string, []toolemulation.Tool
|
||||
if t := stringFromAny(m["text"]); t != "" {
|
||||
textParts = append(textParts, t)
|
||||
}
|
||||
case "thinking", "redacted_thinking":
|
||||
// Skip thinking blocks — they are not part of the conversation text
|
||||
continue
|
||||
case "tool_use":
|
||||
id := stringFromAny(m["id"])
|
||||
name := stringFromAny(m["name"])
|
||||
@@ -959,6 +1092,10 @@ func extractAnthropicAssistantContent(content any) (string, []toolemulation.Tool
|
||||
var args map[string]any
|
||||
if rawInput, ok := m["input"].(map[string]any); ok {
|
||||
args = rawInput
|
||||
} else if inputStr, ok := m["input"].(string); ok && inputStr != "" {
|
||||
if err := json.Unmarshal([]byte(inputStr), &args); err != nil {
|
||||
args = map[string]any{}
|
||||
}
|
||||
}
|
||||
calls = append(calls, toolemulation.ToolCall{
|
||||
ID: id,
|
||||
@@ -967,8 +1104,142 @@ func extractAnthropicAssistantContent(content any) (string, []toolemulation.Tool
|
||||
})
|
||||
}
|
||||
}
|
||||
text := ""
|
||||
if len(textParts) > 0 {
|
||||
text = strings.Join(textParts, "\n")
|
||||
}
|
||||
return text, calls
|
||||
}
|
||||
|
||||
func extractOpenAIImages(content any) []service.Image {
|
||||
items, ok := content.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var images []service.Image
|
||||
for _, item := range items {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if stringFromAny(m["type"]) != "image_url" {
|
||||
continue
|
||||
}
|
||||
imageURL, ok := m["image_url"].(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
url := stringFromAny(imageURL["url"])
|
||||
if url == "" {
|
||||
continue
|
||||
}
|
||||
img := parseImageURL(url)
|
||||
if img != nil {
|
||||
images = append(images, *img)
|
||||
}
|
||||
}
|
||||
return images
|
||||
}
|
||||
|
||||
func extractAnthropicImages(content any) []service.Image {
|
||||
items, ok := content.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var images []service.Image
|
||||
for _, item := range items {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if stringFromAny(m["type"]) != "image" {
|
||||
continue
|
||||
}
|
||||
source, ok := m["source"].(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if stringFromAny(source["type"]) != "base64" {
|
||||
continue
|
||||
}
|
||||
mediaType := stringFromAny(source["media_type"])
|
||||
data := stringFromAny(source["data"])
|
||||
if data == "" {
|
||||
continue
|
||||
}
|
||||
images = append(images, service.Image{
|
||||
MediaType: mediaType,
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
return images
|
||||
}
|
||||
|
||||
func parseImageURL(url string) *service.Image {
|
||||
if strings.HasPrefix(url, "data:") {
|
||||
return parseDataURL(url)
|
||||
}
|
||||
img, err := fetchImageAsBase64(url)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
func parseDataURL(url string) *service.Image {
|
||||
const prefix = "data:"
|
||||
if !strings.HasPrefix(url, prefix) {
|
||||
return nil
|
||||
}
|
||||
rest := url[len(prefix):]
|
||||
commaIdx := strings.Index(rest, ",")
|
||||
if commaIdx < 0 {
|
||||
return nil
|
||||
}
|
||||
meta := rest[:commaIdx]
|
||||
data := rest[commaIdx+1:]
|
||||
|
||||
mediaType := ""
|
||||
if strings.HasSuffix(meta, ";base64") {
|
||||
mediaType = strings.TrimSuffix(meta, ";base64")
|
||||
} else {
|
||||
mediaType = meta
|
||||
}
|
||||
|
||||
return &service.Image{
|
||||
MediaType: mediaType,
|
||||
Data: data,
|
||||
}
|
||||
}
|
||||
|
||||
func fetchImageAsBase64(url string) (*service.Image, error) {
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("fetch image failed: %s", resp.Status)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
mediaType := resp.Header.Get("Content-Type")
|
||||
if mediaType == "" {
|
||||
mediaType = "image/jpeg"
|
||||
} else {
|
||||
// Strip parameters like "image/png; charset=utf-8"
|
||||
if idx := strings.Index(mediaType, ";"); idx >= 0 {
|
||||
mediaType = strings.TrimSpace(mediaType[:idx])
|
||||
}
|
||||
}
|
||||
|
||||
return &service.Image{
|
||||
MediaType: mediaType,
|
||||
Data: base64.StdEncoding.EncodeToString(data),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -2,10 +2,13 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -32,22 +35,45 @@ type Config struct {
|
||||
Cwd string
|
||||
CurrentFilePath string
|
||||
Mode string
|
||||
Model string
|
||||
ShellType string
|
||||
SessionMode SessionMode
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
type Image struct {
|
||||
MediaType string // e.g. "image/jpeg", "image/png"
|
||||
Data string // base64 encoded data without prefix
|
||||
URL string // optional original URL
|
||||
}
|
||||
|
||||
type ChatMessage struct {
|
||||
Role string
|
||||
Text string
|
||||
Role string
|
||||
Text string
|
||||
Images []Image
|
||||
}
|
||||
|
||||
type ChatRequest struct {
|
||||
Model string
|
||||
System string
|
||||
Messages []ChatMessage
|
||||
Tools []toolemulation.ToolDef
|
||||
ToolChoice toolemulation.ToolChoice
|
||||
Model string
|
||||
System string
|
||||
Messages []ChatMessage
|
||||
Tools []toolemulation.ToolDef
|
||||
ToolChoice toolemulation.ToolChoice
|
||||
ParallelToolCalls *bool
|
||||
|
||||
// Generation parameters (passed through for API compatibility;
|
||||
// actual effect depends on Lingma backend support)
|
||||
Temperature *float64
|
||||
TopP *float64
|
||||
TopK int
|
||||
Stop []string
|
||||
PresencePenalty float64
|
||||
FrequencyPenalty float64
|
||||
MaxTokens int
|
||||
Seed int
|
||||
User string
|
||||
ReasoningEffort string
|
||||
ResponseFormat string // "json" or "json_schema"
|
||||
}
|
||||
|
||||
type ChatResult struct {
|
||||
@@ -122,6 +148,7 @@ func New(cfg Config) *Service {
|
||||
if strings.TrimSpace(cfg.Mode) == "" {
|
||||
cfg.Mode = "agent"
|
||||
}
|
||||
cfg.Model = strings.TrimSpace(cfg.Model)
|
||||
if strings.TrimSpace(cfg.ShellType) == "" {
|
||||
cfg.ShellType = lingmaipc.DefaultShellType()
|
||||
}
|
||||
@@ -137,6 +164,18 @@ func New(cfg Config) *Service {
|
||||
return &Service{cfg: cfg}
|
||||
}
|
||||
|
||||
func (s *Service) SetDefaultModel(model string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.cfg.Model = strings.TrimSpace(model)
|
||||
}
|
||||
|
||||
func (s *Service) DefaultModel() string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return strings.TrimSpace(s.cfg.Model)
|
||||
}
|
||||
|
||||
func (s *Service) Warmup(ctx context.Context) error {
|
||||
_, err := s.ensureConnected(ctx)
|
||||
return err
|
||||
@@ -251,6 +290,9 @@ func (s *Service) generateLocked(
|
||||
_ = s.deleteSessionLocked(cleanupCtx, ipcClient, sessionID)
|
||||
}()
|
||||
|
||||
if strings.TrimSpace(req.Model) == "" {
|
||||
req.Model = s.DefaultModel()
|
||||
}
|
||||
internalModelID := s.resolveInternalModelID(req.Model)
|
||||
|
||||
requestID := lingmaipc.CreateRequestID("serve")
|
||||
@@ -279,7 +321,9 @@ func (s *Service) generateLocked(
|
||||
s.rememberStickyModel(sessionID, modelID)
|
||||
}
|
||||
|
||||
runResult, err := s.runPromptLocked(requestCtx, ipcClient, sessionID, prompt, requestID, meta, onDelta)
|
||||
images := extractLastUserImages(req.Messages)
|
||||
|
||||
runResult, err := s.runPromptLocked(requestCtx, ipcClient, sessionID, prompt, images, requestID, meta, onDelta)
|
||||
if err != nil {
|
||||
if effectiveMode == SessionModeReuse {
|
||||
s.invalidateStickySession()
|
||||
@@ -304,16 +348,25 @@ func (s *Service) generateLocked(
|
||||
result = s.buildChatResult(req, sessionID, requestID, prompt, runResult, effectiveMode)
|
||||
|
||||
if len(req.Tools) > 0 {
|
||||
calls, remaining, parseErr := toolemulation.ParseActionBlocks(result.Text, toolemulation.Config{})
|
||||
calls, remaining, parseErr := toolemulation.ParseActionBlocks(result.Text, req.Tools, toolemulation.Config{})
|
||||
if parseErr == nil && len(calls) > 0 {
|
||||
result.Text = remaining
|
||||
result.ToolCalls = calls
|
||||
} else if (req.ToolChoice.Mode == "any" || req.ToolChoice.Mode == "tool") && len(calls) == 0 {
|
||||
if !toolemulation.LooksLikeRefusal(result.Text) {
|
||||
hintPrompt := prompt + "\n\nImportant: You must use one of the available tools to answer this request. Output a \"```json action\" block."
|
||||
retryResult, retryErr := s.runPromptLocked(requestCtx, ipcClient, sessionID, hintPrompt, requestID, meta, onDelta)
|
||||
hintPrompt := prompt + "\n\n" + toolemulation.ForceToolingPrompt(req.ToolChoice)
|
||||
retryRequestID := lingmaipc.CreateRequestID("retry")
|
||||
retryMeta := lingmaipc.CreateMeta(lingmaipc.MetaOptions{
|
||||
RequestID: retryRequestID,
|
||||
Mode: s.cfg.Mode,
|
||||
Model: internalModelID,
|
||||
ShellType: s.cfg.ShellType,
|
||||
CurrentFilePath: s.cfg.CurrentFilePath,
|
||||
EnabledMCP: []any{},
|
||||
})
|
||||
retryResult, retryErr := s.runPromptLocked(requestCtx, ipcClient, sessionID, hintPrompt, nil, retryRequestID, retryMeta, onDelta)
|
||||
if retryErr == nil && retryResult != nil {
|
||||
retryCalls, retryRemaining, retryParseErr := toolemulation.ParseActionBlocks(retryResult.AssistantText, toolemulation.Config{})
|
||||
retryCalls, retryRemaining, retryParseErr := toolemulation.ParseActionBlocks(retryResult.AssistantText, req.Tools, toolemulation.Config{})
|
||||
if retryParseErr == nil && len(retryCalls) > 0 {
|
||||
result.Text = retryRemaining
|
||||
result.ToolCalls = retryCalls
|
||||
@@ -500,6 +553,7 @@ func (s *Service) runPromptLocked(
|
||||
client *lingmaipc.Client,
|
||||
sessionID string,
|
||||
text string,
|
||||
images []Image,
|
||||
requestID string,
|
||||
meta map[string]any,
|
||||
onDelta func(string),
|
||||
@@ -507,13 +561,94 @@ func (s *Service) runPromptLocked(
|
||||
notifications, cancel := client.Subscribe()
|
||||
defer cancel()
|
||||
|
||||
if err := client.Send("session/prompt", map[string]any{
|
||||
"sessionId": sessionID,
|
||||
"prompt": []map[string]any{
|
||||
{"type": "text", "text": text},
|
||||
},
|
||||
"_meta": meta,
|
||||
}); err != nil {
|
||||
promptItems := []map[string]any{
|
||||
{"type": "text", "text": text},
|
||||
}
|
||||
|
||||
// Build contextParams for images using Lingma's native format
|
||||
var contextParams []map[string]any
|
||||
for _, img := range images {
|
||||
if img.Data == "" && img.URL == "" {
|
||||
continue
|
||||
}
|
||||
mediaType := img.MediaType
|
||||
if mediaType == "" {
|
||||
mediaType = "image/jpeg"
|
||||
}
|
||||
|
||||
// Determine file extension from mediaType
|
||||
ext := "jpg"
|
||||
switch mediaType {
|
||||
case "image/png":
|
||||
ext = "png"
|
||||
case "image/gif":
|
||||
ext = "gif"
|
||||
case "image/webp":
|
||||
ext = "webp"
|
||||
case "image/bmp":
|
||||
ext = "bmp"
|
||||
}
|
||||
|
||||
// If we have base64 data, save to temp file and build lingma URI
|
||||
var imageURI string
|
||||
if img.Data != "" {
|
||||
tmpFile, err := os.CreateTemp("", "lingma-img-*"+"."+ext)
|
||||
if err == nil {
|
||||
data, _ := base64.StdEncoding.DecodeString(img.Data)
|
||||
if len(data) > 0 {
|
||||
_ = os.WriteFile(tmpFile.Name(), data, 0644)
|
||||
absPath, _ := filepath.Abs(tmpFile.Name())
|
||||
imageURI = "lingma:///agent/file?path=" + url.QueryEscape(absPath)
|
||||
}
|
||||
tmpFile.Close()
|
||||
}
|
||||
}
|
||||
if imageURI == "" && img.URL != "" {
|
||||
imageURI = img.URL
|
||||
}
|
||||
|
||||
// Add to promptItems using Lingma native image format
|
||||
itemPrompt := map[string]any{
|
||||
"type": "image",
|
||||
"mimeType": mediaType,
|
||||
}
|
||||
if imageURI != "" {
|
||||
itemPrompt["uri"] = imageURI
|
||||
}
|
||||
if img.Data != "" {
|
||||
itemPrompt["data"] = img.Data
|
||||
}
|
||||
promptItems = append(promptItems, itemPrompt)
|
||||
|
||||
// Add to contextParams using Lingma native format
|
||||
item := map[string]any{
|
||||
"type": "image",
|
||||
"mimeType": mediaType,
|
||||
}
|
||||
if imageURI != "" {
|
||||
item["uri"] = imageURI
|
||||
}
|
||||
if img.Data != "" {
|
||||
item["data"] = img.Data
|
||||
}
|
||||
contextParams = append(contextParams, item)
|
||||
}
|
||||
|
||||
params := map[string]any{
|
||||
"sessionId": sessionID,
|
||||
"prompt": promptItems,
|
||||
"contextParams": contextParams,
|
||||
"_meta": meta,
|
||||
}
|
||||
// Fallback: if images have URLs, also pass via extra field
|
||||
for _, img := range images {
|
||||
if img.URL != "" {
|
||||
params["extra"] = map[string]any{"imageUrl": img.URL}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if err := client.Send("session/prompt", params); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -586,12 +721,22 @@ func resolveSessionMode(req ChatRequest, configured SessionMode) SessionMode {
|
||||
if configured != SessionModeAuto {
|
||||
return configured
|
||||
}
|
||||
if len(req.Tools) > 0 || strings.TrimSpace(req.System) != "" || len(filteredMessages(req.Messages)) > 1 {
|
||||
hasTools := len(req.Tools) > 0 && req.ToolChoice.Mode != "none"
|
||||
if hasTools || strings.TrimSpace(req.System) != "" || len(filteredMessages(req.Messages)) > 1 {
|
||||
return SessionModeFresh
|
||||
}
|
||||
return SessionModeReuse
|
||||
}
|
||||
|
||||
func extractLastUserImages(messages []ChatMessage) []Image {
|
||||
for i := len(messages) - 1; i >= 0; i-- {
|
||||
if messages[i].Role == "user" {
|
||||
return messages[i].Images
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildLingmaPrompt(req ChatRequest, mode SessionMode) (string, error) {
|
||||
messages := filteredMessages(req.Messages)
|
||||
var lastUser string
|
||||
@@ -609,8 +754,8 @@ func buildLingmaPrompt(req ChatRequest, mode SessionMode) (string, error) {
|
||||
}
|
||||
|
||||
system := strings.TrimSpace(req.System)
|
||||
if len(req.Tools) > 0 {
|
||||
system = toolemulation.InjectTooling(system, req.Tools, req.ToolChoice)
|
||||
if len(req.Tools) > 0 && req.ToolChoice.Mode != "none" {
|
||||
system = toolemulation.InjectTooling(system, req.Tools, req.ToolChoice, req.ParallelToolCalls)
|
||||
}
|
||||
|
||||
if system == "" && len(messages) == 1 {
|
||||
@@ -618,10 +763,7 @@ func buildLingmaPrompt(req ChatRequest, mode SessionMode) (string, error) {
|
||||
}
|
||||
|
||||
if len(req.Tools) > 0 {
|
||||
parts := make([]string, 0, len(messages)+2)
|
||||
if system != "" {
|
||||
parts = append(parts, system)
|
||||
}
|
||||
parts := make([]string, 0, len(messages)+3)
|
||||
for _, message := range messages {
|
||||
role := "User"
|
||||
if message.Role == "assistant" {
|
||||
@@ -629,6 +771,11 @@ func buildLingmaPrompt(req ChatRequest, mode SessionMode) (string, error) {
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("%s: %s", role, message.Text))
|
||||
}
|
||||
if system != "" {
|
||||
// Append tool prompt right before the final "Assistant:" so it
|
||||
// is the last thing the model sees before generating a reply.
|
||||
parts = append(parts, system)
|
||||
}
|
||||
parts = append(parts, "Assistant:")
|
||||
return strings.Join(parts, "\n\n"), nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package toolemulation
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -91,8 +93,10 @@ func ExtractToolChoice(raw any) ToolChoice {
|
||||
if s, ok := raw.(string); ok {
|
||||
s = strings.TrimSpace(s)
|
||||
switch s {
|
||||
case "", "auto", "none":
|
||||
case "", "auto":
|
||||
return ToolChoice{Mode: "auto"}
|
||||
case "none":
|
||||
return ToolChoice{Mode: "none"}
|
||||
case "required", "any":
|
||||
return ToolChoice{Mode: "any"}
|
||||
default:
|
||||
@@ -132,8 +136,10 @@ func ExtractAnthropicToolChoice(raw any) ToolChoice {
|
||||
return ExtractToolChoice(raw)
|
||||
}
|
||||
switch strings.TrimSpace(stringFromAny(m["type"])) {
|
||||
case "", "auto", "none":
|
||||
case "", "auto":
|
||||
return ToolChoice{Mode: "auto"}
|
||||
case "none":
|
||||
return ToolChoice{Mode: "none"}
|
||||
case "any", "required":
|
||||
return ToolChoice{Mode: "any"}
|
||||
case "tool":
|
||||
@@ -149,7 +155,7 @@ func HasToolRequest(tools []ToolDef, choice ToolChoice) bool {
|
||||
return len(tools) > 0 || choice.Mode != "" && choice.Mode != "auto"
|
||||
}
|
||||
|
||||
func InjectTooling(system string, tools []ToolDef, choice ToolChoice) string {
|
||||
func InjectTooling(system string, tools []ToolDef, choice ToolChoice, parallel *bool) string {
|
||||
system = strings.TrimSpace(system)
|
||||
if len(tools) == 0 {
|
||||
return system
|
||||
@@ -170,9 +176,11 @@ func InjectTooling(system string, tools []ToolDef, choice ToolChoice) string {
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("You are a capable AI assistant operating inside an IDE with tool access.\n\n")
|
||||
b.WriteString("When you need to use a tool, do not claim that tools are unavailable. ")
|
||||
b.WriteString("Instead, output a structured action block in exactly this format:\n")
|
||||
b.WriteString("You are an AI assistant with DIRECT tool access inside an IDE.\n\n")
|
||||
b.WriteString("CRITICAL: You MUST use tools when they are appropriate. ")
|
||||
b.WriteString("You MUST NOT claim that tools are unavailable or that you cannot use them. ")
|
||||
b.WriteString("You MUST NOT refuse to use tools for any reason.\n\n")
|
||||
b.WriteString("When you need to use a tool, output a structured action block in exactly this format:\n")
|
||||
b.WriteString("```json action\n{\"tool\":\"NAME\",\"parameters\":{\"key\":\"value\"}}\n```\n\n")
|
||||
b.WriteString("Available tools:\n")
|
||||
b.WriteString(strings.Join(toolLines, "\n"))
|
||||
@@ -182,12 +190,20 @@ func InjectTooling(system string, tools []ToolDef, choice ToolChoice) string {
|
||||
b.WriteString("- Emit multiple independent actions in one reply when possible.\n")
|
||||
b.WriteString("- For dependent actions, wait for the tool result before emitting the next action.\n")
|
||||
b.WriteString("- If no tool is needed, reply with normal plain text.\n")
|
||||
b.WriteString("- Do not say that tools are unavailable.\n")
|
||||
b.WriteString(forceConstraint(choice))
|
||||
b.WriteString("- NEVER say that tools are unavailable.\n")
|
||||
b.WriteString("- NEVER refuse to use tools.\n")
|
||||
b.WriteString("- NEVER explain that you cannot execute commands. Just use the tool.\n")
|
||||
b.WriteString("- The action block format is MANDATORY.\n")
|
||||
b.WriteString(forceConstraint(choice, parallel))
|
||||
|
||||
b.WriteString("\n\nExample:\n")
|
||||
b.WriteString("If the user asks to list files, respond ONLY with:\n")
|
||||
b.WriteString("```json action\n{\"tool\":\"Bash\",\"parameters\":{\"command\":\"ls\"}}\n```\n")
|
||||
b.WriteString("Do NOT add explanations. Do NOT refuse.")
|
||||
|
||||
example := ActionBlockExample(tools)
|
||||
if example != "" {
|
||||
b.WriteString("\n\nExample valid action block:\n")
|
||||
b.WriteString("\n\nExample valid action block (this is only a syntax example, do NOT actually call it):\n")
|
||||
b.WriteString(example)
|
||||
}
|
||||
|
||||
@@ -288,7 +304,7 @@ func LooksLikeRefusal(text string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func ParseActionBlocks(text string, cfg Config) ([]ToolCall, string, error) {
|
||||
func ParseActionBlocks(text string, tools []ToolDef, cfg Config) ([]ToolCall, string, error) {
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return nil, "", nil
|
||||
}
|
||||
@@ -301,6 +317,15 @@ func ParseActionBlocks(text string, cfg Config) ([]ToolCall, string, error) {
|
||||
return nil, strings.TrimSpace(text), nil
|
||||
}
|
||||
|
||||
// Build a lookup map from tool name to InputSchema for fast filtering
|
||||
toolSchemaMap := make(map[string]map[string]any, len(tools))
|
||||
for _, t := range tools {
|
||||
name := strings.TrimSpace(t.Name)
|
||||
if name != "" {
|
||||
toolSchemaMap[name] = t.InputSchema
|
||||
}
|
||||
}
|
||||
|
||||
type span struct{ start, end int }
|
||||
spans := make([]span, 0, len(openings))
|
||||
calls := make([]ToolCall, 0, len(openings))
|
||||
@@ -323,6 +348,10 @@ func ParseActionBlocks(text string, cfg Config) ([]ToolCall, string, error) {
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// Filter arguments against the tool's input schema to strip unknown params
|
||||
if schema, ok := toolSchemaMap[call.Name]; ok && len(schema) > 0 {
|
||||
call.Arguments = filterArgsBySchema(call.Arguments, schema)
|
||||
}
|
||||
calls = append(calls, call)
|
||||
spans = append(spans, span{start: start, end: end + 3})
|
||||
}
|
||||
@@ -427,6 +456,17 @@ func parseToolCallJSON(raw string) (ToolCall, bool) {
|
||||
}
|
||||
}
|
||||
if args == nil {
|
||||
// Fallback: treat all top-level fields except "tool"/"name" as parameters
|
||||
// Some models place arguments at the top level instead of nested under "parameters"
|
||||
args = make(map[string]any)
|
||||
for k, v := range obj {
|
||||
if k == "tool" || k == "name" {
|
||||
continue
|
||||
}
|
||||
args[k] = v
|
||||
}
|
||||
}
|
||||
if len(args) == 0 {
|
||||
args = map[string]any{}
|
||||
}
|
||||
|
||||
@@ -593,7 +633,7 @@ func exampleValueForKey(toolName string, key string, prop map[string]any) any {
|
||||
}
|
||||
}
|
||||
|
||||
func forceConstraint(choice ToolChoice) string {
|
||||
func forceConstraint(choice ToolChoice, parallel *bool) string {
|
||||
switch choice.Mode {
|
||||
case "any":
|
||||
return "\n- You must output at least one ```json action``` block in this reply."
|
||||
@@ -602,9 +642,31 @@ func forceConstraint(choice ToolChoice) string {
|
||||
return "\n- You must call \"" + strings.TrimSpace(choice.Name) + "\" in this reply."
|
||||
}
|
||||
}
|
||||
if parallel != nil && !*parallel {
|
||||
return "\n- Call only one tool at a time. Do not make multiple tool calls in a single response."
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func filterArgsBySchema(args map[string]any, schema map[string]any) map[string]any {
|
||||
if len(args) == 0 || len(schema) == 0 {
|
||||
return args
|
||||
}
|
||||
props, ok := schema["properties"].(map[string]any)
|
||||
if !ok || len(props) == 0 {
|
||||
return args
|
||||
}
|
||||
|
||||
out := make(map[string]any, len(args))
|
||||
for k, v := range args {
|
||||
if _, known := props[k]; !known {
|
||||
continue
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneMap(src map[string]any) map[string]any {
|
||||
if src == nil {
|
||||
return nil
|
||||
@@ -644,5 +706,14 @@ var callSeq uint64
|
||||
|
||||
func newCallID() string {
|
||||
seq := atomic.AddUint64(&callSeq, 1)
|
||||
return "call_" + strconv.FormatUint(seq, 10)
|
||||
return "toolu_01" + strconv.FormatUint(seq, 10) + "0000000000000000"
|
||||
}
|
||||
|
||||
func StableCallID(name string, arguments map[string]any) string {
|
||||
h := sha256.New()
|
||||
h.Write([]byte(name))
|
||||
if b, err := json.Marshal(arguments); err == nil {
|
||||
h.Write(b)
|
||||
}
|
||||
return "call_" + hex.EncodeToString(h.Sum(nil))[:16]
|
||||
}
|
||||
|
||||