feat: add websocket transport support
This commit is contained in:
15
README.md
15
README.md
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
[English](./README.md) | [简体中文](./README.zh-CN.md)
|
[English](./README.md) | [简体中文](./README.zh-CN.md)
|
||||||
|
|
||||||
A standalone Go backend that talks to Lingma over Windows named-pipe IPC and exposes:
|
A standalone Go backend that talks to Lingma over Lingma's local pipe or websocket transport and exposes:
|
||||||
|
|
||||||
- `GET /v1/models`
|
- `GET /v1/models`
|
||||||
- `POST /v1/messages`
|
- `POST /v1/messages`
|
||||||
@@ -12,7 +12,7 @@ Current scope:
|
|||||||
|
|
||||||
- supports both non-streaming and streaming responses
|
- supports both non-streaming and streaming responses
|
||||||
- one request at a time
|
- one request at a time
|
||||||
- Windows only
|
- supports Windows named-pipe transport and local websocket transport
|
||||||
- directly uses Lingma IPC, not DOM/CDP
|
- directly uses Lingma IPC, not DOM/CDP
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
@@ -57,13 +57,15 @@ Recommended layout:
|
|||||||
{
|
{
|
||||||
"host": "127.0.0.1",
|
"host": "127.0.0.1",
|
||||||
"port": 8095,
|
"port": 8095,
|
||||||
|
"transport": "auto",
|
||||||
"mode": "chat",
|
"mode": "chat",
|
||||||
"session_mode": "reuse",
|
"session_mode": "reuse",
|
||||||
"timeout": 120,
|
"timeout": 120,
|
||||||
"cwd": "C:/Workspace/Personal/lingma-ipc-proxy",
|
"cwd": "C:/Workspace/Personal/lingma-ipc-proxy",
|
||||||
"shell_type": "powershell",
|
"shell_type": "powershell",
|
||||||
"current_file_path": "",
|
"current_file_path": "",
|
||||||
"pipe": ""
|
"pipe": "",
|
||||||
|
"websocket_url": ""
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -95,11 +97,12 @@ Run the built binary:
|
|||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
.\dist\lingma-ipc-proxy.exe --host 127.0.0.1 --port 8095 --session-mode auto
|
.\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
|
## Windows Service
|
||||||
|
|
||||||
For this project, the correct deployment shape is a native Windows process, not Docker. The proxy talks to Lingma over Windows named pipes, so it should run on the same Windows host as Lingma itself.
|
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
|
### NSSM
|
||||||
|
|
||||||
@@ -167,7 +170,9 @@ go run .\cmd\lingma-ipc-proxy --port 8095 --session-mode auto
|
|||||||
|
|
||||||
- `--host`
|
- `--host`
|
||||||
- `--port`
|
- `--port`
|
||||||
|
- `--transport`
|
||||||
- `--pipe`
|
- `--pipe`
|
||||||
|
- `--ws-url`
|
||||||
- `--cwd`
|
- `--cwd`
|
||||||
- `--current-file-path`
|
- `--current-file-path`
|
||||||
- `--mode`
|
- `--mode`
|
||||||
@@ -180,7 +185,9 @@ go run .\cmd\lingma-ipc-proxy --port 8095 --session-mode auto
|
|||||||
|
|
||||||
## Environment
|
## Environment
|
||||||
|
|
||||||
|
- `LINGMA_PROXY_TRANSPORT`
|
||||||
- `LINGMA_IPC_PIPE`
|
- `LINGMA_IPC_PIPE`
|
||||||
|
- `LINGMA_PROXY_WS_URL`
|
||||||
- `LINGMA_PROXY_HOST`
|
- `LINGMA_PROXY_HOST`
|
||||||
- `LINGMA_PROXY_PORT`
|
- `LINGMA_PROXY_PORT`
|
||||||
- `LINGMA_PROXY_CWD`
|
- `LINGMA_PROXY_CWD`
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
[English](./README.md) | [简体中文](./README.zh-CN.md)
|
[English](./README.md) | [简体中文](./README.zh-CN.md)
|
||||||
|
|
||||||
`lingma-ipc-proxy` 是一个独立的 Go 后端,通过 Windows Named Pipe IPC 与 Lingma 通信,并对外暴露:
|
`lingma-ipc-proxy` 是一个独立的 Go 后端,通过 Lingma 本地 pipe 或 websocket 传输与其通信,并对外暴露:
|
||||||
|
|
||||||
- `GET /v1/models`
|
- `GET /v1/models`
|
||||||
- `POST /v1/messages`
|
- `POST /v1/messages`
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
- 支持非流式与流式响应
|
- 支持非流式与流式响应
|
||||||
- 单次只处理一个请求
|
- 单次只处理一个请求
|
||||||
- 仅支持 Windows
|
- 支持 Windows named pipe 传输,也支持本地 websocket 传输
|
||||||
- 直接走 Lingma IPC,不依赖 DOM/CDP
|
- 直接走 Lingma IPC,不依赖 DOM/CDP
|
||||||
|
|
||||||
## 运行
|
## 运行
|
||||||
@@ -57,13 +57,15 @@ go run .\cmd\lingma-ipc-proxy
|
|||||||
{
|
{
|
||||||
"host": "127.0.0.1",
|
"host": "127.0.0.1",
|
||||||
"port": 8095,
|
"port": 8095,
|
||||||
|
"transport": "auto",
|
||||||
"mode": "chat",
|
"mode": "chat",
|
||||||
"session_mode": "reuse",
|
"session_mode": "reuse",
|
||||||
"timeout": 120,
|
"timeout": 120,
|
||||||
"cwd": "C:/Workspace/Personal/lingma-ipc-proxy",
|
"cwd": "C:/Workspace/Personal/lingma-ipc-proxy",
|
||||||
"shell_type": "powershell",
|
"shell_type": "powershell",
|
||||||
"current_file_path": "",
|
"current_file_path": "",
|
||||||
"pipe": ""
|
"pipe": "",
|
||||||
|
"websocket_url": ""
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -95,11 +97,12 @@ go build -trimpath -ldflags "-s -w" -o .\dist\lingma-ipc-proxy.exe .\cmd\lingma-
|
|||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
.\dist\lingma-ipc-proxy.exe --host 127.0.0.1 --port 8095 --session-mode auto
|
.\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 服务
|
## Windows 服务
|
||||||
|
|
||||||
这个项目正确的部署形态是 Windows 本机进程,不是 Docker。原因很直接:代理需要通过 Windows named pipe 与本机 Lingma 通信,所以必须和 Lingma 跑在同一台 Windows 主机上。
|
这个项目正确的部署形态是本机进程,不是 Docker。原因很直接:代理需要通过本地 pipe 或 websocket 与 Lingma 通信,所以必须和 Lingma 跑在同一台主机上。
|
||||||
|
|
||||||
### NSSM
|
### NSSM
|
||||||
|
|
||||||
@@ -167,7 +170,9 @@ go run .\cmd\lingma-ipc-proxy --port 8095 --session-mode auto
|
|||||||
|
|
||||||
- `--host`
|
- `--host`
|
||||||
- `--port`
|
- `--port`
|
||||||
|
- `--transport`
|
||||||
- `--pipe`
|
- `--pipe`
|
||||||
|
- `--ws-url`
|
||||||
- `--cwd`
|
- `--cwd`
|
||||||
- `--current-file-path`
|
- `--current-file-path`
|
||||||
- `--mode`
|
- `--mode`
|
||||||
@@ -180,7 +185,9 @@ go run .\cmd\lingma-ipc-proxy --port 8095 --session-mode auto
|
|||||||
|
|
||||||
## 环境变量
|
## 环境变量
|
||||||
|
|
||||||
|
- `LINGMA_PROXY_TRANSPORT`
|
||||||
- `LINGMA_IPC_PIPE`
|
- `LINGMA_IPC_PIPE`
|
||||||
|
- `LINGMA_PROXY_WS_URL`
|
||||||
- `LINGMA_PROXY_HOST`
|
- `LINGMA_PROXY_HOST`
|
||||||
- `LINGMA_PROXY_PORT`
|
- `LINGMA_PROXY_PORT`
|
||||||
- `LINGMA_PROXY_CWD`
|
- `LINGMA_PROXY_CWD`
|
||||||
|
|||||||
@@ -16,13 +16,16 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"lingma-ipc-proxy/internal/httpapi"
|
"lingma-ipc-proxy/internal/httpapi"
|
||||||
|
"lingma-ipc-proxy/internal/lingmaipc"
|
||||||
"lingma-ipc-proxy/internal/service"
|
"lingma-ipc-proxy/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
type fileConfig struct {
|
type fileConfig struct {
|
||||||
Host string `json:"host"`
|
Host string `json:"host"`
|
||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
|
Transport string `json:"transport"`
|
||||||
Pipe string `json:"pipe"`
|
Pipe string `json:"pipe"`
|
||||||
|
WebSocketURL string `json:"websocket_url"`
|
||||||
Cwd string `json:"cwd"`
|
Cwd string `json:"cwd"`
|
||||||
CurrentFilePath string `json:"current_file_path"`
|
CurrentFilePath string `json:"current_file_path"`
|
||||||
Mode string `json:"mode"`
|
Mode string `json:"mode"`
|
||||||
@@ -48,6 +51,7 @@ func main() {
|
|||||||
|
|
||||||
log.Printf("lingma-ipc-proxy listening on http://%s", addr)
|
log.Printf("lingma-ipc-proxy listening on http://%s", addr)
|
||||||
log.Printf("session mode: %s", cfg.SessionMode)
|
log.Printf("session mode: %s", cfg.SessionMode)
|
||||||
|
log.Printf("transport: %s", cfg.Transport)
|
||||||
log.Printf("mode: %s", cfg.Mode)
|
log.Printf("mode: %s", cfg.Mode)
|
||||||
if configPath != "" {
|
if configPath != "" {
|
||||||
log.Printf("config file: %s", configPath)
|
log.Printf("config file: %s", configPath)
|
||||||
@@ -81,6 +85,7 @@ func loadConfig() (service.Config, string) {
|
|||||||
cfg := service.Config{
|
cfg := service.Config{
|
||||||
Host: "127.0.0.1",
|
Host: "127.0.0.1",
|
||||||
Port: 8095,
|
Port: 8095,
|
||||||
|
Transport: lingmaipc.TransportAuto,
|
||||||
Cwd: currentDir(),
|
Cwd: currentDir(),
|
||||||
Mode: "agent",
|
Mode: "agent",
|
||||||
ShellType: "powershell",
|
ShellType: "powershell",
|
||||||
@@ -101,7 +106,9 @@ func loadConfig() (service.Config, string) {
|
|||||||
|
|
||||||
host := flag.String("host", cfg.Host, "Listen host")
|
host := flag.String("host", cfg.Host, "Listen host")
|
||||||
port := flag.Int("port", cfg.Port, "Listen port")
|
port := flag.Int("port", cfg.Port, "Listen port")
|
||||||
|
transport := flag.String("transport", string(cfg.Transport), "Lingma transport: auto, pipe, websocket")
|
||||||
pipe := flag.String("pipe", cfg.Pipe, "Explicit Lingma named pipe path")
|
pipe := flag.String("pipe", cfg.Pipe, "Explicit Lingma named pipe path")
|
||||||
|
wsURL := flag.String("ws-url", cfg.WebSocketURL, "Explicit Lingma local websocket URL")
|
||||||
cwd := flag.String("cwd", cfg.Cwd, "Working directory used when creating Lingma sessions")
|
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")
|
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")
|
mode := flag.String("mode", cfg.Mode, "Lingma ACP mode value")
|
||||||
@@ -112,11 +119,14 @@ func loadConfig() (service.Config, string) {
|
|||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
parsedSessionMode := parseSessionMode(*sessionMode)
|
parsedSessionMode := parseSessionMode(*sessionMode)
|
||||||
|
parsedTransport := parseTransport(*transport)
|
||||||
finalConfigPath := strings.TrimSpace(*config)
|
finalConfigPath := strings.TrimSpace(*config)
|
||||||
|
|
||||||
cfg.Host = strings.TrimSpace(*host)
|
cfg.Host = strings.TrimSpace(*host)
|
||||||
cfg.Port = *port
|
cfg.Port = *port
|
||||||
|
cfg.Transport = parsedTransport
|
||||||
cfg.Pipe = strings.TrimSpace(*pipe)
|
cfg.Pipe = strings.TrimSpace(*pipe)
|
||||||
|
cfg.WebSocketURL = strings.TrimSpace(*wsURL)
|
||||||
cfg.Cwd = strings.TrimSpace(*cwd)
|
cfg.Cwd = strings.TrimSpace(*cwd)
|
||||||
cfg.CurrentFilePath = strings.TrimSpace(*currentFilePath)
|
cfg.CurrentFilePath = strings.TrimSpace(*currentFilePath)
|
||||||
cfg.Mode = strings.TrimSpace(*mode)
|
cfg.Mode = strings.TrimSpace(*mode)
|
||||||
@@ -166,9 +176,15 @@ func overlayFileConfig(dst *service.Config, src fileConfig) {
|
|||||||
if src.Port > 0 {
|
if src.Port > 0 {
|
||||||
dst.Port = src.Port
|
dst.Port = src.Port
|
||||||
}
|
}
|
||||||
|
if strings.TrimSpace(src.Transport) != "" {
|
||||||
|
dst.Transport = parseTransport(src.Transport)
|
||||||
|
}
|
||||||
if strings.TrimSpace(src.Pipe) != "" {
|
if strings.TrimSpace(src.Pipe) != "" {
|
||||||
dst.Pipe = strings.TrimSpace(src.Pipe)
|
dst.Pipe = strings.TrimSpace(src.Pipe)
|
||||||
}
|
}
|
||||||
|
if strings.TrimSpace(src.WebSocketURL) != "" {
|
||||||
|
dst.WebSocketURL = strings.TrimSpace(src.WebSocketURL)
|
||||||
|
}
|
||||||
if strings.TrimSpace(src.Cwd) != "" {
|
if strings.TrimSpace(src.Cwd) != "" {
|
||||||
dst.Cwd = strings.TrimSpace(src.Cwd)
|
dst.Cwd = strings.TrimSpace(src.Cwd)
|
||||||
}
|
}
|
||||||
@@ -196,9 +212,15 @@ func overlayEnvConfig(dst *service.Config) {
|
|||||||
if value := envInt("LINGMA_PROXY_PORT", 0); value > 0 {
|
if value := envInt("LINGMA_PROXY_PORT", 0); value > 0 {
|
||||||
dst.Port = value
|
dst.Port = value
|
||||||
}
|
}
|
||||||
|
if value := strings.TrimSpace(os.Getenv("LINGMA_PROXY_TRANSPORT")); value != "" {
|
||||||
|
dst.Transport = parseTransport(value)
|
||||||
|
}
|
||||||
if value := strings.TrimSpace(os.Getenv("LINGMA_IPC_PIPE")); value != "" {
|
if value := strings.TrimSpace(os.Getenv("LINGMA_IPC_PIPE")); value != "" {
|
||||||
dst.Pipe = value
|
dst.Pipe = value
|
||||||
}
|
}
|
||||||
|
if value := strings.TrimSpace(os.Getenv("LINGMA_PROXY_WS_URL")); value != "" {
|
||||||
|
dst.WebSocketURL = value
|
||||||
|
}
|
||||||
if value := strings.TrimSpace(os.Getenv("LINGMA_PROXY_CWD")); value != "" {
|
if value := strings.TrimSpace(os.Getenv("LINGMA_PROXY_CWD")); value != "" {
|
||||||
dst.Cwd = value
|
dst.Cwd = value
|
||||||
}
|
}
|
||||||
@@ -230,6 +252,14 @@ func parseSessionMode(value string) service.SessionMode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseTransport(value string) lingmaipc.Transport {
|
||||||
|
transport, err := lingmaipc.ParseTransport(value)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
return transport
|
||||||
|
}
|
||||||
|
|
||||||
func lookupArgValue(flagName string) string {
|
func lookupArgValue(flagName string) string {
|
||||||
for i := 1; i < len(os.Args); i++ {
|
for i := 1; i < len(os.Args); i++ {
|
||||||
arg := os.Args[i]
|
arg := os.Args[i]
|
||||||
@@ -269,4 +299,3 @@ func valueOr(value string, fallback string) string {
|
|||||||
}
|
}
|
||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
{
|
{
|
||||||
"host": "127.0.0.1",
|
"host": "127.0.0.1",
|
||||||
"port": 8095,
|
"port": 8095,
|
||||||
|
"transport": "auto",
|
||||||
"mode": "chat",
|
"mode": "chat",
|
||||||
"session_mode": "reuse",
|
"session_mode": "reuse",
|
||||||
"timeout": 120,
|
"timeout": 120,
|
||||||
"cwd": "C:/Workspace/Personal/lingma-ipc-proxy",
|
"cwd": "C:/Workspace/Personal/lingma-ipc-proxy",
|
||||||
"shell_type": "powershell",
|
"shell_type": "powershell",
|
||||||
"current_file_path": "",
|
"current_file_path": "",
|
||||||
"pipe": ""
|
"pipe": "",
|
||||||
|
"websocket_url": ""
|
||||||
}
|
}
|
||||||
|
|||||||
6
go.mod
6
go.mod
@@ -3,6 +3,8 @@ module lingma-ipc-proxy
|
|||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
github.com/Microsoft/go-winio v0.6.2
|
||||||
golang.org/x/sys v0.10.0 // indirect
|
github.com/gorilla/websocket v1.5.3
|
||||||
)
|
)
|
||||||
|
|
||||||
|
require golang.org/x/sys v0.10.0 // indirect
|
||||||
|
|||||||
2
go.sum
2
go.sum
@@ -1,4 +1,6 @@
|
|||||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||||
|
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 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA=
|
||||||
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package lingmaipc
|
package lingmaipc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
@@ -9,17 +8,12 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
|
||||||
"os"
|
"os"
|
||||||
"runtime"
|
"runtime"
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
winio "github.com/Microsoft/go-winio"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -65,56 +59,18 @@ type responseEnvelope struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
conn net.Conn
|
transport framedTransport
|
||||||
reader *bufio.Reader
|
kind Transport
|
||||||
writeMu sync.Mutex
|
pendingMu sync.Mutex
|
||||||
pendingMu sync.Mutex
|
pending map[int]chan responseEnvelope
|
||||||
pending map[int]chan responseEnvelope
|
subsMu sync.RWMutex
|
||||||
subsMu sync.RWMutex
|
subs map[int]chan Notification
|
||||||
subs map[int]chan Notification
|
nextID atomic.Int64
|
||||||
nextID atomic.Int64
|
nextSubID atomic.Int64
|
||||||
nextSubID atomic.Int64
|
closeOnce sync.Once
|
||||||
closeOnce sync.Once
|
closed chan struct{}
|
||||||
closed chan struct{}
|
closeErr atomic.Value
|
||||||
closeErr atomic.Value
|
responseMu sync.Mutex
|
||||||
}
|
|
||||||
|
|
||||||
func ResolvePipePath(explicit string) (string, error) {
|
|
||||||
if runtime.GOOS != "windows" {
|
|
||||||
return "", errors.New("Lingma IPC proxy currently requires Windows")
|
|
||||||
}
|
|
||||||
|
|
||||||
if pipe := strings.TrimSpace(explicit); pipe != "" {
|
|
||||||
return normalizePipePath(pipe), nil
|
|
||||||
}
|
|
||||||
if pipe := strings.TrimSpace(os.Getenv("LINGMA_IPC_PIPE")); pipe != "" {
|
|
||||||
return normalizePipePath(pipe), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
entries, err := os.ReadDir(PipeDir)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("enumerate Lingma named pipes: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
names := make([]string, 0, len(entries))
|
|
||||||
for _, entry := range entries {
|
|
||||||
name := entry.Name()
|
|
||||||
if strings.HasPrefix(name, PipePrefix) {
|
|
||||||
names = append(names, name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Strings(names)
|
|
||||||
if len(names) == 0 {
|
|
||||||
return "", errors.New("no active Lingma named pipe was found")
|
|
||||||
}
|
|
||||||
return PipeDir + names[len(names)-1], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizePipePath(pipe string) string {
|
|
||||||
if strings.HasPrefix(pipe, PipeDir) {
|
|
||||||
return pipe
|
|
||||||
}
|
|
||||||
return PipeDir + pipe
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func DefaultShellType() string {
|
func DefaultShellType() string {
|
||||||
@@ -162,43 +118,27 @@ func CreateMeta(opts MetaOptions) map[string]any {
|
|||||||
return meta
|
return meta
|
||||||
}
|
}
|
||||||
|
|
||||||
func Connect(ctx context.Context, pipePath string) (*Client, error) {
|
func Connect(ctx context.Context, opts DialOptions) (*Client, error) {
|
||||||
if runtime.GOOS != "windows" {
|
transport, err := connectTransport(ctx, opts)
|
||||||
return nil, errors.New("Lingma IPC proxy currently requires Windows")
|
|
||||||
}
|
|
||||||
|
|
||||||
conn, err := winio.DialPipeContext(ctx, pipePath)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("connect Lingma IPC pipe %s: %w", pipePath, err)
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
client := &Client{
|
client := &Client{
|
||||||
conn: conn,
|
transport: transport,
|
||||||
reader: bufio.NewReader(conn),
|
kind: opts.Transport,
|
||||||
pending: make(map[int]chan responseEnvelope),
|
pending: make(map[int]chan responseEnvelope),
|
||||||
subs: make(map[int]chan Notification),
|
subs: make(map[int]chan Notification),
|
||||||
closed: make(chan struct{}),
|
closed: make(chan struct{}),
|
||||||
}
|
}
|
||||||
go client.readLoop()
|
go client.readLoop()
|
||||||
return client, nil
|
return client, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) Request(ctx context.Context, method string, params any, out any) error {
|
func (c *Client) Request(ctx context.Context, method string, params any, out any) error {
|
||||||
if params == nil {
|
payload, id, err := c.buildRequest(method, params)
|
||||||
params = map[string]any{}
|
|
||||||
}
|
|
||||||
|
|
||||||
id := int(c.nextID.Add(1))
|
|
||||||
payload := map[string]any{
|
|
||||||
"jsonrpc": "2.0",
|
|
||||||
"id": id,
|
|
||||||
"method": method,
|
|
||||||
"params": params,
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := json.Marshal(payload)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("marshal request %s: %w", method, err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
responseCh := make(chan responseEnvelope, 1)
|
responseCh := make(chan responseEnvelope, 1)
|
||||||
@@ -206,7 +146,7 @@ func (c *Client) Request(ctx context.Context, method string, params any, out any
|
|||||||
c.pending[id] = responseCh
|
c.pending[id] = responseCh
|
||||||
c.pendingMu.Unlock()
|
c.pendingMu.Unlock()
|
||||||
|
|
||||||
if err := c.writeFrame(body); err != nil {
|
if err := c.transport.WriteFrame(payload); err != nil {
|
||||||
c.pendingMu.Lock()
|
c.pendingMu.Lock()
|
||||||
delete(c.pending, id)
|
delete(c.pending, id)
|
||||||
c.pendingMu.Unlock()
|
c.pendingMu.Unlock()
|
||||||
@@ -235,6 +175,14 @@ func (c *Client) Request(ctx context.Context, method string, params any, out any
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Client) Send(method string, params any) error {
|
||||||
|
payload, _, err := c.buildRequest(method, params)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.transport.WriteFrame(payload)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) Subscribe() (<-chan Notification, func()) {
|
func (c *Client) Subscribe() (<-chan Notification, func()) {
|
||||||
id := int(c.nextSubID.Add(1))
|
id := int(c.nextSubID.Add(1))
|
||||||
ch := make(chan Notification, 2048)
|
ch := make(chan Notification, 2048)
|
||||||
@@ -253,10 +201,21 @@ func (c *Client) Subscribe() (<-chan Notification, func()) {
|
|||||||
return ch, cancel
|
return ch, cancel
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Client) Address() string {
|
||||||
|
if c.transport == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return c.transport.Address()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Transport() Transport {
|
||||||
|
return c.kind
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) Close() error {
|
func (c *Client) Close() error {
|
||||||
c.closeOnce.Do(func() {
|
c.closeOnce.Do(func() {
|
||||||
close(c.closed)
|
close(c.closed)
|
||||||
if err := c.conn.Close(); err != nil {
|
if err := c.transport.Close(); err != nil {
|
||||||
c.closeErr.Store(err)
|
c.closeErr.Store(err)
|
||||||
}
|
}
|
||||||
c.failPending(io.EOF)
|
c.failPending(io.EOF)
|
||||||
@@ -268,26 +227,32 @@ func (c *Client) Close() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) writeFrame(body []byte) error {
|
func (c *Client) buildRequest(method string, params any) ([]byte, int, error) {
|
||||||
c.writeMu.Lock()
|
if params == nil {
|
||||||
defer c.writeMu.Unlock()
|
params = map[string]any{}
|
||||||
|
}
|
||||||
|
|
||||||
frame := []byte(fmt.Sprintf("Content-Length: %d\r\n\r\n", len(body)))
|
id := int(c.nextID.Add(1))
|
||||||
if _, err := c.conn.Write(frame); err != nil {
|
payload := map[string]any{
|
||||||
return fmt.Errorf("write frame header: %w", err)
|
"jsonrpc": "2.0",
|
||||||
|
"id": id,
|
||||||
|
"method": method,
|
||||||
|
"params": params,
|
||||||
}
|
}
|
||||||
if _, err := c.conn.Write(body); err != nil {
|
|
||||||
return fmt.Errorf("write frame body: %w", err)
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("marshal request %s: %w", method, err)
|
||||||
}
|
}
|
||||||
return nil
|
return body, id, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) readLoop() {
|
func (c *Client) readLoop() {
|
||||||
defer c.Close()
|
defer c.Close()
|
||||||
for {
|
for {
|
||||||
body, err := c.readFrame()
|
body, err := c.transport.ReadFrame()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if !errors.Is(err, net.ErrClosed) && !errors.Is(err, io.EOF) {
|
if !errors.Is(err, io.EOF) {
|
||||||
c.closeErr.Store(err)
|
c.closeErr.Store(err)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@@ -299,8 +264,11 @@ func (c *Client) readLoop() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if envelope.Method != "" && envelope.ID == nil {
|
if envelope.Method != "" {
|
||||||
c.broadcast(Notification{JSONRPC: envelope.JSONRPC, Method: envelope.Method, Params: envelope.Params})
|
c.broadcast(Notification{JSONRPC: envelope.JSONRPC, Method: envelope.Method, Params: envelope.Params})
|
||||||
|
if envelope.ID != nil {
|
||||||
|
_ = c.sendEmptyResponse(*envelope.ID)
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,35 +289,19 @@ func (c *Client) readLoop() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) readFrame() ([]byte, error) {
|
func (c *Client) sendEmptyResponse(id int) error {
|
||||||
contentLength := -1
|
c.responseMu.Lock()
|
||||||
for {
|
defer c.responseMu.Unlock()
|
||||||
line, err := c.reader.ReadString('\n')
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if line == "\r\n" {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
line = strings.TrimSpace(line)
|
|
||||||
if strings.HasPrefix(strings.ToLower(line), "content-length:") {
|
|
||||||
raw := strings.TrimSpace(line[len("content-length:"):])
|
|
||||||
n, err := strconv.Atoi(raw)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("parse content length %q: %w", raw, err)
|
|
||||||
}
|
|
||||||
contentLength = n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if contentLength < 0 {
|
|
||||||
return nil, errors.New("missing Content-Length header")
|
|
||||||
}
|
|
||||||
|
|
||||||
body := make([]byte, contentLength)
|
body, err := json.Marshal(map[string]any{
|
||||||
if _, err := io.ReadFull(c.reader, body); err != nil {
|
"jsonrpc": "2.0",
|
||||||
return nil, err
|
"id": id,
|
||||||
|
"result": nil,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
return body, nil
|
return c.transport.WriteFrame(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) broadcast(notification Notification) {
|
func (c *Client) broadcast(notification Notification) {
|
||||||
|
|||||||
344
internal/lingmaipc/transport.go
Normal file
344
internal/lingmaipc/transport.go
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
package lingmaipc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"runtime"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
winio "github.com/Microsoft/go-winio"
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Transport string
|
||||||
|
|
||||||
|
const (
|
||||||
|
TransportAuto Transport = "auto"
|
||||||
|
TransportPipe Transport = "pipe"
|
||||||
|
TransportWebSocket Transport = "websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DialOptions struct {
|
||||||
|
Transport Transport
|
||||||
|
PipePath string
|
||||||
|
WebSocketURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
type framedTransport interface {
|
||||||
|
ReadFrame() ([]byte, error)
|
||||||
|
WriteFrame([]byte) error
|
||||||
|
Close() error
|
||||||
|
Address() string
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseTransport(value string) (Transport, error) {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||||
|
case "", string(TransportAuto):
|
||||||
|
return TransportAuto, nil
|
||||||
|
case string(TransportPipe):
|
||||||
|
return TransportPipe, nil
|
||||||
|
case "ws", string(TransportWebSocket):
|
||||||
|
return TransportWebSocket, nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("invalid Lingma transport %q; expected auto, pipe, or websocket", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResolveDialOptions(transport Transport, explicitPipe string, explicitWebSocketURL string) (DialOptions, error) {
|
||||||
|
switch transport {
|
||||||
|
case "", TransportAuto:
|
||||||
|
if hasConfiguredWebSocketURL(explicitWebSocketURL) {
|
||||||
|
wsURL, err := ResolveWebSocketURL(explicitWebSocketURL)
|
||||||
|
if err != nil {
|
||||||
|
return DialOptions{}, err
|
||||||
|
}
|
||||||
|
return DialOptions{Transport: TransportWebSocket, WebSocketURL: wsURL}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
pipePath, pipeErr := ResolvePipePath(explicitPipe)
|
||||||
|
if pipeErr == nil {
|
||||||
|
return DialOptions{Transport: TransportPipe, PipePath: pipePath}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
wsURL, wsErr := ResolveWebSocketURL(explicitWebSocketURL)
|
||||||
|
if wsErr == nil {
|
||||||
|
return DialOptions{Transport: TransportWebSocket, WebSocketURL: wsURL}, nil
|
||||||
|
}
|
||||||
|
return DialOptions{}, fmt.Errorf("resolve Lingma transport automatically: pipe: %w; websocket: %v", pipeErr, wsErr)
|
||||||
|
case TransportPipe:
|
||||||
|
pipePath, err := ResolvePipePath(explicitPipe)
|
||||||
|
if err != nil {
|
||||||
|
return DialOptions{}, err
|
||||||
|
}
|
||||||
|
return DialOptions{Transport: TransportPipe, PipePath: pipePath}, nil
|
||||||
|
case TransportWebSocket:
|
||||||
|
wsURL, err := ResolveWebSocketURL(explicitWebSocketURL)
|
||||||
|
if err != nil {
|
||||||
|
return DialOptions{}, err
|
||||||
|
}
|
||||||
|
return DialOptions{Transport: TransportWebSocket, WebSocketURL: wsURL}, nil
|
||||||
|
default:
|
||||||
|
return DialOptions{}, fmt.Errorf("unsupported Lingma transport %q", transport)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResolvePipePath(explicit string) (string, error) {
|
||||||
|
if runtime.GOOS != "windows" {
|
||||||
|
return "", errors.New("Lingma pipe transport currently requires Windows")
|
||||||
|
}
|
||||||
|
|
||||||
|
if pipe := strings.TrimSpace(explicit); pipe != "" {
|
||||||
|
return normalizePipePath(pipe), nil
|
||||||
|
}
|
||||||
|
if pipe := strings.TrimSpace(os.Getenv("LINGMA_IPC_PIPE")); pipe != "" {
|
||||||
|
return normalizePipePath(pipe), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(PipeDir)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("enumerate Lingma named pipes: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
names := make([]string, 0, len(entries))
|
||||||
|
for _, entry := range entries {
|
||||||
|
name := entry.Name()
|
||||||
|
if strings.HasPrefix(name, PipePrefix) {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
if len(names) == 0 {
|
||||||
|
return "", errors.New("no active Lingma named pipe was found")
|
||||||
|
}
|
||||||
|
return PipeDir + names[len(names)-1], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResolveWebSocketURL(explicit string) (string, error) {
|
||||||
|
value := strings.TrimSpace(explicit)
|
||||||
|
if value == "" {
|
||||||
|
value = strings.TrimSpace(os.Getenv("LINGMA_PROXY_WS_URL"))
|
||||||
|
}
|
||||||
|
if value == "" {
|
||||||
|
return "", errors.New("no Lingma websocket URL configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := url.Parse(value)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("parse Lingma websocket URL %q: %w", value, err)
|
||||||
|
}
|
||||||
|
if parsed.Scheme != "ws" && parsed.Scheme != "wss" {
|
||||||
|
return "", fmt.Errorf("Lingma websocket URL must start with ws:// or wss://: %q", value)
|
||||||
|
}
|
||||||
|
if parsed.Host == "" {
|
||||||
|
return "", fmt.Errorf("Lingma websocket URL is missing a host: %q", value)
|
||||||
|
}
|
||||||
|
if parsed.Path == "" {
|
||||||
|
parsed.Path = "/"
|
||||||
|
}
|
||||||
|
return parsed.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasConfiguredWebSocketURL(explicit string) bool {
|
||||||
|
return strings.TrimSpace(explicit) != "" || strings.TrimSpace(os.Getenv("LINGMA_PROXY_WS_URL")) != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizePipePath(pipe string) string {
|
||||||
|
if strings.HasPrefix(pipe, PipeDir) {
|
||||||
|
return pipe
|
||||||
|
}
|
||||||
|
return PipeDir + pipe
|
||||||
|
}
|
||||||
|
|
||||||
|
func connectTransport(ctx context.Context, opts DialOptions) (framedTransport, error) {
|
||||||
|
switch opts.Transport {
|
||||||
|
case TransportPipe:
|
||||||
|
return connectPipeTransport(ctx, opts.PipePath)
|
||||||
|
case TransportWebSocket:
|
||||||
|
return connectWebSocketTransport(ctx, opts.WebSocketURL)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported Lingma transport %q", opts.Transport)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type pipeTransport struct {
|
||||||
|
path string
|
||||||
|
conn net.Conn
|
||||||
|
reader *framedReader
|
||||||
|
write sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func connectPipeTransport(ctx context.Context, pipePath string) (*pipeTransport, error) {
|
||||||
|
conn, err := winio.DialPipeContext(ctx, pipePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("connect Lingma IPC pipe %s: %w", pipePath, err)
|
||||||
|
}
|
||||||
|
return &pipeTransport{
|
||||||
|
path: pipePath,
|
||||||
|
conn: conn,
|
||||||
|
reader: newFramedReader(conn),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *pipeTransport) ReadFrame() ([]byte, error) {
|
||||||
|
return t.reader.ReadFrame()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *pipeTransport) WriteFrame(body []byte) error {
|
||||||
|
t.write.Lock()
|
||||||
|
defer t.write.Unlock()
|
||||||
|
|
||||||
|
frame := []byte(fmt.Sprintf("Content-Length: %d\r\n\r\n", len(body)))
|
||||||
|
if _, err := t.conn.Write(frame); err != nil {
|
||||||
|
return fmt.Errorf("write frame header: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := t.conn.Write(body); err != nil {
|
||||||
|
return fmt.Errorf("write frame body: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *pipeTransport) Close() error {
|
||||||
|
return t.conn.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *pipeTransport) Address() string {
|
||||||
|
return t.path
|
||||||
|
}
|
||||||
|
|
||||||
|
type websocketTransport struct {
|
||||||
|
url string
|
||||||
|
conn *websocket.Conn
|
||||||
|
buffer bytes.Buffer
|
||||||
|
writeMu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func connectWebSocketTransport(ctx context.Context, wsURL string) (*websocketTransport, error) {
|
||||||
|
dialer := websocket.Dialer{HandshakeTimeout: 5 * time.Second}
|
||||||
|
conn, _, err := dialer.DialContext(ctx, wsURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("connect Lingma websocket %s: %w", wsURL, err)
|
||||||
|
}
|
||||||
|
return &websocketTransport{url: wsURL, conn: conn}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *websocketTransport) ReadFrame() ([]byte, error) {
|
||||||
|
for {
|
||||||
|
if body, ok, err := tryReadBufferedFrame(&t.buffer); ok || err != nil {
|
||||||
|
return body, err
|
||||||
|
}
|
||||||
|
|
||||||
|
messageType, payload, err := t.conn.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if messageType != websocket.TextMessage && messageType != websocket.BinaryMessage {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t.buffer.Write(payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *websocketTransport) WriteFrame(body []byte) error {
|
||||||
|
t.writeMu.Lock()
|
||||||
|
defer t.writeMu.Unlock()
|
||||||
|
|
||||||
|
frame := []byte(fmt.Sprintf("Content-Length: %d\r\n\r\n", len(body)))
|
||||||
|
frame = append(frame, body...)
|
||||||
|
if err := t.conn.WriteMessage(websocket.TextMessage, frame); err != nil {
|
||||||
|
return fmt.Errorf("write websocket frame: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *websocketTransport) Close() error {
|
||||||
|
return t.conn.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *websocketTransport) Address() string {
|
||||||
|
return t.url
|
||||||
|
}
|
||||||
|
|
||||||
|
type framedReader struct {
|
||||||
|
reader *bufio.Reader
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFramedReader(r io.Reader) *framedReader {
|
||||||
|
return &framedReader{reader: bufio.NewReader(r)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *framedReader) ReadFrame() ([]byte, error) {
|
||||||
|
contentLength := -1
|
||||||
|
for {
|
||||||
|
line, err := r.reader.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if line == "\r\n" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(strings.ToLower(line), "content-length:") {
|
||||||
|
raw := strings.TrimSpace(line[len("content-length:"):])
|
||||||
|
n, err := strconv.Atoi(raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse content length %q: %w", raw, err)
|
||||||
|
}
|
||||||
|
contentLength = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if contentLength < 0 {
|
||||||
|
return nil, errors.New("missing Content-Length header")
|
||||||
|
}
|
||||||
|
|
||||||
|
body := make([]byte, contentLength)
|
||||||
|
if _, err := io.ReadFull(r.reader, body); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func tryReadBufferedFrame(buffer *bytes.Buffer) ([]byte, bool, error) {
|
||||||
|
data := buffer.Bytes()
|
||||||
|
headerEnd := bytes.Index(data, []byte("\r\n\r\n"))
|
||||||
|
if headerEnd < 0 {
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
contentLength := -1
|
||||||
|
for _, rawLine := range bytes.Split(data[:headerEnd], []byte("\r\n")) {
|
||||||
|
line := strings.TrimSpace(string(rawLine))
|
||||||
|
if strings.HasPrefix(strings.ToLower(line), "content-length:") {
|
||||||
|
raw := strings.TrimSpace(line[len("content-length:"):])
|
||||||
|
n, err := strconv.Atoi(raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, fmt.Errorf("parse content length %q: %w", raw, err)
|
||||||
|
}
|
||||||
|
contentLength = n
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if contentLength < 0 {
|
||||||
|
return nil, false, errors.New("missing Content-Length header")
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyStart := headerEnd + len("\r\n\r\n")
|
||||||
|
if len(data[bodyStart:]) < contentLength {
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
frame := make([]byte, contentLength)
|
||||||
|
copy(frame, data[bodyStart:bodyStart+contentLength])
|
||||||
|
buffer.Next(bodyStart + contentLength)
|
||||||
|
return frame, true, nil
|
||||||
|
}
|
||||||
@@ -25,7 +25,9 @@ const (
|
|||||||
type Config struct {
|
type Config struct {
|
||||||
Host string
|
Host string
|
||||||
Port int
|
Port int
|
||||||
|
Transport lingmaipc.Transport
|
||||||
Pipe string
|
Pipe string
|
||||||
|
WebSocketURL string
|
||||||
Cwd string
|
Cwd string
|
||||||
CurrentFilePath string
|
CurrentFilePath string
|
||||||
Mode string
|
Mode string
|
||||||
@@ -57,6 +59,8 @@ type ChatResult struct {
|
|||||||
UsedTokens int
|
UsedTokens int
|
||||||
LimitTokens int
|
LimitTokens int
|
||||||
PipePath string
|
PipePath string
|
||||||
|
Endpoint string
|
||||||
|
Transport string
|
||||||
EffectiveSession SessionMode
|
EffectiveSession SessionMode
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,6 +81,8 @@ type Model struct {
|
|||||||
|
|
||||||
type State struct {
|
type State struct {
|
||||||
PipePath string `json:"pipe_path,omitempty"`
|
PipePath string `json:"pipe_path,omitempty"`
|
||||||
|
Endpoint string `json:"endpoint,omitempty"`
|
||||||
|
Transport string `json:"transport,omitempty"`
|
||||||
Connected bool `json:"connected"`
|
Connected bool `json:"connected"`
|
||||||
StickySessionID string `json:"sticky_session_id,omitempty"`
|
StickySessionID string `json:"sticky_session_id,omitempty"`
|
||||||
SessionMode SessionMode `json:"session_mode"`
|
SessionMode SessionMode `json:"session_mode"`
|
||||||
@@ -87,6 +93,8 @@ type Service struct {
|
|||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
client *lingmaipc.Client
|
client *lingmaipc.Client
|
||||||
pipePath string
|
pipePath string
|
||||||
|
endpoint string
|
||||||
|
transport lingmaipc.Transport
|
||||||
stickySessionID string
|
stickySessionID string
|
||||||
stickyModelID string
|
stickyModelID string
|
||||||
}
|
}
|
||||||
@@ -114,6 +122,9 @@ func New(cfg Config) *Service {
|
|||||||
if cfg.Timeout <= 0 {
|
if cfg.Timeout <= 0 {
|
||||||
cfg.Timeout = 120 * time.Second
|
cfg.Timeout = 120 * time.Second
|
||||||
}
|
}
|
||||||
|
if cfg.Transport == "" {
|
||||||
|
cfg.Transport = lingmaipc.TransportAuto
|
||||||
|
}
|
||||||
if cfg.SessionMode == "" {
|
if cfg.SessionMode == "" {
|
||||||
cfg.SessionMode = SessionModeAuto
|
cfg.SessionMode = SessionModeAuto
|
||||||
}
|
}
|
||||||
@@ -136,6 +147,8 @@ func (s *Service) State() State {
|
|||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
return State{
|
return State{
|
||||||
PipePath: s.pipePath,
|
PipePath: s.pipePath,
|
||||||
|
Endpoint: s.endpoint,
|
||||||
|
Transport: string(s.transport),
|
||||||
Connected: s.client != nil,
|
Connected: s.client != nil,
|
||||||
StickySessionID: s.stickySessionID,
|
StickySessionID: s.stickySessionID,
|
||||||
SessionMode: s.cfg.SessionMode,
|
SessionMode: s.cfg.SessionMode,
|
||||||
@@ -282,6 +295,7 @@ func (s *Service) buildChatResult(
|
|||||||
runResult *promptRunResult,
|
runResult *promptRunResult,
|
||||||
effectiveMode SessionMode,
|
effectiveMode SessionMode,
|
||||||
) *ChatResult {
|
) *ChatResult {
|
||||||
|
endpoint := s.currentPipePath()
|
||||||
return &ChatResult{
|
return &ChatResult{
|
||||||
Text: runResult.AssistantText,
|
Text: runResult.AssistantText,
|
||||||
Model: valueOr(strings.TrimSpace(req.Model), "lingma"),
|
Model: valueOr(strings.TrimSpace(req.Model), "lingma"),
|
||||||
@@ -293,7 +307,9 @@ func (s *Service) buildChatResult(
|
|||||||
StopReason: nestedString(runResult.PromptResult, "stopReason"),
|
StopReason: nestedString(runResult.PromptResult, "stopReason"),
|
||||||
UsedTokens: int(nestedInt64(runResult.ContextUsage, "usedTokens")),
|
UsedTokens: int(nestedInt64(runResult.ContextUsage, "usedTokens")),
|
||||||
LimitTokens: int(nestedInt64(runResult.ContextUsage, "limitTokens")),
|
LimitTokens: int(nestedInt64(runResult.ContextUsage, "limitTokens")),
|
||||||
PipePath: s.currentPipePath(),
|
PipePath: endpoint,
|
||||||
|
Endpoint: endpoint,
|
||||||
|
Transport: string(s.currentTransport()),
|
||||||
EffectiveSession: effectiveMode,
|
EffectiveSession: effectiveMode,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -309,11 +325,11 @@ func (s *Service) ensureConnectedLocked(ctx context.Context) (*lingmaipc.Client,
|
|||||||
return s.client, nil
|
return s.client, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
pipePath, err := lingmaipc.ResolvePipePath(s.cfg.Pipe)
|
dialOptions, err := lingmaipc.ResolveDialOptions(s.cfg.Transport, s.cfg.Pipe, s.cfg.WebSocketURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
client, err := lingmaipc.Connect(ctx, pipePath)
|
client, err := lingmaipc.Connect(ctx, dialOptions)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -327,19 +343,25 @@ func (s *Service) ensureConnectedLocked(ctx context.Context) (*lingmaipc.Client,
|
|||||||
}
|
}
|
||||||
|
|
||||||
s.client = client
|
s.client = client
|
||||||
s.pipePath = pipePath
|
s.pipePath = dialOptions.PipePath
|
||||||
|
s.endpoint = client.Address()
|
||||||
|
s.transport = client.Transport()
|
||||||
return client, nil
|
return client, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) closeClientLocked() error {
|
func (s *Service) closeClientLocked() error {
|
||||||
if s.client == nil {
|
if s.client == nil {
|
||||||
s.pipePath = ""
|
s.pipePath = ""
|
||||||
|
s.endpoint = ""
|
||||||
|
s.transport = ""
|
||||||
s.clearStickyLocked()
|
s.clearStickyLocked()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
client := s.client
|
client := s.client
|
||||||
s.client = nil
|
s.client = nil
|
||||||
s.pipePath = ""
|
s.pipePath = ""
|
||||||
|
s.endpoint = ""
|
||||||
|
s.transport = ""
|
||||||
s.clearStickyLocked()
|
s.clearStickyLocked()
|
||||||
return client.Close()
|
return client.Close()
|
||||||
}
|
}
|
||||||
@@ -388,9 +410,18 @@ func (s *Service) clearStickyLocked() {
|
|||||||
func (s *Service) currentPipePath() string {
|
func (s *Service) currentPipePath() string {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
if strings.TrimSpace(s.endpoint) != "" {
|
||||||
|
return s.endpoint
|
||||||
|
}
|
||||||
return s.pipePath
|
return s.pipePath
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) currentTransport() lingmaipc.Transport {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.transport
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) resolveSessionLocked(ctx context.Context, client *lingmaipc.Client, mode SessionMode) (string, error) {
|
func (s *Service) resolveSessionLocked(ctx context.Context, client *lingmaipc.Client, mode SessionMode) (string, error) {
|
||||||
if mode == SessionModeReuse && strings.TrimSpace(s.stickySessionID) != "" {
|
if mode == SessionModeReuse && strings.TrimSpace(s.stickySessionID) != "" {
|
||||||
return s.stickySessionID, nil
|
return s.stickySessionID, nil
|
||||||
@@ -436,18 +467,17 @@ func (s *Service) runPromptLocked(
|
|||||||
notifications, cancel := client.Subscribe()
|
notifications, cancel := client.Subscribe()
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
promptResult := map[string]any{}
|
if err := client.Send("session/prompt", map[string]any{
|
||||||
if err := client.Request(ctx, "session/prompt", map[string]any{
|
|
||||||
"sessionId": sessionID,
|
"sessionId": sessionID,
|
||||||
"prompt": []map[string]any{
|
"prompt": []map[string]any{
|
||||||
{"type": "text", "text": text},
|
{"type": "text", "text": text},
|
||||||
},
|
},
|
||||||
"_meta": meta,
|
"_meta": meta,
|
||||||
}, &promptResult); err != nil {
|
}); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
result := &promptRunResult{PromptResult: promptResult}
|
result := &promptRunResult{PromptResult: map[string]any{}}
|
||||||
var builder strings.Builder
|
var builder strings.Builder
|
||||||
|
|
||||||
for {
|
for {
|
||||||
|
|||||||
Reference in New Issue
Block a user