package main import ( "context" "encoding/json" "errors" "fmt" "strings" "time" pi "github.com/joshp123/pi-golang" ) const ( translateMaxAttempts = 3 translateBaseDelay = 15 * time.Second ) var errEmptyTranslation = errors.New("empty translation") type PiTranslator struct { client *pi.OneShotClient } 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) if err != nil { return nil, err } return &PiTranslator{client: client}, nil } func (t *PiTranslator) Translate(ctx context.Context, text, srcLang, tgtLang string) (string, error) { return t.translate(ctx, text, t.translateMasked) } func (t *PiTranslator) TranslateRaw(ctx context.Context, text, srcLang, tgtLang string) (string, error) { return t.translate(ctx, text, t.translateRaw) } func (t *PiTranslator) translate(ctx context.Context, text string, run func(context.Context, string) (string, error)) (string, error) { if t.client == nil { return "", errors.New("pi client unavailable") } prefix, core, suffix := splitWhitespace(text) if core == "" { return text, nil } translated, err := t.translateWithRetry(ctx, func(ctx context.Context) (string, error) { return run(ctx, core) }) if err != nil { return "", err } return prefix + translated + suffix, nil } func (t *PiTranslator) translateWithRetry(ctx context.Context, run func(context.Context) (string, error)) (string, error) { var lastErr error for attempt := 0; attempt < translateMaxAttempts; attempt++ { translated, err := run(ctx) if err == nil { return translated, nil } if !isRetryableTranslateError(err) { return "", err } lastErr = err if attempt+1 < translateMaxAttempts { delay := translateBaseDelay * time.Duration(attempt+1) if err := sleepWithContext(ctx, delay); err != nil { return "", err } } } return "", lastErr } func (t *PiTranslator) translateMasked(ctx context.Context, core string) (string, error) { state := NewPlaceholderState(core) placeholders := make([]string, 0, 8) mapping := map[string]string{} masked := maskMarkdown(core, state.Next, &placeholders, mapping) resText, err := runPrompt(ctx, t.client, masked) if err != nil { return "", err } translated := strings.TrimSpace(resText) if translated == "" { return "", errEmptyTranslation } if err := validatePlaceholders(translated, placeholders); err != nil { return "", err } return unmaskMarkdown(translated, placeholders, mapping), nil } func (t *PiTranslator) translateRaw(ctx context.Context, core string) (string, error) { resText, err := runPrompt(ctx, t.client, core) if err != nil { return "", err } translated := strings.TrimSpace(resText) if translated == "" { return "", errEmptyTranslation } return translated, nil } func isRetryableTranslateError(err error) bool { if err == nil { return false } if errors.Is(err, errEmptyTranslation) { return true } message := strings.ToLower(err.Error()) return strings.Contains(message, "placeholder missing") || strings.Contains(message, "rate limit") || strings.Contains(message, "429") } func sleepWithContext(ctx context.Context, delay time.Duration) error { timer := time.NewTimer(delay) defer timer.Stop() select { case <-ctx.Done(): return ctx.Err() case <-timer.C: return nil } } func (t *PiTranslator) Close() { if t.client != nil { _ = t.client.Close() } } type agentEndPayload struct { Messages []agentMessage `json:"messages"` } 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 runPrompt(ctx context.Context, client *pi.OneShotClient, message string) (string, error) { events, cancel := client.Subscribe(256) defer cancel() if err := client.Prompt(ctx, message); err != nil { return "", err } for { select { case <-ctx.Done(): return "", ctx.Err() case event, ok := <-events: if !ok { return "", errors.New("event stream closed") } if event.Type == "agent_end" { return extractTranslationResult(event.Raw) } } } } 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 normalizeThinking(value string) string { switch strings.ToLower(strings.TrimSpace(value)) { case "low", "high": return strings.ToLower(strings.TrimSpace(value)) default: return "high" } }