3v324v23 commited on
Commit
32455d6
·
0 Parent(s):

init: eino siliconflow demo

Browse files
.dockerignore ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ .DS_Store
2
+ .git
3
+ .idea
4
+ .vscode
5
+ bin
6
+ dist
7
+
.env.example ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 优先使用硅基流动(可选)。不填则回退到 OPENAI_*,都不填则自动走 Mock。
2
+ SILICONFLOW_API_KEY=
3
+ SILICONFLOW_MODEL=Qwen/Qwen2.5-7B-Instruct
4
+ SILICONFLOW_BASE_URL=https://api.siliconflow.cn/v1
5
+
6
+ # OpenAI 兼容(可选)
7
+ OPENAI_API_KEY=
8
+ OPENAI_MODEL=
9
+ OPENAI_BASE_URL=
10
+
11
+ # HTTP 服务端口
12
+ PORT=8080
.gitignore ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ .DS_Store
2
+ .idea/
3
+ .vscode/
4
+ .env
5
+ dist/
6
+ bin/
7
+
Dockerfile ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM golang:1.22-bookworm AS build
2
+
3
+ WORKDIR /app
4
+
5
+ COPY go.mod go.sum ./
6
+ RUN go mod download
7
+
8
+ COPY . ./
9
+
10
+ RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/server .
11
+
12
+
13
+ FROM gcr.io/distroless/static-debian12:nonroot
14
+
15
+ WORKDIR /app
16
+
17
+ COPY --from=build /out/server /app/server
18
+ COPY --from=build /app/web /app/web
19
+
20
+ ENV PORT=7860
21
+ EXPOSE 7860
22
+
23
+ USER nonroot:nonroot
24
+
25
+ ENTRYPOINT ["/app/server"]
26
+
README.md ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Eino + 硅基流动 Qwen2.5-7B-Instruct 展示
3
+ short_description: Eino+硅基流动 Qwen2.5-7B-Instruct 展示
4
+ sdk: docker
5
+ app_port: 7860
6
+ colorFrom: blue
7
+ colorTo: green
8
+ ---
9
+
10
+ # Eino 闭环最小项目(Go)
11
+
12
+ 这是一个“能跑起来”的最小闭环示例:启动一个 HTTP 服务,提供 `/chat` 接口,并自带一个 Web 展示页。
13
+
14
+ - 有 `SILICONFLOW_API_KEY` 时:默认调用硅基流动的 `Qwen/Qwen2.5-7B-Instruct`
15
+ - 有 `OPENAI_API_KEY` 时:通过 CloudWeGo Eino(`BaseChatModel` 接口)+ OpenAI 兼容接口真实调用模型
16
+ - 没有 Key 时:自动进入 Mock 模式,保证你也能一键跑通闭环
17
+
18
+ ## 目录结构
19
+
20
+ - `main.go`:HTTP 入口
21
+ - `internal/app`:路由与业务封装
22
+ - `web/index.html`:Web 展示页
23
+
24
+ ## 快速开始
25
+
26
+ ### 1) 准备环境
27
+
28
+ 需要本机已安装 Go(建议 1.21+)。
29
+
30
+ ### 2) 启动(Mock 模式,默认可跑)
31
+
32
+ 在项目根目录执行:
33
+
34
+ ```bash
35
+ go run .
36
+ ```
37
+
38
+ 然后测试:
39
+
40
+ ```bash
41
+ curl -s http://localhost:8080/healthz
42
+ curl -s http://localhost:8080/chat \
43
+ -H 'content-type: application/json' \
44
+ -d '{"input":"用一句话解释 Eino 是什么"}'
45
+ ```
46
+
47
+ 打开展示页:
48
+
49
+ ```bash
50
+ open http://localhost:8080
51
+ ```
52
+
53
+ ### 3) 启动(真实调用:硅基流动 Qwen2.5-7B-Instruct)
54
+
55
+ 把环境变量配上即可:
56
+
57
+ ```bash
58
+ export SILICONFLOW_API_KEY="你的 key"
59
+ export SILICONFLOW_MODEL="Qwen/Qwen2.5-7B-Instruct"
60
+ export SILICONFLOW_BASE_URL="https://api.siliconflow.cn/v1"
61
+
62
+ go run .
63
+ ```
64
+
65
+ 然后打开展示页:
66
+
67
+ ```bash
68
+ open http://localhost:8080
69
+ ```
70
+
71
+ Hugging Face Spaces 会自动监听 `7860` 端口,不需要你手动设置 `PORT`。
72
+
73
+ ### 4) 启动(真实调用:其他 OpenAI 兼容接口)
74
+
75
+ 把环境变量配上即可:
76
+
77
+ ```bash
78
+ export OPENAI_API_KEY="你的 key"
79
+ export OPENAI_MODEL="gpt-4o-mini"
80
+ # 可选:如果你用的是第三方/自建 OpenAI 兼容网关
81
+ # export OPENAI_BASE_URL="https://你的网关/v1"
82
+
83
+ go run .
84
+ ```
85
+
86
+ 然后:
87
+
88
+ ```bash
89
+ curl -s http://localhost:8080/chat \
90
+ -H 'content-type: application/json' \
91
+ -d '{"input":"用两句话解释:什么是闭环?"}'
92
+ ```
93
+
94
+ ## 接口说明
95
+
96
+ - `GET /healthz`:健康检查
97
+ - `POST /chat`:请求体 JSON
98
+
99
+ 请求体:
100
+
101
+ ```json
102
+ { "input": "你好" }
103
+ ```
104
+
105
+ 响应体(示例):
106
+
107
+ ```json
108
+ {
109
+ "ok": true,
110
+ "mock": true,
111
+ "model": "gpt-4o-mini",
112
+ "output": "(Mock)你输入了:你好"
113
+ }
114
+ ```
115
+
116
+ ## 常见问题
117
+
118
+ - 如果你要走真实模型,但没有配置 `OPENAI_API_KEY`,会自动回退到 Mock。
119
+ - 如果 `go mod tidy` 卡住或报 `proxy.golang.org` 超时:可以用国内代理拉依赖,例如:
120
+
121
+ ```bash
122
+ export GOPROXY=https://goproxy.cn,direct
123
+ export GOSUMDB=sum.golang.google.cn
124
+ ```
125
+
126
+ - 这个项目没有依赖 `eino-ext`(目前 `eino-ext` 仓库还不包含 OpenAI 适配代码)。这里用 Eino 的 `components/model.BaseChatModel` 自己实现了一个最小 OpenAI 兼容适配器,代码在 `internal/einoopenai`。
127
+
128
+ - 出于安全原因,`SILICONFLOW_API_KEY` 不会写进仓库;请在 Hugging Face Spaces 的 Secrets 中配置它。
go.mod ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ module eino-closedloop-cn
2
+
3
+ go 1.21
4
+
5
+ require github.com/cloudwego/eino v0.8.5
6
+
7
+ require (
8
+ github.com/bahlo/generic-list-go v0.2.0 // indirect
9
+ github.com/buger/jsonparser v1.1.1 // indirect
10
+ github.com/bytedance/gopkg v0.1.3 // indirect
11
+ github.com/bytedance/sonic v1.15.0 // indirect
12
+ github.com/bytedance/sonic/loader v0.5.0 // indirect
13
+ github.com/cloudwego/base64x v0.1.6 // indirect
14
+ github.com/dustin/go-humanize v1.0.1 // indirect
15
+ github.com/eino-contrib/jsonschema v1.0.3 // indirect
16
+ github.com/goph/emperror v0.17.2 // indirect
17
+ github.com/json-iterator/go v1.1.12 // indirect
18
+ github.com/klauspost/cpuid/v2 v2.2.9 // indirect
19
+ github.com/mailru/easyjson v0.7.7 // indirect
20
+ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
21
+ github.com/modern-go/reflect2 v1.0.2 // indirect
22
+ github.com/nikolalohinski/gonja v1.5.3 // indirect
23
+ github.com/pelletier/go-toml/v2 v2.0.9 // indirect
24
+ github.com/pkg/errors v0.9.1 // indirect
25
+ github.com/sirupsen/logrus v1.9.3 // indirect
26
+ github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f // indirect
27
+ github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
28
+ github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
29
+ github.com/yargevad/filepathx v1.0.0 // indirect
30
+ golang.org/x/arch v0.11.0 // indirect
31
+ golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 // indirect
32
+ golang.org/x/sys v0.26.0 // indirect
33
+ gopkg.in/yaml.v3 v3.0.1 // indirect
34
+ )
go.sum ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ github.com/airbrake/gobrake v3.6.1+incompatible/go.mod h1:wM4gu3Cn0W0K7GUuVWnlXZU11AGBXMILnrdOU8Kn00o=
2
+ github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
3
+ github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
4
+ github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA=
5
+ github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
6
+ github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
7
+ github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
8
+ github.com/bugsnag/bugsnag-go v1.4.0/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqREvZLWFrtK8=
9
+ github.com/bugsnag/panicwrap v1.2.0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE=
10
+ github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
11
+ github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
12
+ github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
13
+ github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
14
+ github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
15
+ github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
16
+ github.com/certifi/gocertifi v0.0.0-20190105021004-abcd57078448/go.mod h1:GJKEexRPVJrBSOjoqN5VNOIKJ5Q3RViH6eu3puDRwx4=
17
+ github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
18
+ github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
19
+ github.com/cloudwego/eino v0.8.5 h1:ZNRJBiOW8eEOxMKjR4KbxW9Px9+DtETi8k7Yk1cuzv8=
20
+ github.com/cloudwego/eino v0.8.5/go.mod h1:+2N4nsMPxA6kGBHpH+75JuTfEcGprAMTdsZESrShKpU=
21
+ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
22
+ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
23
+ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
24
+ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
25
+ github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
26
+ github.com/eino-contrib/jsonschema v1.0.3 h1:2Kfsm1xlMV0ssY2nuxshS4AwbLFuqmPmzIjLVJ1Fsp0=
27
+ github.com/eino-contrib/jsonschema v1.0.3/go.mod h1:cpnX4SyKjWjGC7iN2EbhxaTdLqGjCi0e9DxpLYxddD4=
28
+ github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
29
+ github.com/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ=
30
+ github.com/go-check/check v0.0.0-20180628173108-788fd7840127 h1:0gkP6mzaMqkmpcJYCFOLkIBwI7xFExG03bbkOkCvUPI=
31
+ github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98=
32
+ github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
33
+ github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
34
+ github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
35
+ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
36
+ github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
37
+ github.com/goph/emperror v0.17.2 h1:yLapQcmEsO0ipe9p5TaN22djm3OFV/TfM/fcYP0/J18=
38
+ github.com/goph/emperror v0.17.2/go.mod h1:+ZbQ+fUNO/6FNiUo0ujtMjhgad9Xa6fQL9KhH4LNHic=
39
+ github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
40
+ github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k=
41
+ github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
42
+ github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
43
+ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
44
+ github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
45
+ github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
46
+ github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
47
+ github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8=
48
+ github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
49
+ github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
50
+ github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
51
+ github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
52
+ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
53
+ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
54
+ github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
55
+ github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
56
+ github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
57
+ github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
58
+ github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU=
59
+ github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
60
+ github.com/mattn/go-isatty v0.0.8 h1:HLtExJ+uU2HOZ+wI0Tt5DtUDrx8yhUqDcp7fYERX4CE=
61
+ github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
62
+ github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4=
63
+ github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
64
+ github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
65
+ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
66
+ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
67
+ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
68
+ github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
69
+ github.com/nikolalohinski/gonja v1.5.3 h1:GsA+EEaZDZPGJ8JtpeGN78jidhOlxeJROpqMT9fTj9c=
70
+ github.com/nikolalohinski/gonja v1.5.3/go.mod h1:RmjwxNiXAEqcq1HeK5SSMmqFJvKOfTfXhkJv6YBtPa4=
71
+ github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
72
+ github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
73
+ github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
74
+ github.com/pelletier/go-toml/v2 v2.0.9 h1:uH2qQXheeefCCkuBBSLi7jCiSmj3VRh2+Goq2N7Xxu0=
75
+ github.com/pelletier/go-toml/v2 v2.0.9/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
76
+ github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
77
+ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
78
+ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
79
+ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
80
+ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
81
+ github.com/rollbar/rollbar-go v1.0.2/go.mod h1:AcFs5f0I+c71bpHlXNNDbOWJiKwjFDtISeXco0L5PKQ=
82
+ github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
83
+ github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
84
+ github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
85
+ github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f h1:Z2cODYsUxQPofhpYRMQVwWz4yUVpHF+vPi+eUdruUYI=
86
+ github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f/go.mod h1:JqzWyvTuI2X4+9wOHmKSQCYxybB/8j6Ko43qVmXDuZg=
87
+ github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY=
88
+ github.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec=
89
+ github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY=
90
+ github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60=
91
+ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
92
+ github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
93
+ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
94
+ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
95
+ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
96
+ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
97
+ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
98
+ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
99
+ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
100
+ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
101
+ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
102
+ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
103
+ github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
104
+ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
105
+ github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
106
+ github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
107
+ github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
108
+ github.com/x-cray/logrus-prefixed-formatter v0.5.2 h1:00txxvfBM9muc0jiLIEAkAcIMJzfthRT6usrui8uGmg=
109
+ github.com/x-cray/logrus-prefixed-formatter v0.5.2/go.mod h1:2duySbKsL6M18s5GU7VPsoEPHyzalCE06qoARUCeBBE=
110
+ github.com/yargevad/filepathx v1.0.0 h1:SYcT+N3tYGi+NvazubCNlvgIPbzAk7i7y2dwg3I5FYc=
111
+ github.com/yargevad/filepathx v1.0.0/go.mod h1:BprfX/gpYNJHJfc35GjRRpVcwWXS89gGulUIU5tK3tA=
112
+ golang.org/x/arch v0.11.0 h1:KXV8WWKCXm6tRpLirl2szsO5j/oOODwZf4hATmGVNs4=
113
+ golang.org/x/arch v0.11.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
114
+ golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
115
+ golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA=
116
+ golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio=
117
+ golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 h1:MGwJjxBy0HJshjDNfLsYO8xppfqWlA5ZT9OhtUUhTNw=
118
+ golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc=
119
+ golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
120
+ golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
121
+ golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
122
+ golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
123
+ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
124
+ golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
125
+ golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
126
+ golang.org/x/term v0.10.0 h1:3R7pNqamzBraeqj/Tj8qt1aQ2HpmlC+Cx/qL/7hn4/c=
127
+ golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o=
128
+ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
129
+ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
130
+ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
131
+ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
132
+ gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
133
+ gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
134
+ gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
135
+ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
136
+ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
137
+ gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
internal/app/handlers.go ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package app
2
+
3
+ import (
4
+ "encoding/json"
5
+ "net/http"
6
+ "strings"
7
+ "time"
8
+ )
9
+
10
+ type chatRequest struct {
11
+ Input string `json:"input"`
12
+ }
13
+
14
+ type chatResponse struct {
15
+ OK bool `json:"ok"`
16
+ Mock bool `json:"mock"`
17
+ Model string `json:"model"`
18
+ Output string `json:"output"`
19
+ Error string `json:"error,omitempty"`
20
+ }
21
+
22
+ func RegisterRoutes(mux *http.ServeMux) {
23
+ mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
24
+ w.Header().Set("content-type", "application/json; charset=utf-8")
25
+ _ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
26
+ })
27
+
28
+ mux.HandleFunc("/chat", func(w http.ResponseWriter, r *http.Request) {
29
+ w.Header().Set("content-type", "application/json; charset=utf-8")
30
+
31
+ if r.Method != http.MethodPost {
32
+ w.WriteHeader(http.StatusMethodNotAllowed)
33
+ _ = json.NewEncoder(w).Encode(chatResponse{OK: false, Error: "仅支持 POST"})
34
+ return
35
+ }
36
+
37
+ var req chatRequest
38
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
39
+ w.WriteHeader(http.StatusBadRequest)
40
+ _ = json.NewEncoder(w).Encode(chatResponse{OK: false, Error: "JSON 解析失败"})
41
+ return
42
+ }
43
+
44
+ req.Input = strings.TrimSpace(req.Input)
45
+ if req.Input == "" {
46
+ w.WriteHeader(http.StatusBadRequest)
47
+ _ = json.NewEncoder(w).Encode(chatResponse{OK: false, Error: "input 不能为空"})
48
+ return
49
+ }
50
+
51
+ ctx, cancel := withTimeout(r.Context(), 25*time.Second)
52
+ defer cancel()
53
+
54
+ out, meta, err := ChatOnce(ctx, req.Input)
55
+ if err != nil {
56
+ w.WriteHeader(http.StatusBadGateway)
57
+ _ = json.NewEncoder(w).Encode(chatResponse{OK: false, Error: err.Error(), Mock: meta.Mock, Model: meta.Model})
58
+ return
59
+ }
60
+
61
+ _ = json.NewEncoder(w).Encode(chatResponse{OK: true, Mock: meta.Mock, Model: meta.Model, Output: out})
62
+ })
63
+
64
+ fs := http.FileServer(http.Dir("web"))
65
+ mux.Handle("/", fs)
66
+ }
internal/app/timeouts.go ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package app
2
+
3
+ import (
4
+ "context"
5
+ "time"
6
+ )
7
+
8
+ func withTimeout(parent context.Context, d time.Duration) (context.Context, context.CancelFunc) {
9
+ if parent == nil {
10
+ parent = context.Background()
11
+ }
12
+ return context.WithTimeout(parent, d)
13
+ }
internal/app/worker.go ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package app
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "os"
7
+ "strings"
8
+
9
+ "eino-closedloop-cn/internal/einoopenai"
10
+
11
+ "github.com/cloudwego/eino/components/model"
12
+ "github.com/cloudwego/eino/schema"
13
+ )
14
+
15
+ type chatMeta struct {
16
+ Mock bool
17
+ Model string
18
+ }
19
+
20
+ func ChatOnce(ctx context.Context, input string) (string, chatMeta, error) {
21
+ apiKey := strings.TrimSpace(os.Getenv("SILICONFLOW_API_KEY"))
22
+ modelName := strings.TrimSpace(os.Getenv("SILICONFLOW_MODEL"))
23
+ if apiKey == "" {
24
+ apiKey = strings.TrimSpace(os.Getenv("OPENAI_API_KEY"))
25
+ modelName = strings.TrimSpace(os.Getenv("OPENAI_MODEL"))
26
+ }
27
+ if modelName == "" {
28
+ modelName = "Qwen/Qwen2.5-7B-Instruct"
29
+ }
30
+
31
+ if apiKey == "" {
32
+ return fmt.Sprintf("(Mock)你输入了:%s", input), chatMeta{Mock: true, Model: modelName}, nil
33
+ }
34
+
35
+ baseURL := strings.TrimSpace(os.Getenv("SILICONFLOW_BASE_URL"))
36
+ if baseURL == "" {
37
+ baseURL = strings.TrimSpace(os.Getenv("OPENAI_BASE_URL"))
38
+ }
39
+ if baseURL == "" && strings.TrimSpace(os.Getenv("SILICONFLOW_API_KEY")) != "" {
40
+ baseURL = "https://api.siliconflow.cn/v1"
41
+ }
42
+
43
+ chatModel := einoopenai.NewChatModel(einoopenai.ChatModelConfig{
44
+ APIKey: apiKey,
45
+ BaseURL: baseURL,
46
+ Model: modelName,
47
+ })
48
+
49
+ resp, err := chatModel.Generate(ctx, []*schema.Message{
50
+ schema.UserMessage(input),
51
+ }, model.WithModel(modelName))
52
+ if err != nil {
53
+ return "", chatMeta{Mock: false, Model: modelName}, err
54
+ }
55
+
56
+ return strings.TrimSpace(resp.Content), chatMeta{Mock: false, Model: modelName}, nil
57
+ }
internal/einoopenai/chat_model.go ADDED
@@ -0,0 +1,207 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package einoopenai
2
+
3
+ import (
4
+ "bytes"
5
+ "context"
6
+ "encoding/json"
7
+ "errors"
8
+ "fmt"
9
+ "io"
10
+ "net/http"
11
+ "net/url"
12
+ "strings"
13
+ "time"
14
+
15
+ "github.com/cloudwego/eino/components/model"
16
+ "github.com/cloudwego/eino/schema"
17
+ )
18
+
19
+ type ChatModelConfig struct {
20
+ APIKey string
21
+ BaseURL string
22
+ Model string
23
+
24
+ HTTPClient *http.Client
25
+ }
26
+
27
+ type ChatModel struct {
28
+ apiKey string
29
+ baseURL string
30
+ model string
31
+ client *http.Client
32
+ }
33
+
34
+ func NewChatModel(cfg ChatModelConfig) *ChatModel {
35
+ baseURL := strings.TrimSpace(cfg.BaseURL)
36
+ if baseURL == "" {
37
+ baseURL = "https://api.openai.com/v1"
38
+ }
39
+
40
+ modelName := strings.TrimSpace(cfg.Model)
41
+ if modelName == "" {
42
+ modelName = "gpt-4o-mini"
43
+ }
44
+
45
+ client := cfg.HTTPClient
46
+ if client == nil {
47
+ client = &http.Client{Timeout: 60 * time.Second}
48
+ }
49
+
50
+ return &ChatModel{
51
+ apiKey: strings.TrimSpace(cfg.APIKey),
52
+ baseURL: baseURL,
53
+ model: modelName,
54
+ client: client,
55
+ }
56
+ }
57
+
58
+ func (m *ChatModel) Generate(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.Message, error) {
59
+ if strings.TrimSpace(m.apiKey) == "" {
60
+ return nil, errors.New("OPENAI_API_KEY 为空")
61
+ }
62
+
63
+ common := model.GetCommonOptions(&model.Options{Model: &m.model}, opts...)
64
+ modelName := m.model
65
+ if common.Model != nil && strings.TrimSpace(*common.Model) != "" {
66
+ modelName = strings.TrimSpace(*common.Model)
67
+ }
68
+
69
+ msgs := make([]openAIMessage, 0, len(input))
70
+ for _, msg := range input {
71
+ if msg == nil {
72
+ continue
73
+ }
74
+ content := strings.TrimSpace(msg.Content)
75
+ if content == "" {
76
+ continue
77
+ }
78
+ msgs = append(msgs, openAIMessage{Role: string(msg.Role), Content: content})
79
+ }
80
+
81
+ if len(msgs) == 0 {
82
+ return nil, errors.New("输入消息为空")
83
+ }
84
+
85
+ reqBody := openAIChatCompletionRequest{
86
+ Model: modelName,
87
+ Messages: msgs,
88
+ Temperature: common.Temperature,
89
+ MaxTokens: common.MaxTokens,
90
+ TopP: common.TopP,
91
+ Stop: common.Stop,
92
+ }
93
+
94
+ respBody, status, err := m.doJSON(ctx, http.MethodPost, "/chat/completions", reqBody)
95
+ if err != nil {
96
+ return nil, err
97
+ }
98
+
99
+ if status >= 400 {
100
+ msg := parseOpenAIError(respBody)
101
+ if msg == "" {
102
+ msg = string(respBody)
103
+ }
104
+ return nil, fmt.Errorf("OpenAI 请求失败(HTTP %d):%s", status, msg)
105
+ }
106
+
107
+ var out openAIChatCompletionResponse
108
+ if err := json.Unmarshal(respBody, &out); err != nil {
109
+ return nil, fmt.Errorf("OpenAI 响应解析失败:%w", err)
110
+ }
111
+
112
+ if len(out.Choices) == 0 {
113
+ return nil, errors.New("OpenAI 响应 choices 为空")
114
+ }
115
+
116
+ content := strings.TrimSpace(out.Choices[0].Message.Content)
117
+ return schema.AssistantMessage(content, nil), nil
118
+ }
119
+
120
+ func (m *ChatModel) Stream(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.StreamReader[*schema.Message], error) {
121
+ sr, sw := schema.Pipe[*schema.Message](1)
122
+ go func() {
123
+ defer sw.Close()
124
+ msg, err := m.Generate(ctx, input, opts...)
125
+ if err != nil {
126
+ sw.Send(nil, err)
127
+ return
128
+ }
129
+ sw.Send(msg, nil)
130
+ }()
131
+ return sr, nil
132
+ }
133
+
134
+ func (m *ChatModel) doJSON(ctx context.Context, method, path string, body any) ([]byte, int, error) {
135
+ endpoint, err := joinURL(m.baseURL, path)
136
+ if err != nil {
137
+ return nil, 0, err
138
+ }
139
+
140
+ buf, err := json.Marshal(body)
141
+ if err != nil {
142
+ return nil, 0, err
143
+ }
144
+
145
+ req, err := http.NewRequestWithContext(ctx, method, endpoint, bytes.NewReader(buf))
146
+ if err != nil {
147
+ return nil, 0, err
148
+ }
149
+
150
+ req.Header.Set("content-type", "application/json")
151
+ req.Header.Set("authorization", "Bearer "+m.apiKey)
152
+
153
+ resp, err := m.client.Do(req)
154
+ if err != nil {
155
+ return nil, 0, err
156
+ }
157
+ defer resp.Body.Close()
158
+
159
+ respBytes, _ := io.ReadAll(resp.Body)
160
+ return respBytes, resp.StatusCode, nil
161
+ }
162
+
163
+ func joinURL(base, path string) (string, error) {
164
+ u, err := url.Parse(strings.TrimRight(strings.TrimSpace(base), "/"))
165
+ if err != nil {
166
+ return "", err
167
+ }
168
+ ref, err := url.Parse(path)
169
+ if err != nil {
170
+ return "", err
171
+ }
172
+ return u.ResolveReference(ref).String(), nil
173
+ }
174
+
175
+ type openAIMessage struct {
176
+ Role string `json:"role"`
177
+ Content string `json:"content"`
178
+ }
179
+
180
+ type openAIChatCompletionRequest struct {
181
+ Model string `json:"model"`
182
+ Messages []openAIMessage `json:"messages"`
183
+ Temperature *float32 `json:"temperature,omitempty"`
184
+ MaxTokens *int `json:"max_tokens,omitempty"`
185
+ TopP *float32 `json:"top_p,omitempty"`
186
+ Stop []string `json:"stop,omitempty"`
187
+ }
188
+
189
+ type openAIChatCompletionResponse struct {
190
+ Choices []struct {
191
+ Message openAIMessage `json:"message"`
192
+ } `json:"choices"`
193
+ }
194
+
195
+ type openAIErrorResponse struct {
196
+ Error struct {
197
+ Message string `json:"message"`
198
+ } `json:"error"`
199
+ }
200
+
201
+ func parseOpenAIError(b []byte) string {
202
+ var out openAIErrorResponse
203
+ if err := json.Unmarshal(b, &out); err != nil {
204
+ return ""
205
+ }
206
+ return strings.TrimSpace(out.Error.Message)
207
+ }
main.go ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package main
2
+
3
+ import (
4
+ "context"
5
+ "log"
6
+ "net/http"
7
+ "os"
8
+ "os/signal"
9
+ "syscall"
10
+ "time"
11
+
12
+ "eino-closedloop-cn/internal/app"
13
+ )
14
+
15
+ func main() {
16
+ port := os.Getenv("PORT")
17
+ if port == "" {
18
+ port = "8080"
19
+ }
20
+
21
+ mux := http.NewServeMux()
22
+ app.RegisterRoutes(mux)
23
+
24
+ srv := &http.Server{
25
+ Addr: ":" + port,
26
+ Handler: mux,
27
+ ReadHeaderTimeout: 5 * time.Second,
28
+ }
29
+
30
+ errCh := make(chan error, 1)
31
+ go func() {
32
+ log.Printf("HTTP 服务已启动:http://localhost:%s", port)
33
+ errCh <- srv.ListenAndServe()
34
+ }()
35
+
36
+ stop := make(chan os.Signal, 1)
37
+ signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
38
+
39
+ select {
40
+ case <-stop:
41
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
42
+ defer cancel()
43
+ _ = srv.Shutdown(ctx)
44
+ case err := <-errCh:
45
+ if err != nil && err != http.ErrServerClosed {
46
+ log.Fatalf("服务启动失败:%v", err)
47
+ }
48
+ }
49
+ }
web/index.html ADDED
@@ -0,0 +1,299 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Eino + 硅基流动 Qwen2.5-7B-Instruct 展示</title>
7
+ <style>
8
+ :root {
9
+ color-scheme: light;
10
+ }
11
+ body {
12
+ margin: 0;
13
+ font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial,
14
+ "Apple Color Emoji", "Segoe UI Emoji";
15
+ background: #ffffff;
16
+ color: #111827;
17
+ }
18
+ .wrap {
19
+ max-width: 980px;
20
+ margin: 0 auto;
21
+ padding: 28px 16px 48px;
22
+ }
23
+ .title {
24
+ display: flex;
25
+ align-items: baseline;
26
+ gap: 12px;
27
+ margin: 0 0 12px;
28
+ }
29
+ .title h1 {
30
+ font-size: 20px;
31
+ margin: 0;
32
+ font-weight: 700;
33
+ letter-spacing: 0.2px;
34
+ }
35
+ .badge {
36
+ font-size: 12px;
37
+ padding: 3px 10px;
38
+ border-radius: 999px;
39
+ border: 1px solid #e5e7eb;
40
+ background: #f9fafb;
41
+ color: #111827;
42
+ }
43
+ .sub {
44
+ margin: 0 0 18px;
45
+ color: #374151;
46
+ font-size: 13px;
47
+ line-height: 1.55;
48
+ }
49
+ .grid {
50
+ display: grid;
51
+ grid-template-columns: 1fr;
52
+ gap: 14px;
53
+ }
54
+ .card {
55
+ border: 1px solid #e5e7eb;
56
+ border-radius: 12px;
57
+ overflow: hidden;
58
+ background: #ffffff;
59
+ }
60
+ .cardHead {
61
+ padding: 12px 14px;
62
+ display: flex;
63
+ align-items: center;
64
+ justify-content: space-between;
65
+ gap: 12px;
66
+ border-bottom: 1px solid #e5e7eb;
67
+ background: #fafafa;
68
+ }
69
+ .cardHead strong {
70
+ font-size: 13px;
71
+ }
72
+ .right {
73
+ display: flex;
74
+ align-items: center;
75
+ gap: 10px;
76
+ flex-wrap: wrap;
77
+ }
78
+ .kv {
79
+ display: inline-flex;
80
+ gap: 6px;
81
+ align-items: baseline;
82
+ font-size: 12px;
83
+ color: #374151;
84
+ }
85
+ .kv code {
86
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
87
+ "Courier New", monospace;
88
+ background: #f3f4f6;
89
+ border: 1px solid #e5e7eb;
90
+ padding: 1px 6px;
91
+ border-radius: 8px;
92
+ color: #111827;
93
+ }
94
+ .cardBody {
95
+ padding: 14px;
96
+ }
97
+ textarea {
98
+ width: 100%;
99
+ box-sizing: border-box;
100
+ resize: vertical;
101
+ min-height: 92px;
102
+ border-radius: 10px;
103
+ border: 1px solid #d1d5db;
104
+ padding: 10px 12px;
105
+ font-size: 14px;
106
+ line-height: 1.5;
107
+ outline: none;
108
+ }
109
+ textarea:focus {
110
+ border-color: #2563eb;
111
+ box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
112
+ }
113
+ .actions {
114
+ display: flex;
115
+ gap: 10px;
116
+ margin-top: 10px;
117
+ flex-wrap: wrap;
118
+ }
119
+ button {
120
+ appearance: none;
121
+ border: 1px solid #d1d5db;
122
+ background: #ffffff;
123
+ color: #111827;
124
+ border-radius: 10px;
125
+ padding: 9px 12px;
126
+ font-size: 13px;
127
+ font-weight: 600;
128
+ cursor: pointer;
129
+ }
130
+ button.primary {
131
+ border-color: #2563eb;
132
+ background: #2563eb;
133
+ color: #ffffff;
134
+ }
135
+ button:disabled {
136
+ opacity: 0.6;
137
+ cursor: not-allowed;
138
+ }
139
+ .out {
140
+ white-space: pre-wrap;
141
+ word-break: break-word;
142
+ font-size: 14px;
143
+ line-height: 1.65;
144
+ color: #111827;
145
+ }
146
+ .muted {
147
+ color: #6b7280;
148
+ font-size: 12px;
149
+ }
150
+ .err {
151
+ color: #b91c1c;
152
+ }
153
+ @media (min-width: 920px) {
154
+ .grid {
155
+ grid-template-columns: 1fr 1fr;
156
+ }
157
+ }
158
+ </style>
159
+ </head>
160
+ <body>
161
+ <div class="wrap">
162
+ <div class="title">
163
+ <h1>Eino + 硅基流动(OpenAI 兼容)展示</h1>
164
+ <span class="badge" id="statusBadge">未连接</span>
165
+ </div>
166
+ <p class="sub">
167
+ 这个页面只负责“看效果”。API Key 不会出现在浏览器里,浏览器只请求本地的 Go 服务 `/chat`。
168
+ </p>
169
+
170
+ <div class="grid">
171
+ <div class="card">
172
+ <div class="cardHead">
173
+ <strong>输入</strong>
174
+ <div class="right">
175
+ <span class="kv">接口 <code>POST /chat</code></span>
176
+ </div>
177
+ </div>
178
+ <div class="cardBody">
179
+ <textarea id="input" placeholder="输入你要问的问题…"></textarea>
180
+ <div class="actions">
181
+ <button class="primary" id="sendBtn">发送</button>
182
+ <button id="fillBtn">填入示例</button>
183
+ <button id="clearBtn">清空输出</button>
184
+ <span class="muted" id="hint"></span>
185
+ </div>
186
+ </div>
187
+ </div>
188
+
189
+ <div class="card">
190
+ <div class="cardHead">
191
+ <strong>输出</strong>
192
+ <div class="right">
193
+ <span class="kv">模式 <code id="mode">-</code></span>
194
+ <span class="kv">模型 <code id="model">-</code></span>
195
+ <span class="kv">耗时 <code id="ms">-</code></span>
196
+ </div>
197
+ </div>
198
+ <div class="cardBody">
199
+ <div class="out" id="output"></div>
200
+ <div class="muted" id="error"></div>
201
+ </div>
202
+ </div>
203
+ </div>
204
+ </div>
205
+
206
+ <script>
207
+ const $ = (id) => document.getElementById(id)
208
+
209
+ const inputEl = $("input")
210
+ const outputEl = $("output")
211
+ const errorEl = $("error")
212
+ const sendBtn = $("sendBtn")
213
+ const fillBtn = $("fillBtn")
214
+ const clearBtn = $("clearBtn")
215
+ const hintEl = $("hint")
216
+ const statusBadge = $("statusBadge")
217
+ const modeEl = $("mode")
218
+ const modelEl = $("model")
219
+ const msEl = $("ms")
220
+
221
+ function setBusy(busy) {
222
+ sendBtn.disabled = busy
223
+ fillBtn.disabled = busy
224
+ clearBtn.disabled = busy
225
+ hintEl.textContent = busy ? "请求中…" : ""
226
+ }
227
+
228
+ function setStatus(ok) {
229
+ statusBadge.textContent = ok ? "已连接" : "未连接"
230
+ statusBadge.style.background = ok ? "#ecfeff" : "#f9fafb"
231
+ statusBadge.style.borderColor = ok ? "#a5f3fc" : "#e5e7eb"
232
+ statusBadge.style.color = ok ? "#0e7490" : "#111827"
233
+ }
234
+
235
+ async function ping() {
236
+ try {
237
+ const r = await fetch("/healthz")
238
+ setStatus(r.ok)
239
+ } catch {
240
+ setStatus(false)
241
+ }
242
+ }
243
+
244
+ fillBtn.addEventListener("click", () => {
245
+ inputEl.value = "请用 3 条要点解释:Qwen2.5-7B-Instruct 适合做什么?"
246
+ inputEl.focus()
247
+ })
248
+
249
+ clearBtn.addEventListener("click", () => {
250
+ outputEl.textContent = ""
251
+ errorEl.textContent = ""
252
+ modeEl.textContent = "-"
253
+ modelEl.textContent = "-"
254
+ msEl.textContent = "-"
255
+ })
256
+
257
+ sendBtn.addEventListener("click", async () => {
258
+ const text = (inputEl.value || "").trim()
259
+ if (!text) return
260
+
261
+ setBusy(true)
262
+ errorEl.textContent = ""
263
+ errorEl.className = "muted"
264
+
265
+ const t0 = performance.now()
266
+ try {
267
+ const r = await fetch("/chat", {
268
+ method: "POST",
269
+ headers: { "content-type": "application/json" },
270
+ body: JSON.stringify({ input: text }),
271
+ })
272
+ const data = await r.json().catch(() => ({}))
273
+ const t1 = performance.now()
274
+ msEl.textContent = `${Math.round(t1 - t0)}ms`
275
+
276
+ if (!r.ok || !data.ok) {
277
+ const msg = data.error || `请求失败(HTTP ${r.status})`
278
+ errorEl.textContent = msg
279
+ errorEl.className = "muted err"
280
+ return
281
+ }
282
+
283
+ modeEl.textContent = data.mock ? "mock" : "real"
284
+ modelEl.textContent = data.model || "-"
285
+ outputEl.textContent = data.output || ""
286
+ } catch (e) {
287
+ errorEl.textContent = e?.message || "请求异常"
288
+ errorEl.className = "muted err"
289
+ } finally {
290
+ setBusy(false)
291
+ ping()
292
+ }
293
+ })
294
+
295
+ ping()
296
+ </script>
297
+ </body>
298
+ </html>
299
+