mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-22 15:31:07 +00:00
fix(docs): run i18n through a local rpc client
This commit is contained in:
120
scripts/docs-i18n/pi_command.go
Normal file
120
scripts/docs-i18n/pi_command.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
envDocsPiExecutable = "OPENCLAW_DOCS_I18N_PI_EXECUTABLE"
|
||||
envDocsPiArgs = "OPENCLAW_DOCS_I18N_PI_ARGS"
|
||||
envDocsPiPackageVersion = "OPENCLAW_DOCS_I18N_PI_PACKAGE_VERSION"
|
||||
defaultPiPackageVersion = "0.58.3"
|
||||
)
|
||||
|
||||
type docsPiCommand struct {
|
||||
Executable string
|
||||
Args []string
|
||||
}
|
||||
|
||||
var (
|
||||
materializedPiRuntimeMu sync.Mutex
|
||||
materializedPiRuntimeCommand docsPiCommand
|
||||
materializedPiRuntimeErr error
|
||||
)
|
||||
|
||||
func resolveDocsPiCommand(ctx context.Context) (docsPiCommand, error) {
|
||||
if executable := strings.TrimSpace(os.Getenv(envDocsPiExecutable)); executable != "" {
|
||||
return docsPiCommand{
|
||||
Executable: executable,
|
||||
Args: strings.Fields(os.Getenv(envDocsPiArgs)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
piPath, err := exec.LookPath("pi")
|
||||
if err == nil && !shouldMaterializePiRuntime(piPath) {
|
||||
return docsPiCommand{Executable: piPath}, nil
|
||||
}
|
||||
|
||||
return ensureMaterializedPiRuntime(ctx)
|
||||
}
|
||||
|
||||
func shouldMaterializePiRuntime(piPath string) bool {
|
||||
realPath, err := filepath.EvalSymlinks(piPath)
|
||||
if err != nil {
|
||||
realPath = piPath
|
||||
}
|
||||
return strings.Contains(filepath.ToSlash(realPath), "/Projects/pi-mono/")
|
||||
}
|
||||
|
||||
func ensureMaterializedPiRuntime(ctx context.Context) (docsPiCommand, error) {
|
||||
materializedPiRuntimeMu.Lock()
|
||||
defer materializedPiRuntimeMu.Unlock()
|
||||
|
||||
if materializedPiRuntimeErr == nil && materializedPiRuntimeCommand.Executable != "" {
|
||||
return materializedPiRuntimeCommand, nil
|
||||
}
|
||||
|
||||
runtimeDir, err := getMaterializedPiRuntimeDir()
|
||||
if err != nil {
|
||||
materializedPiRuntimeErr = err
|
||||
return docsPiCommand{}, err
|
||||
}
|
||||
cliPath := filepath.Join(runtimeDir, "node_modules", "@mariozechner", "pi-coding-agent", "dist", "cli.js")
|
||||
if _, err := os.Stat(cliPath); errors.Is(err, os.ErrNotExist) {
|
||||
installCtx, cancel := context.WithTimeout(ctx, 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
if err := os.MkdirAll(runtimeDir, 0o755); err != nil {
|
||||
materializedPiRuntimeErr = err
|
||||
return docsPiCommand{}, err
|
||||
}
|
||||
|
||||
packageVersion := getMaterializedPiPackageVersion()
|
||||
install := exec.CommandContext(
|
||||
installCtx,
|
||||
"npm",
|
||||
"install",
|
||||
"--silent",
|
||||
"--no-audit",
|
||||
"--no-fund",
|
||||
fmt.Sprintf("@mariozechner/pi-coding-agent@%s", packageVersion),
|
||||
)
|
||||
install.Dir = runtimeDir
|
||||
install.Env = os.Environ()
|
||||
output, err := install.CombinedOutput()
|
||||
if err != nil {
|
||||
materializedPiRuntimeErr = fmt.Errorf("materialize pi runtime: %w (%s)", err, strings.TrimSpace(string(output)))
|
||||
return docsPiCommand{}, materializedPiRuntimeErr
|
||||
}
|
||||
}
|
||||
|
||||
materializedPiRuntimeCommand = docsPiCommand{
|
||||
Executable: "node",
|
||||
Args: []string{cliPath},
|
||||
}
|
||||
materializedPiRuntimeErr = nil
|
||||
return materializedPiRuntimeCommand, nil
|
||||
}
|
||||
|
||||
func getMaterializedPiRuntimeDir() (string, error) {
|
||||
cacheDir, err := os.UserCacheDir()
|
||||
if err != nil {
|
||||
cacheDir = os.TempDir()
|
||||
}
|
||||
return filepath.Join(cacheDir, "openclaw", "docs-i18n", "pi-runtime", getMaterializedPiPackageVersion()), nil
|
||||
}
|
||||
|
||||
func getMaterializedPiPackageVersion() string {
|
||||
if version := strings.TrimSpace(os.Getenv(envDocsPiPackageVersion)); version != "" {
|
||||
return version
|
||||
}
|
||||
return defaultPiPackageVersion
|
||||
}
|
||||
302
scripts/docs-i18n/pi_rpc_client.go
Normal file
302
scripts/docs-i18n/pi_rpc_client.go
Normal file
@@ -0,0 +1,302 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
type docsPiClientOptions struct {
|
||||
SystemPrompt string
|
||||
Thinking string
|
||||
}
|
||||
|
||||
type docsPiClient struct {
|
||||
process *exec.Cmd
|
||||
stdin io.WriteCloser
|
||||
stderr bytes.Buffer
|
||||
events chan piEvent
|
||||
promptLock sync.Mutex
|
||||
closeOnce sync.Once
|
||||
closed chan struct{}
|
||||
requestID uint64
|
||||
}
|
||||
|
||||
type piEvent struct {
|
||||
Type string
|
||||
Raw json.RawMessage
|
||||
}
|
||||
|
||||
type agentEndPayload struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
Messages []agentMessage `json:"messages"`
|
||||
}
|
||||
|
||||
type rpcResponse struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Command string `json:"command,omitempty"`
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type agentMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content json.RawMessage `json:"content"`
|
||||
StopReason string `json:"stopReason,omitempty"`
|
||||
ErrorMessage string `json:"errorMessage,omitempty"`
|
||||
}
|
||||
|
||||
type contentBlock struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
func startDocsPiClient(ctx context.Context, options docsPiClientOptions) (*docsPiClient, error) {
|
||||
command, err := resolveDocsPiCommand(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
args := append([]string{}, command.Args...)
|
||||
args = append(args,
|
||||
"--mode", "rpc",
|
||||
"--provider", "anthropic",
|
||||
"--model", modelVersion,
|
||||
"--thinking", options.Thinking,
|
||||
"--no-session",
|
||||
)
|
||||
if strings.TrimSpace(options.SystemPrompt) != "" {
|
||||
args = append(args, "--system-prompt", options.SystemPrompt)
|
||||
}
|
||||
|
||||
process := exec.Command(command.Executable, args...)
|
||||
agentDir, err := getDocsPiAgentDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
process.Env = append(os.Environ(), fmt.Sprintf("PI_CODING_AGENT_DIR=%s", agentDir))
|
||||
stdin, err := process.StdinPipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stdout, err := process.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stderr, err := process.StderrPipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := &docsPiClient{
|
||||
process: process,
|
||||
stdin: stdin,
|
||||
events: make(chan piEvent, 256),
|
||||
closed: make(chan struct{}),
|
||||
}
|
||||
|
||||
if err := process.Start(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
go client.captureStderr(stderr)
|
||||
go client.readStdout(stdout)
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (client *docsPiClient) Prompt(ctx context.Context, message string) (string, error) {
|
||||
client.promptLock.Lock()
|
||||
defer client.promptLock.Unlock()
|
||||
|
||||
command := map[string]string{
|
||||
"type": "prompt",
|
||||
"id": fmt.Sprintf("req-%d", atomic.AddUint64(&client.requestID, 1)),
|
||||
"message": message,
|
||||
}
|
||||
payload, err := json.Marshal(command)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if _, err := client.stdin.Write(append(payload, '\n')); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
case <-client.closed:
|
||||
return "", errors.New("pi process closed")
|
||||
case event, ok := <-client.events:
|
||||
if !ok {
|
||||
return "", errors.New("pi event stream closed")
|
||||
}
|
||||
if event.Type == "response" {
|
||||
response, err := decodeRpcResponse(event.Raw)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !response.Success {
|
||||
if strings.TrimSpace(response.Error) == "" {
|
||||
return "", errors.New("pi prompt failed")
|
||||
}
|
||||
return "", errors.New(strings.TrimSpace(response.Error))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if event.Type == "agent_end" {
|
||||
return extractTranslationResult(event.Raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (client *docsPiClient) Stderr() string {
|
||||
return client.stderr.String()
|
||||
}
|
||||
|
||||
func (client *docsPiClient) Close() error {
|
||||
client.closeOnce.Do(func() {
|
||||
close(client.closed)
|
||||
if client.stdin != nil {
|
||||
_ = client.stdin.Close()
|
||||
}
|
||||
if client.process != nil && client.process.Process != nil {
|
||||
_ = client.process.Process.Signal(syscall.SIGTERM)
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
if client.process != nil {
|
||||
_ = client.process.Wait()
|
||||
}
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
if client.process != nil && client.process.Process != nil {
|
||||
_ = client.process.Process.Kill()
|
||||
}
|
||||
}
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (client *docsPiClient) captureStderr(stderr io.Reader) {
|
||||
_, _ = io.Copy(&client.stderr, stderr)
|
||||
}
|
||||
|
||||
func (client *docsPiClient) readStdout(stdout io.Reader) {
|
||||
defer close(client.events)
|
||||
|
||||
reader := bufio.NewReader(stdout)
|
||||
for {
|
||||
line, err := reader.ReadBytes('\n')
|
||||
line = bytes.TrimSpace(line)
|
||||
if len(line) > 0 {
|
||||
var envelope struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
if json.Unmarshal(line, &envelope) == nil && envelope.Type != "" {
|
||||
select {
|
||||
case client.events <- piEvent{Type: envelope.Type, Raw: append([]byte{}, line...)}:
|
||||
case <-client.closed:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func extractTranslationResult(raw json.RawMessage) (string, error) {
|
||||
var payload agentEndPayload
|
||||
if err := json.Unmarshal(raw, &payload); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for index := len(payload.Messages) - 1; index >= 0; index-- {
|
||||
message := payload.Messages[index]
|
||||
if message.Role != "assistant" {
|
||||
continue
|
||||
}
|
||||
if message.ErrorMessage != "" || strings.EqualFold(message.StopReason, "error") {
|
||||
msg := strings.TrimSpace(message.ErrorMessage)
|
||||
if msg == "" {
|
||||
msg = "unknown error"
|
||||
}
|
||||
return "", fmt.Errorf("pi error: %s", msg)
|
||||
}
|
||||
text, err := extractContentText(message.Content)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return text, nil
|
||||
}
|
||||
return "", errors.New("assistant message not found")
|
||||
}
|
||||
|
||||
func extractContentText(content json.RawMessage) (string, error) {
|
||||
trimmed := strings.TrimSpace(string(content))
|
||||
if trimmed == "" {
|
||||
return "", nil
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "\"") {
|
||||
var text string
|
||||
if err := json.Unmarshal(content, &text); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return text, nil
|
||||
}
|
||||
|
||||
var blocks []contentBlock
|
||||
if err := json.Unmarshal(content, &blocks); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var parts []string
|
||||
for _, block := range blocks {
|
||||
if block.Type == "text" && block.Text != "" {
|
||||
parts = append(parts, block.Text)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, ""), nil
|
||||
}
|
||||
|
||||
func decodeRpcResponse(raw json.RawMessage) (rpcResponse, error) {
|
||||
var response rpcResponse
|
||||
if err := json.Unmarshal(raw, &response); err != nil {
|
||||
return rpcResponse{}, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func getDocsPiAgentDir() (string, error) {
|
||||
cacheDir, err := os.UserCacheDir()
|
||||
if err != nil {
|
||||
cacheDir = os.TempDir()
|
||||
}
|
||||
dir := filepath.Join(cacheDir, "openclaw", "docs-i18n", "agent")
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return dir, nil
|
||||
}
|
||||
@@ -6,8 +6,6 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
pi "github.com/joshp123/pi-golang"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -19,21 +17,14 @@ const (
|
||||
var errEmptyTranslation = errors.New("empty translation")
|
||||
|
||||
type PiTranslator struct {
|
||||
client *pi.OneShotClient
|
||||
client *docsPiClient
|
||||
}
|
||||
|
||||
func NewPiTranslator(srcLang, tgtLang string, glossary []GlossaryEntry, thinking string) (*PiTranslator, error) {
|
||||
options := pi.DefaultOneShotOptions()
|
||||
options.AppName = "openclaw-docs-i18n"
|
||||
options.WorkDir = "/tmp"
|
||||
options.Mode = pi.ModeDragons
|
||||
options.Dragons = pi.DragonsOptions{
|
||||
Provider: "anthropic",
|
||||
Model: modelVersion,
|
||||
Thinking: normalizeThinking(thinking),
|
||||
}
|
||||
options.SystemPrompt = translationPrompt(srcLang, tgtLang, glossary)
|
||||
client, err := pi.StartOneShot(options)
|
||||
client, err := startDocsPiClient(context.Background(), docsPiClientOptions{
|
||||
SystemPrompt: translationPrompt(srcLang, tgtLang, glossary),
|
||||
Thinking: normalizeThinking(thinking),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -146,7 +137,7 @@ func (t *PiTranslator) Close() {
|
||||
}
|
||||
|
||||
type promptRunner interface {
|
||||
Run(context.Context, string) (pi.RunResult, error)
|
||||
Prompt(context.Context, string) (string, error)
|
||||
Stderr() string
|
||||
}
|
||||
|
||||
@@ -154,11 +145,11 @@ func runPrompt(ctx context.Context, client promptRunner, message string) (string
|
||||
promptCtx, cancel := context.WithTimeout(ctx, translatePromptTimeout)
|
||||
defer cancel()
|
||||
|
||||
result, err := client.Run(promptCtx, message)
|
||||
result, err := client.Prompt(promptCtx, message)
|
||||
if err != nil {
|
||||
return "", decoratePromptError(err, client.Stderr())
|
||||
}
|
||||
return result.Text, nil
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func decoratePromptError(err error, stderr string) error {
|
||||
|
||||
@@ -3,20 +3,20 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
pi "github.com/joshp123/pi-golang"
|
||||
)
|
||||
|
||||
type fakePromptRunner struct {
|
||||
run func(context.Context, string) (pi.RunResult, error)
|
||||
prompt func(context.Context, string) (string, error)
|
||||
stderr string
|
||||
}
|
||||
|
||||
func (runner fakePromptRunner) Run(ctx context.Context, message string) (pi.RunResult, error) {
|
||||
return runner.run(ctx, message)
|
||||
func (runner fakePromptRunner) Prompt(ctx context.Context, message string) (string, error) {
|
||||
return runner.prompt(ctx, message)
|
||||
}
|
||||
|
||||
func (runner fakePromptRunner) Stderr() string {
|
||||
@@ -28,7 +28,7 @@ func TestRunPromptAddsTimeout(t *testing.T) {
|
||||
|
||||
var deadline time.Time
|
||||
client := fakePromptRunner{
|
||||
run: func(ctx context.Context, message string) (pi.RunResult, error) {
|
||||
prompt: func(ctx context.Context, message string) (string, error) {
|
||||
var ok bool
|
||||
deadline, ok = ctx.Deadline()
|
||||
if !ok {
|
||||
@@ -37,7 +37,7 @@ func TestRunPromptAddsTimeout(t *testing.T) {
|
||||
if message != "Translate me" {
|
||||
t.Fatalf("unexpected message %q", message)
|
||||
}
|
||||
return pi.RunResult{Text: "translated"}, nil
|
||||
return "translated", nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -60,8 +60,8 @@ func TestRunPromptIncludesStderr(t *testing.T) {
|
||||
|
||||
rootErr := errors.New("context deadline exceeded")
|
||||
client := fakePromptRunner{
|
||||
run: func(context.Context, string) (pi.RunResult, error) {
|
||||
return pi.RunResult{}, rootErr
|
||||
prompt: func(context.Context, string) (string, error) {
|
||||
return "", rootErr
|
||||
},
|
||||
stderr: "boom",
|
||||
}
|
||||
@@ -90,3 +90,47 @@ func TestDecoratePromptErrorLeavesCleanErrorsAlone(t *testing.T) {
|
||||
t.Fatalf("expected unchanged message, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDocsPiCommandUsesOverrideEnv(t *testing.T) {
|
||||
t.Setenv(envDocsPiExecutable, "/tmp/custom-pi")
|
||||
t.Setenv(envDocsPiArgs, "--mode rpc --foo bar")
|
||||
|
||||
command, err := resolveDocsPiCommand(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("resolveDocsPiCommand returned error: %v", err)
|
||||
}
|
||||
|
||||
if command.Executable != "/tmp/custom-pi" {
|
||||
t.Fatalf("unexpected executable %q", command.Executable)
|
||||
}
|
||||
if strings.Join(command.Args, " ") != "--mode rpc --foo bar" {
|
||||
t.Fatalf("unexpected args %v", command.Args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldMaterializePiRuntimeForPiMonoWrapper(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
sourceDir := filepath.Join(root, "Projects", "pi-mono", "packages", "coding-agent", "dist")
|
||||
binDir := filepath.Join(root, "bin")
|
||||
if err := os.MkdirAll(sourceDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir source dir: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(binDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir bin dir: %v", err)
|
||||
}
|
||||
|
||||
target := filepath.Join(sourceDir, "cli.js")
|
||||
if err := os.WriteFile(target, []byte("console.log('pi');\n"), 0o644); err != nil {
|
||||
t.Fatalf("write target: %v", err)
|
||||
}
|
||||
link := filepath.Join(binDir, "pi")
|
||||
if err := os.Symlink(target, link); err != nil {
|
||||
t.Fatalf("symlink: %v", err)
|
||||
}
|
||||
|
||||
if !shouldMaterializePiRuntime(link) {
|
||||
t.Fatal("expected pi-mono wrapper to materialize runtime")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user