The init commit:

- simple file command-line translate tools.
- support config and command-line args
This commit is contained in:
xkm
2026-02-20 02:48:48 +08:00
commit 5912b96a7e
4 changed files with 284 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
.idea/

10
go.mod Normal file
View File

@@ -0,0 +1,10 @@
module translate
go 1.26
require (
github.com/alecthomas/kong v1.14.0
github.com/alecthomas/kong-toml v0.4.0
)
require github.com/pelletier/go-toml v1.9.5 // indirect

12
go.sum Normal file
View File

@@ -0,0 +1,12 @@
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/kong v1.14.0 h1:gFgEUZWu2ZmZ+UhyZ1bDhuutbKN1nTtJTwh19Wsn21s=
github.com/alecthomas/kong v1.14.0/go.mod h1:wrlbXem1CWqUV5Vbmss5ISYhsVPkBb1Yo7YKJghju2I=
github.com/alecthomas/kong-toml v0.4.0 h1:sSK/HHi2M5jqSXYTxmuxkdZcJ+ip9jhYvwcjDGcaJBQ=
github.com/alecthomas/kong-toml v0.4.0/go.mod h1:hRVV9iGmqYsFqs17jFQgqhkjYIxiklbfy95xJ3nlpKI=
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=

261
main.go Normal file
View File

@@ -0,0 +1,261 @@
package main
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"strings"
"github.com/alecthomas/kong"
kongtoml "github.com/alecthomas/kong-toml"
)
type ChatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type chatCompletionReq struct {
Model string `json:"model"`
Messages []ChatMessage `json:"messages"`
Temperature *float64 `json:"temperature,omitempty"`
ReasoningEffort string `json:"reasoning_effort,omitempty"`
Stream bool `json:"stream"`
}
type streamEvent struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
} `json:"delta"`
} `json:"choices"`
Error *struct {
Message string `json:"message"`
Type string `json:"type"`
} `json:"error,omitempty"`
}
func streamChatText(
ctx context.Context,
client *http.Client,
baseURL string,
apiKey string,
model string,
reasoningEffort string,
temperature *float64,
msgs []ChatMessage,
) (io.ReadCloser, error) {
if msgs == nil || len(msgs) == 0 {
return nil, errors.New("missing messages")
}
endpoint := strings.TrimRight(baseURL, "/") + "/chat/completions"
body := chatCompletionReq{
Model: model,
Messages: msgs,
Temperature: temperature,
ReasoningEffort: reasoningEffort,
Stream: true,
}
payload, err := json.Marshal(body)
if err != nil {
return nil, err
}
pr, pw := io.Pipe()
go func() {
defer func(pw *io.PipeWriter) {
err := pw.Close()
if err != nil {
log.Println(err)
}
}(pw)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload))
if err != nil {
_ = pw.CloseWithError(err)
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := client.Do(req)
if err != nil {
_ = pw.CloseWithError(err)
return
}
defer func(Body io.ReadCloser) {
err := Body.Close()
if err != nil {
log.Println(err)
}
}(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode > 299 {
b, _ := io.ReadAll(resp.Body)
_ = pw.CloseWithError(fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b))))
return
}
sc := bufio.NewScanner(resp.Body)
sc.Buffer(make([]byte, 0, 64*1024), 2*1024*1024)
var dataLines []string
flushEvent := func() bool {
if len(dataLines) == 0 {
_ = pw.Close()
return true
}
data := strings.Join(dataLines, "\n")
dataLines = dataLines[:0]
if data == "[DONE]" {
_ = pw.Close()
return false
}
var evt streamEvent
if err := json.Unmarshal([]byte(data), &evt); err != nil {
_ = pw.CloseWithError(fmt.Errorf("failed to unmarshal event: %w, data=%q", err, data))
return false
}
if evt.Error != nil {
_ = pw.CloseWithError(fmt.Errorf("api error: %+v", evt.Error))
return false
}
if len(evt.Choices) > 0 {
chunk := evt.Choices[0].Delta.Content
if chunk != "" {
if _, err := io.WriteString(pw, chunk); err != nil {
_ = pw.CloseWithError(err)
return false
}
}
}
return true
}
for sc.Scan() {
line := sc.Text()
if line == "" {
if ok := flushEvent(); !ok {
return
}
continue
}
if strings.HasPrefix(line, "data:") {
v := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
dataLines = append(dataLines, v)
}
}
if len(dataLines) > 0 {
_ = flushEvent()
return
}
if err := sc.Err(); err != nil {
_ = pw.CloseWithError(err)
return
}
}()
return pr, nil
}
// Translate src to lang
func Translate(
ctx context.Context,
client *http.Client,
baseURL string,
apiKey string,
model string,
reasoningEffort string,
temperature *float64,
src string,
lang string,
) (io.ReadCloser, error) {
if client == nil {
client = http.DefaultClient
}
msgs := []ChatMessage{
{
Role: "system",
Content: "You are a translation API. Please fully translate the user input to '" + lang + "'. Do not include any formatting changes/non-translation output. No explanation of the translation content. Do not translate proper nouns.",
},
{
Role: "user",
Content: src,
},
}
return streamChatText(ctx, client, baseURL, apiKey, model, reasoningEffort, temperature, msgs)
}
var cli struct {
Config string `short:"c" default:"~/.config/translate.toml" help:"Path to config file"`
BaseURL string `short:"b" help:"LLM API BASE URL" default:"https://api.openai.com/v1"`
ApiKey string `short:"k" help:"LLM API Key"`
Model string `short:"m" help:"LLM Model" default:"gpt-5-nano"`
ReasoningEffort string `help:"LLM Reasoning Effort (remember some LLM maybe un-support some settings)" default:"minimal"`
Temperature *float64 `help:"LLM Temperature"`
Lang string `short:"l" help:"The target language" default:"zh-CN"`
ReverseLang string `help:"The target language when use -r" default:"en-US"`
Reverse bool `short:"r" help:"Set this flag to true to enable reverse translation"`
Src []string `arg:"" optional:"" name:"src" help:"Text to translate or leave empty to use stdin"`
}
func main() {
_ = kong.Parse(&cli)
_ = kong.Parse(
&cli,
kong.Configuration(kongtoml.Loader, cli.Config),
)
lang := cli.Lang
if cli.Reverse {
lang = cli.ReverseLang
}
src := strings.Join(cli.Src, " ")
if src == "" {
stdin, err := io.ReadAll(os.Stdin)
src = string(stdin)
if err != nil {
panic(err)
}
}
stream, err := Translate(context.Background(), http.DefaultClient, cli.BaseURL, cli.ApiKey, cli.Model,
cli.ReasoningEffort, cli.Temperature, src, lang)
if err != nil {
panic(err)
}
_, err = io.Copy(os.Stdout, stream)
_, _ = os.Stdout.Write([]byte{'\n'})
if err != nil {
panic(err)
}
err = stream.Close()
if err != nil {
panic(err)
}
}