Spaces:
Running
Running
github-actions[bot]
commited on
Commit
·
c5eab62
1
Parent(s):
1352c53
Update from GitHub Actions
Browse files- Dockerfile +27 -0
- Makefile +23 -0
- docker-compose.yml +22 -0
- go.mod +35 -0
- go.sum +70 -0
- internal/apiserver/router.go +98 -0
- internal/config/config.go +27 -0
- internal/middleware/auth.go +36 -0
- internal/monica/client.go +28 -0
- internal/monica/sse.go +314 -0
- internal/types/image.go +210 -0
- internal/types/monica.go +361 -0
- internal/types/openai.go +22 -0
- internal/utils/base64.go +10 -0
- internal/utils/req_client.go +44 -0
- internal/utils/string.go +21 -0
- main.go +31 -0
- nginx.conf +39 -0
Dockerfile
ADDED
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# 使用适合Go应用的基础镜像
|
2 |
+
FROM golang:alpine AS builder
|
3 |
+
ARG TARGETOS
|
4 |
+
ARG TARGETARCH
|
5 |
+
RUN apk update && apk add --no-cache upx make && rm -rf /var/cache/apk/*
|
6 |
+
|
7 |
+
# 设置工作目录
|
8 |
+
WORKDIR /app
|
9 |
+
|
10 |
+
# 复制所有文件到容器中
|
11 |
+
COPY . .
|
12 |
+
|
13 |
+
# 下载依赖
|
14 |
+
RUN go mod tidy
|
15 |
+
|
16 |
+
# 构建应用程序
|
17 |
+
RUN make build-${TARGETOS}-${TARGETARCH}
|
18 |
+
|
19 |
+
FROM scratch AS final
|
20 |
+
WORKDIR /data
|
21 |
+
COPY --from=builder /app/build/monica /data/monica
|
22 |
+
|
23 |
+
# 开放端口
|
24 |
+
EXPOSE 8080
|
25 |
+
|
26 |
+
# 运行
|
27 |
+
CMD ["./monica"]
|
Makefile
ADDED
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
.PHONY: build
|
2 |
+
|
3 |
+
build: build-linux-amd64 build-linux-arm64 build-darwin-arm64
|
4 |
+
|
5 |
+
build-darwin-arm64:
|
6 |
+
@rm -rf build || true
|
7 |
+
@mkdir -p build || true
|
8 |
+
@go mod tidy
|
9 |
+
@CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags "-s -w" -o build/monica .
|
10 |
+
|
11 |
+
build-linux-amd64:
|
12 |
+
@rm -rf build || true
|
13 |
+
@mkdir -p build || true
|
14 |
+
@go mod tidy
|
15 |
+
@CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "-s -w" -o build/monica .
|
16 |
+
@upx -7 build/monica
|
17 |
+
|
18 |
+
build-linux-arm64:
|
19 |
+
@rm -rf build || true
|
20 |
+
@mkdir -p build || true
|
21 |
+
@go mod tidy
|
22 |
+
@CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "-s -w" -o build/monica .
|
23 |
+
@upx -7 build/monica
|
docker-compose.yml
ADDED
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
services:
|
2 |
+
monica-proxy:
|
3 |
+
build:
|
4 |
+
context: .
|
5 |
+
dockerfile: Dockerfile
|
6 |
+
image: monica-proxy
|
7 |
+
container_name: monica-proxy
|
8 |
+
restart: unless-stopped
|
9 |
+
command: ["./monica"]
|
10 |
+
environment:
|
11 |
+
- MONICA_COOKIE=${MONICA_COOKIE}
|
12 |
+
- BEARER_TOKEN=${BEARER_TOKEN}
|
13 |
+
|
14 |
+
nginx:
|
15 |
+
image: nginx:latest
|
16 |
+
container_name: monica-nginx
|
17 |
+
ports:
|
18 |
+
- "8080:80"
|
19 |
+
volumes:
|
20 |
+
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
21 |
+
depends_on:
|
22 |
+
- monica-proxy
|
go.mod
ADDED
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
module monica-proxy
|
2 |
+
|
3 |
+
go 1.24
|
4 |
+
|
5 |
+
toolchain go1.24.0
|
6 |
+
|
7 |
+
require (
|
8 |
+
github.com/bytedance/sonic v1.12.9
|
9 |
+
github.com/go-resty/resty/v2 v2.16.5
|
10 |
+
github.com/google/uuid v1.6.0
|
11 |
+
github.com/labstack/echo/v4 v4.13.3
|
12 |
+
github.com/samber/lo v1.49.1
|
13 |
+
github.com/sashabaranov/go-openai v1.37.0
|
14 |
+
)
|
15 |
+
|
16 |
+
require github.com/cespare/xxhash/v2 v2.3.0
|
17 |
+
|
18 |
+
require (
|
19 |
+
github.com/bytedance/sonic/loader v0.2.3 // indirect
|
20 |
+
github.com/cloudwego/base64x v0.1.5 // indirect
|
21 |
+
github.com/joho/godotenv v1.5.1
|
22 |
+
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
23 |
+
github.com/labstack/gommon v0.4.2 // indirect
|
24 |
+
github.com/mattn/go-colorable v0.1.14 // indirect
|
25 |
+
github.com/mattn/go-isatty v0.0.20 // indirect
|
26 |
+
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
27 |
+
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
28 |
+
github.com/valyala/fasttemplate v1.2.2 // indirect
|
29 |
+
golang.org/x/arch v0.14.0 // indirect
|
30 |
+
golang.org/x/crypto v0.35.0 // indirect
|
31 |
+
golang.org/x/net v0.35.0 // indirect
|
32 |
+
golang.org/x/sys v0.30.0 // indirect
|
33 |
+
golang.org/x/text v0.22.0 // indirect
|
34 |
+
golang.org/x/time v0.10.0 // indirect
|
35 |
+
)
|
go.sum
ADDED
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
github.com/bytedance/sonic v1.12.9 h1:Od1BvK55NnewtGaJsTDeAOSnLVO2BTSLOe0+ooKokmQ=
|
2 |
+
github.com/bytedance/sonic v1.12.9/go.mod h1:uVvFidNmlt9+wa31S1urfwwthTWteBgG0hWuoKAXTx8=
|
3 |
+
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
4 |
+
github.com/bytedance/sonic/loader v0.2.3 h1:yctD0Q3v2NOGfSWPLPvG2ggA2kV6TS6s4wioyEqssH0=
|
5 |
+
github.com/bytedance/sonic/loader v0.2.3/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
6 |
+
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
7 |
+
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
8 |
+
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
|
9 |
+
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
10 |
+
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
11 |
+
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
12 |
+
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
13 |
+
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
14 |
+
github.com/go-resty/resty/v2 v2.16.5 h1:hBKqmWrr7uRc3euHVqmh1HTHcKn99Smr7o5spptdhTM=
|
15 |
+
github.com/go-resty/resty/v2 v2.16.5/go.mod h1:hkJtXbA2iKHzJheXYvQ8snQES5ZLGKMwQ07xAwp/fiA=
|
16 |
+
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
17 |
+
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
18 |
+
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
19 |
+
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
20 |
+
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
21 |
+
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
22 |
+
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
23 |
+
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
24 |
+
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
|
25 |
+
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
|
26 |
+
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
27 |
+
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
|
28 |
+
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
29 |
+
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
30 |
+
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
31 |
+
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
32 |
+
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
33 |
+
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
34 |
+
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
|
35 |
+
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
|
36 |
+
github.com/sashabaranov/go-openai v1.37.0 h1:hQQowgYm4OXJ1Z/wTrE+XZaO20BYsL0R3uRPSpfNZkY=
|
37 |
+
github.com/sashabaranov/go-openai v1.37.0/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
|
38 |
+
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
39 |
+
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
40 |
+
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
41 |
+
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
42 |
+
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
43 |
+
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
44 |
+
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
45 |
+
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
46 |
+
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
47 |
+
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
48 |
+
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
49 |
+
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
50 |
+
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
51 |
+
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
52 |
+
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
53 |
+
golang.org/x/arch v0.14.0 h1:z9JUEZWr8x4rR0OU6c4/4t6E6jOZ8/QBS2bBYBm4tx4=
|
54 |
+
golang.org/x/arch v0.14.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
55 |
+
golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
|
56 |
+
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
|
57 |
+
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
58 |
+
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
59 |
+
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
60 |
+
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
61 |
+
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
62 |
+
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
63 |
+
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
64 |
+
golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4=
|
65 |
+
golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
66 |
+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
67 |
+
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
68 |
+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
69 |
+
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
70 |
+
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
internal/apiserver/router.go
ADDED
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
package apiserver
|
2 |
+
|
3 |
+
import (
|
4 |
+
"monica-proxy/internal/middleware"
|
5 |
+
"monica-proxy/internal/monica"
|
6 |
+
"monica-proxy/internal/types"
|
7 |
+
"net/http"
|
8 |
+
|
9 |
+
"github.com/labstack/echo/v4"
|
10 |
+
"github.com/sashabaranov/go-openai"
|
11 |
+
)
|
12 |
+
|
13 |
+
// RegisterRoutes 注册 Echo 路由
|
14 |
+
func RegisterRoutes(e *echo.Echo) {
|
15 |
+
// 添加Bearer Token认证中间件
|
16 |
+
e.Use(middleware.BearerAuth())
|
17 |
+
|
18 |
+
// ChatGPT 风格的请求转发到 /v1/chat/completions
|
19 |
+
e.POST("/v1/chat/completions", handleChatCompletion)
|
20 |
+
// 获取支持的模型列表
|
21 |
+
e.GET("/v1/models", handleListModels)
|
22 |
+
}
|
23 |
+
|
24 |
+
// handleChatCompletion 接收 ChatGPT 形式的对话请求并转发给 Monica
|
25 |
+
func handleChatCompletion(c echo.Context) error {
|
26 |
+
var req openai.ChatCompletionRequest
|
27 |
+
if err := c.Bind(&req); err != nil {
|
28 |
+
return c.JSON(http.StatusBadRequest, map[string]interface{}{
|
29 |
+
"error": "Invalid request payload",
|
30 |
+
})
|
31 |
+
}
|
32 |
+
|
33 |
+
// 检查请求是否包含消息
|
34 |
+
if len(req.Messages) == 0 {
|
35 |
+
return c.JSON(http.StatusBadRequest, map[string]interface{}{
|
36 |
+
"error": "No messages found",
|
37 |
+
})
|
38 |
+
}
|
39 |
+
|
40 |
+
// marshalIndent, err := json.MarshalIndent(req, "", " ")
|
41 |
+
// if err != nil {
|
42 |
+
// return err
|
43 |
+
// }
|
44 |
+
// log.Printf("Received completion request: \n%s\n", marshalIndent)
|
45 |
+
// 将 ChatGPTRequest 转换为 MonicaRequest
|
46 |
+
monicaReq, err := types.ChatGPTToMonica(req)
|
47 |
+
if err != nil {
|
48 |
+
return c.JSON(http.StatusInternalServerError, map[string]interface{}{
|
49 |
+
"error": err.Error(),
|
50 |
+
})
|
51 |
+
}
|
52 |
+
|
53 |
+
// 调用 Monica 并获取 SSE Stream
|
54 |
+
stream, err := monica.SendMonicaRequest(c.Request().Context(), monicaReq)
|
55 |
+
if err != nil {
|
56 |
+
return c.JSON(http.StatusInternalServerError, map[string]interface{}{
|
57 |
+
"error": err.Error(),
|
58 |
+
})
|
59 |
+
}
|
60 |
+
// Resty 不会自动关闭 Body,需要我们自己来处理
|
61 |
+
defer stream.RawBody().Close()
|
62 |
+
|
63 |
+
// 根据 stream 参数决定是否使用流式响应
|
64 |
+
if req.Stream {
|
65 |
+
// 使用流式响应
|
66 |
+
c.Response().Header().Set(echo.HeaderContentType, "text/event-stream")
|
67 |
+
c.Response().Header().Set("Cache-Control", "no-cache")
|
68 |
+
c.Response().Header().Set("Transfer-Encoding", "chunked")
|
69 |
+
c.Response().WriteHeader(http.StatusOK)
|
70 |
+
|
71 |
+
// 将 Monica 的 SSE 数据逐行读出,再以 SSE 格式返回给调用方
|
72 |
+
if err := monica.StreamMonicaSSEToClient(req.Model, c.Response().Writer, stream.RawBody()); err != nil {
|
73 |
+
return err
|
74 |
+
}
|
75 |
+
} else {
|
76 |
+
// 使用非流式响应
|
77 |
+
c.Response().Header().Set(echo.HeaderContentType, "application/json")
|
78 |
+
|
79 |
+
// 收集所有的 SSE 数据并转换为完整的响应
|
80 |
+
response, err := monica.CollectMonicaSSEToCompletion(req.Model, stream.RawBody())
|
81 |
+
if err != nil {
|
82 |
+
return c.JSON(http.StatusInternalServerError, map[string]interface{}{
|
83 |
+
"error": err.Error(),
|
84 |
+
})
|
85 |
+
}
|
86 |
+
|
87 |
+
// 返回完整的响应
|
88 |
+
return c.JSON(http.StatusOK, response)
|
89 |
+
}
|
90 |
+
|
91 |
+
return nil
|
92 |
+
}
|
93 |
+
|
94 |
+
// handleListModels 返回支持的模型列表
|
95 |
+
func handleListModels(c echo.Context) error {
|
96 |
+
models := types.GetSupportedModels()
|
97 |
+
return c.JSON(http.StatusOK, models)
|
98 |
+
}
|
internal/config/config.go
ADDED
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
package config
|
2 |
+
|
3 |
+
import (
|
4 |
+
"os"
|
5 |
+
|
6 |
+
"github.com/joho/godotenv"
|
7 |
+
)
|
8 |
+
|
9 |
+
var MonicaConfig *Config
|
10 |
+
|
11 |
+
// Config 存储应用配置
|
12 |
+
type Config struct {
|
13 |
+
MonicaCookie string
|
14 |
+
BearerToken string
|
15 |
+
}
|
16 |
+
|
17 |
+
// LoadConfig 从环境变量加载配置
|
18 |
+
func LoadConfig() *Config {
|
19 |
+
// 尝试加载 .env 文件,但不强制要求文件存在
|
20 |
+
_ = godotenv.Load()
|
21 |
+
|
22 |
+
MonicaConfig = &Config{
|
23 |
+
MonicaCookie: os.Getenv("MONICA_COOKIE"),
|
24 |
+
BearerToken: os.Getenv("BEARER_TOKEN"),
|
25 |
+
}
|
26 |
+
return MonicaConfig
|
27 |
+
}
|
internal/middleware/auth.go
ADDED
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
package middleware
|
2 |
+
|
3 |
+
import (
|
4 |
+
"log"
|
5 |
+
"monica-proxy/internal/config"
|
6 |
+
"net/http"
|
7 |
+
"strings"
|
8 |
+
|
9 |
+
"github.com/labstack/echo/v4"
|
10 |
+
)
|
11 |
+
|
12 |
+
// BearerAuth 创建一个Bearer Token认证中间件
|
13 |
+
func BearerAuth() echo.MiddlewareFunc {
|
14 |
+
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
15 |
+
return func(c echo.Context) error {
|
16 |
+
// 获取Authorization header
|
17 |
+
auth := c.Request().Header.Get("Authorization")
|
18 |
+
|
19 |
+
// 检查header格式
|
20 |
+
if auth == "" || !strings.HasPrefix(auth, "Bearer ") {
|
21 |
+
return echo.NewHTTPError(http.StatusUnauthorized, "invalid authorization header")
|
22 |
+
}
|
23 |
+
|
24 |
+
// 提取token
|
25 |
+
token := strings.TrimPrefix(auth, "Bearer ")
|
26 |
+
|
27 |
+
// 验证token
|
28 |
+
if token != config.MonicaConfig.BearerToken || token == "" {
|
29 |
+
log.Printf("invalid token: %s", token)
|
30 |
+
return echo.NewHTTPError(http.StatusUnauthorized, "invalid token")
|
31 |
+
}
|
32 |
+
|
33 |
+
return next(c)
|
34 |
+
}
|
35 |
+
}
|
36 |
+
}
|
internal/monica/client.go
ADDED
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
package monica
|
2 |
+
|
3 |
+
import (
|
4 |
+
"context"
|
5 |
+
"github.com/go-resty/resty/v2"
|
6 |
+
"log"
|
7 |
+
"monica-proxy/internal/config"
|
8 |
+
"monica-proxy/internal/types"
|
9 |
+
"monica-proxy/internal/utils"
|
10 |
+
)
|
11 |
+
|
12 |
+
// SendMonicaRequest 发起对 Monica AI 的请求(使用 resty)
|
13 |
+
func SendMonicaRequest(ctx context.Context, mReq *types.MonicaRequest) (*resty.Response, error) {
|
14 |
+
// 发起请求
|
15 |
+
resp, err := utils.RestySSEClient.R().
|
16 |
+
SetContext(ctx).
|
17 |
+
SetHeader("cookie", config.MonicaConfig.MonicaCookie).
|
18 |
+
SetBody(mReq).
|
19 |
+
Post(types.BotChatURL)
|
20 |
+
|
21 |
+
if err != nil {
|
22 |
+
log.Printf("monica API error: %v", err)
|
23 |
+
return nil, err
|
24 |
+
}
|
25 |
+
|
26 |
+
// 如果需要在这里做更多判断,可自行补充
|
27 |
+
return resp, nil
|
28 |
+
}
|
internal/monica/sse.go
ADDED
@@ -0,0 +1,314 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
package monica
|
2 |
+
|
3 |
+
import (
|
4 |
+
"bufio"
|
5 |
+
"bytes"
|
6 |
+
"fmt"
|
7 |
+
"io"
|
8 |
+
"sync"
|
9 |
+
"time"
|
10 |
+
|
11 |
+
"monica-proxy/internal/types"
|
12 |
+
"monica-proxy/internal/utils"
|
13 |
+
"net/http"
|
14 |
+
"strings"
|
15 |
+
|
16 |
+
"github.com/bytedance/sonic"
|
17 |
+
"github.com/sashabaranov/go-openai"
|
18 |
+
)
|
19 |
+
|
20 |
+
const (
|
21 |
+
sseObject = "chat.completion.chunk"
|
22 |
+
sseFinish = "[DONE]"
|
23 |
+
flushInterval = 100 * time.Millisecond // 刷新间隔
|
24 |
+
bufferSize = 4096 // 缓冲区大小
|
25 |
+
|
26 |
+
dataPrefix = "data: "
|
27 |
+
dataPrefixLen = len(dataPrefix)
|
28 |
+
lineEnd = "\n\n"
|
29 |
+
)
|
30 |
+
|
31 |
+
// SSEData 用于解析 Monica SSE json
|
32 |
+
type SSEData struct {
|
33 |
+
Text string `json:"text"`
|
34 |
+
Finished bool `json:"finished"`
|
35 |
+
AgentStatus AgentStatus `json:"agent_status,omitempty"`
|
36 |
+
}
|
37 |
+
|
38 |
+
type AgentStatus struct {
|
39 |
+
UID string `json:"uid"`
|
40 |
+
Type string `json:"type"`
|
41 |
+
Text string `json:"text"`
|
42 |
+
Metadata struct {
|
43 |
+
Title string `json:"title"`
|
44 |
+
ReasoningDetail string `json:"reasoning_detail"`
|
45 |
+
} `json:"metadata"`
|
46 |
+
}
|
47 |
+
|
48 |
+
var sseDataPool = sync.Pool{
|
49 |
+
New: func() interface{} {
|
50 |
+
return &SSEData{}
|
51 |
+
},
|
52 |
+
}
|
53 |
+
|
54 |
+
// processMonicaSSE 处理Monica的SSE数据
|
55 |
+
type processMonicaSSE struct {
|
56 |
+
reader *bufio.Reader
|
57 |
+
model string
|
58 |
+
buf []byte
|
59 |
+
}
|
60 |
+
|
61 |
+
// handleSSEData 处理单条SSE数据
|
62 |
+
type handleSSEData func(*SSEData) error
|
63 |
+
|
64 |
+
// processSSEStream 处理SSE流
|
65 |
+
func (p *processMonicaSSE) processSSEStream(handler handleSSEData) error {
|
66 |
+
if p.buf == nil {
|
67 |
+
p.buf = make([]byte, 4096)
|
68 |
+
}
|
69 |
+
|
70 |
+
var line []byte
|
71 |
+
var err error
|
72 |
+
for {
|
73 |
+
line, err = p.reader.ReadBytes('\n')
|
74 |
+
if err != nil {
|
75 |
+
if err == io.EOF {
|
76 |
+
return nil
|
77 |
+
}
|
78 |
+
return fmt.Errorf("read error: %w", err)
|
79 |
+
}
|
80 |
+
|
81 |
+
// Monica SSE 的行前缀一般是 "data: "
|
82 |
+
if len(line) < dataPrefixLen || !bytes.HasPrefix(line, []byte(dataPrefix)) {
|
83 |
+
continue
|
84 |
+
}
|
85 |
+
|
86 |
+
jsonStr := line[dataPrefixLen : len(line)-1] // 去掉\n
|
87 |
+
if len(jsonStr) == 0 {
|
88 |
+
continue
|
89 |
+
}
|
90 |
+
|
91 |
+
// 如果是 [DONE] 则结束
|
92 |
+
if bytes.Equal(jsonStr, []byte(sseFinish)) {
|
93 |
+
return nil
|
94 |
+
}
|
95 |
+
|
96 |
+
// 从对象池获取一个对象
|
97 |
+
sseData := sseDataPool.Get().(*SSEData)
|
98 |
+
|
99 |
+
// 解析 JSON
|
100 |
+
if err := sonic.Unmarshal(jsonStr, sseData); err != nil {
|
101 |
+
sseData = &SSEData{}
|
102 |
+
sseDataPool.Put(sseData)
|
103 |
+
return fmt.Errorf("unmarshal error: %w", err)
|
104 |
+
}
|
105 |
+
|
106 |
+
// 调用处理函数
|
107 |
+
if err := handler(sseData); err != nil {
|
108 |
+
sseData = &SSEData{}
|
109 |
+
sseDataPool.Put(sseData)
|
110 |
+
return err
|
111 |
+
}
|
112 |
+
|
113 |
+
// 使用完后清理并放回对象池
|
114 |
+
sseData = &SSEData{}
|
115 |
+
sseDataPool.Put(sseData)
|
116 |
+
}
|
117 |
+
}
|
118 |
+
|
119 |
+
// CollectMonicaSSEToCompletion 将 Monica SSE 转换为完整的 ChatCompletion 响应
|
120 |
+
func CollectMonicaSSEToCompletion(model string, r io.Reader) (*openai.ChatCompletionResponse, error) {
|
121 |
+
var fullContent strings.Builder
|
122 |
+
processor := &processMonicaSSE{
|
123 |
+
reader: bufio.NewReaderSize(r, bufferSize),
|
124 |
+
model: model,
|
125 |
+
}
|
126 |
+
|
127 |
+
// 处理SSE数据
|
128 |
+
err := processor.processSSEStream(func(sseData *SSEData) error {
|
129 |
+
// 如果是 agent_status,跳过
|
130 |
+
if sseData.AgentStatus.Type != "" {
|
131 |
+
return nil
|
132 |
+
}
|
133 |
+
// 累积内容
|
134 |
+
fullContent.WriteString(sseData.Text)
|
135 |
+
return nil
|
136 |
+
})
|
137 |
+
|
138 |
+
if err != nil {
|
139 |
+
return nil, err
|
140 |
+
}
|
141 |
+
|
142 |
+
// 构造完整的响应
|
143 |
+
response := &openai.ChatCompletionResponse{
|
144 |
+
ID: fmt.Sprintf("chatcmpl-%s", utils.RandStringUsingMathRand(29)),
|
145 |
+
Object: "chat.completion",
|
146 |
+
Created: time.Now().Unix(),
|
147 |
+
Model: model,
|
148 |
+
Choices: []openai.ChatCompletionChoice{
|
149 |
+
{
|
150 |
+
Index: 0,
|
151 |
+
Message: openai.ChatCompletionMessage{
|
152 |
+
Role: "assistant",
|
153 |
+
Content: fullContent.String(),
|
154 |
+
},
|
155 |
+
FinishReason: "stop",
|
156 |
+
},
|
157 |
+
},
|
158 |
+
Usage: openai.Usage{
|
159 |
+
// Monica API 不提供 token 使用信息,这里暂时填 0
|
160 |
+
PromptTokens: 0,
|
161 |
+
CompletionTokens: 0,
|
162 |
+
TotalTokens: 0,
|
163 |
+
},
|
164 |
+
}
|
165 |
+
|
166 |
+
return response, nil
|
167 |
+
}
|
168 |
+
|
169 |
+
// StreamMonicaSSEToClient 将 Monica SSE 转成前端可用的流
|
170 |
+
func StreamMonicaSSEToClient(model string, w io.Writer, r io.Reader) error {
|
171 |
+
writer := bufio.NewWriterSize(w, bufferSize)
|
172 |
+
defer writer.Flush()
|
173 |
+
|
174 |
+
chatId := utils.RandStringUsingMathRand(29)
|
175 |
+
now := time.Now().Unix()
|
176 |
+
fingerprint := utils.RandStringUsingMathRand(10)
|
177 |
+
|
178 |
+
// 创建一个定时刷新的 ticker
|
179 |
+
ticker := time.NewTicker(flushInterval)
|
180 |
+
defer ticker.Stop()
|
181 |
+
|
182 |
+
// 创建一个 done channel 用于清理
|
183 |
+
done := make(chan struct{})
|
184 |
+
defer close(done)
|
185 |
+
|
186 |
+
// 启动一个 goroutine 定期刷新缓冲区
|
187 |
+
go func() {
|
188 |
+
for {
|
189 |
+
select {
|
190 |
+
case <-ticker.C:
|
191 |
+
if f, ok := w.(http.Flusher); ok {
|
192 |
+
writer.Flush()
|
193 |
+
f.Flush()
|
194 |
+
}
|
195 |
+
case <-done:
|
196 |
+
return
|
197 |
+
}
|
198 |
+
}
|
199 |
+
}()
|
200 |
+
|
201 |
+
processor := &processMonicaSSE{
|
202 |
+
reader: bufio.NewReaderSize(r, bufferSize),
|
203 |
+
model: model,
|
204 |
+
}
|
205 |
+
|
206 |
+
var thinkFlag bool
|
207 |
+
return processor.processSSEStream(func(sseData *SSEData) error {
|
208 |
+
var sseMsg types.ChatCompletionStreamResponse
|
209 |
+
switch {
|
210 |
+
case sseData.Finished:
|
211 |
+
sseMsg = types.ChatCompletionStreamResponse{
|
212 |
+
ID: "chatcmpl-" + chatId,
|
213 |
+
Object: sseObject,
|
214 |
+
Created: now,
|
215 |
+
Model: model,
|
216 |
+
Choices: []types.ChatCompletionStreamChoice{
|
217 |
+
{
|
218 |
+
Index: 0,
|
219 |
+
Delta: openai.ChatCompletionStreamChoiceDelta{
|
220 |
+
Role: openai.ChatMessageRoleAssistant,
|
221 |
+
},
|
222 |
+
FinishReason: openai.FinishReasonStop,
|
223 |
+
},
|
224 |
+
},
|
225 |
+
}
|
226 |
+
case sseData.AgentStatus.Type == "thinking":
|
227 |
+
thinkFlag = true
|
228 |
+
sseMsg = types.ChatCompletionStreamResponse{
|
229 |
+
ID: "chatcmpl-" + chatId,
|
230 |
+
Object: sseObject,
|
231 |
+
SystemFingerprint: fingerprint,
|
232 |
+
Created: now,
|
233 |
+
Model: model,
|
234 |
+
Choices: []types.ChatCompletionStreamChoice{
|
235 |
+
{
|
236 |
+
Index: 0,
|
237 |
+
Delta: openai.ChatCompletionStreamChoiceDelta{
|
238 |
+
Role: openai.ChatMessageRoleAssistant,
|
239 |
+
Content: `<think>`,
|
240 |
+
},
|
241 |
+
FinishReason: openai.FinishReasonNull,
|
242 |
+
},
|
243 |
+
},
|
244 |
+
}
|
245 |
+
case sseData.AgentStatus.Type == "thinking_detail_stream":
|
246 |
+
sseMsg = types.ChatCompletionStreamResponse{
|
247 |
+
ID: "chatcmpl-" + chatId,
|
248 |
+
Object: sseObject,
|
249 |
+
SystemFingerprint: fingerprint,
|
250 |
+
Created: now,
|
251 |
+
Model: model,
|
252 |
+
Choices: []types.ChatCompletionStreamChoice{
|
253 |
+
{
|
254 |
+
Index: 0,
|
255 |
+
Delta: openai.ChatCompletionStreamChoiceDelta{
|
256 |
+
Role: openai.ChatMessageRoleAssistant,
|
257 |
+
Content: sseData.AgentStatus.Metadata.ReasoningDetail,
|
258 |
+
},
|
259 |
+
FinishReason: openai.FinishReasonNull,
|
260 |
+
},
|
261 |
+
},
|
262 |
+
}
|
263 |
+
default:
|
264 |
+
if thinkFlag {
|
265 |
+
sseData.Text = "</think>" + sseData.Text
|
266 |
+
thinkFlag = false
|
267 |
+
}
|
268 |
+
sseMsg = types.ChatCompletionStreamResponse{
|
269 |
+
ID: "chatcmpl-" + chatId,
|
270 |
+
Object: sseObject,
|
271 |
+
SystemFingerprint: fingerprint,
|
272 |
+
Created: now,
|
273 |
+
Model: model,
|
274 |
+
Choices: []types.ChatCompletionStreamChoice{
|
275 |
+
{
|
276 |
+
Index: 0,
|
277 |
+
Delta: openai.ChatCompletionStreamChoiceDelta{
|
278 |
+
Role: openai.ChatMessageRoleAssistant,
|
279 |
+
Content: sseData.Text,
|
280 |
+
},
|
281 |
+
FinishReason: openai.FinishReasonNull,
|
282 |
+
},
|
283 |
+
},
|
284 |
+
}
|
285 |
+
}
|
286 |
+
|
287 |
+
var sb strings.Builder
|
288 |
+
sb.WriteString("data: ")
|
289 |
+
sendLine, _ := sonic.MarshalString(sseMsg)
|
290 |
+
sb.WriteString(sendLine)
|
291 |
+
sb.WriteString("\n\n")
|
292 |
+
|
293 |
+
// 写入缓冲区
|
294 |
+
if _, err := writer.WriteString(sb.String()); err != nil {
|
295 |
+
return fmt.Errorf("write error: %w", err)
|
296 |
+
}
|
297 |
+
|
298 |
+
// 如果发现 finished=true,就可以结束
|
299 |
+
if sseData.Finished {
|
300 |
+
writer.WriteString(dataPrefix)
|
301 |
+
writer.WriteString(sseFinish)
|
302 |
+
writer.WriteString(lineEnd)
|
303 |
+
writer.Flush()
|
304 |
+
if f, ok := w.(http.Flusher); ok {
|
305 |
+
f.Flush()
|
306 |
+
}
|
307 |
+
return nil
|
308 |
+
}
|
309 |
+
|
310 |
+
sseData.AgentStatus.Type = ""
|
311 |
+
sseData.Finished = false
|
312 |
+
return nil
|
313 |
+
})
|
314 |
+
}
|
internal/types/image.go
ADDED
@@ -0,0 +1,210 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
package types
|
2 |
+
|
3 |
+
import (
|
4 |
+
"context"
|
5 |
+
"fmt"
|
6 |
+
"monica-proxy/internal/config"
|
7 |
+
"monica-proxy/internal/utils"
|
8 |
+
"net/http"
|
9 |
+
"strings"
|
10 |
+
"sync"
|
11 |
+
"time"
|
12 |
+
|
13 |
+
"github.com/cespare/xxhash/v2"
|
14 |
+
"github.com/google/uuid"
|
15 |
+
)
|
16 |
+
|
17 |
+
const MaxFileSize = 10 * 1024 * 1024 // 10MB
|
18 |
+
|
19 |
+
var imageCache sync.Map
|
20 |
+
|
21 |
+
// sampleAndHash 对base64字符串进行采样并计算xxHash
|
22 |
+
func sampleAndHash(data string) string {
|
23 |
+
// 如果数据长度小于1024,直接计算整个字符串的哈希
|
24 |
+
if len(data) <= 1024 {
|
25 |
+
return fmt.Sprintf("%x", xxhash.Sum64String(data))
|
26 |
+
}
|
27 |
+
|
28 |
+
// 采样策略:
|
29 |
+
// 1. 取前256字节
|
30 |
+
// 2. 取中间256字节
|
31 |
+
// 3. 取最后256字节
|
32 |
+
var samples []string
|
33 |
+
samples = append(samples, data[:256])
|
34 |
+
mid := len(data) / 2
|
35 |
+
samples = append(samples, data[mid-128:mid+128])
|
36 |
+
samples = append(samples, data[len(data)-256:])
|
37 |
+
|
38 |
+
// 将采样数据拼接后计算哈希
|
39 |
+
return fmt.Sprintf("%x", xxhash.Sum64String(strings.Join(samples, "")))
|
40 |
+
}
|
41 |
+
|
42 |
+
// UploadBase64Image 上传base64编码的图片到Monica
|
43 |
+
func UploadBase64Image(ctx context.Context, base64Data string) (*FileInfo, error) {
|
44 |
+
// 1. 生成缓存key
|
45 |
+
cacheKey := sampleAndHash(base64Data)
|
46 |
+
|
47 |
+
// 2. 检查缓存
|
48 |
+
if value, exists := imageCache.Load(cacheKey); exists {
|
49 |
+
return value.(*FileInfo), nil
|
50 |
+
}
|
51 |
+
|
52 |
+
// 3. 解析base64数据
|
53 |
+
// 移除 "data:image/png;base64," 这样的前缀
|
54 |
+
parts := strings.Split(base64Data, ",")
|
55 |
+
if len(parts) != 2 {
|
56 |
+
return nil, fmt.Errorf("invalid base64 image format")
|
57 |
+
}
|
58 |
+
|
59 |
+
// 获取图片类型
|
60 |
+
mimeType := strings.TrimSuffix(strings.TrimPrefix(parts[0], "data:"), ";base64")
|
61 |
+
if !strings.HasPrefix(mimeType, "image/") {
|
62 |
+
return nil, fmt.Errorf("invalid image mime type: %s", mimeType)
|
63 |
+
}
|
64 |
+
|
65 |
+
// 解码base64数据
|
66 |
+
imageData, err := utils.Base64Decode(parts[1])
|
67 |
+
if err != nil {
|
68 |
+
return nil, fmt.Errorf("decode base64 failed: %v", err)
|
69 |
+
}
|
70 |
+
|
71 |
+
// 4. 验证图片格式和大小
|
72 |
+
fileInfo, err := validateImageBytes(imageData, mimeType)
|
73 |
+
if err != nil {
|
74 |
+
return nil, fmt.Errorf("validate image failed: %v", err)
|
75 |
+
}
|
76 |
+
// log.Printf("file info: %+v", fileInfo)
|
77 |
+
|
78 |
+
// 5. 获取预签名URL
|
79 |
+
preSignReq := &PreSignRequest{
|
80 |
+
FilenameList: []string{fileInfo.FileName},
|
81 |
+
Module: ImageModule,
|
82 |
+
Location: ImageLocation,
|
83 |
+
ObjID: uuid.New().String(),
|
84 |
+
}
|
85 |
+
|
86 |
+
var preSignResp PreSignResponse
|
87 |
+
_, err = utils.RestyDefaultClient.R().
|
88 |
+
SetContext(ctx).
|
89 |
+
SetHeader("cookie", config.MonicaConfig.MonicaCookie).
|
90 |
+
SetBody(preSignReq).
|
91 |
+
SetResult(&preSignResp).
|
92 |
+
Post(PreSignURL)
|
93 |
+
|
94 |
+
if err != nil {
|
95 |
+
return nil, fmt.Errorf("get pre-sign url failed: %v", err)
|
96 |
+
}
|
97 |
+
|
98 |
+
if len(preSignResp.Data.PreSignURLList) == 0 || len(preSignResp.Data.ObjectURLList) == 0 {
|
99 |
+
return nil, fmt.Errorf("no pre-sign url or object url returned")
|
100 |
+
}
|
101 |
+
// log.Printf("preSign info: %+v", preSignResp)
|
102 |
+
|
103 |
+
// 6. 上传图片数据
|
104 |
+
_, err = utils.RestyDefaultClient.R().
|
105 |
+
SetContext(ctx).
|
106 |
+
SetHeader("Content-Type", fileInfo.FileType).
|
107 |
+
SetBody(imageData).
|
108 |
+
Put(preSignResp.Data.PreSignURLList[0])
|
109 |
+
|
110 |
+
if err != nil {
|
111 |
+
return nil, fmt.Errorf("upload file failed: %v", err)
|
112 |
+
}
|
113 |
+
|
114 |
+
// 7. 创建文件对象
|
115 |
+
fileInfo.ObjectURL = preSignResp.Data.ObjectURLList[0]
|
116 |
+
uploadReq := &FileUploadRequest{
|
117 |
+
Data: []FileInfo{*fileInfo},
|
118 |
+
}
|
119 |
+
|
120 |
+
var uploadResp FileUploadResponse
|
121 |
+
_, err = utils.RestyDefaultClient.R().
|
122 |
+
SetContext(ctx).
|
123 |
+
SetHeader("cookie", config.MonicaConfig.MonicaCookie).
|
124 |
+
SetBody(uploadReq).
|
125 |
+
SetResult(&uploadResp).
|
126 |
+
Post(FileUploadURL)
|
127 |
+
|
128 |
+
if err != nil {
|
129 |
+
return nil, fmt.Errorf("create file object failed: %v", err)
|
130 |
+
}
|
131 |
+
// log.Printf("uploadResp: %+v", uploadResp)
|
132 |
+
if len(uploadResp.Data.Items) > 0 {
|
133 |
+
fileInfo.FileName = uploadResp.Data.Items[0].FileName
|
134 |
+
fileInfo.FileType = uploadResp.Data.Items[0].FileType
|
135 |
+
fileInfo.FileSize = uploadResp.Data.Items[0].FileSize
|
136 |
+
fileInfo.FileUID = uploadResp.Data.Items[0].FileUID
|
137 |
+
fileInfo.FileExt = uploadResp.Data.Items[0].FileType
|
138 |
+
fileInfo.FileTokens = uploadResp.Data.Items[0].FileTokens
|
139 |
+
fileInfo.FileChunks = uploadResp.Data.Items[0].FileChunks
|
140 |
+
}
|
141 |
+
|
142 |
+
fileInfo.UseFullText = true
|
143 |
+
fileInfo.FileURL = preSignResp.Data.CDNURLList[0]
|
144 |
+
|
145 |
+
// 8. 获取文件llm读取结果知道有返回
|
146 |
+
var batchResp FileBatchGetResponse
|
147 |
+
reqMap := make(map[string][]string)
|
148 |
+
reqMap["file_uids"] = []string{fileInfo.FileUID}
|
149 |
+
var retryCount = 1
|
150 |
+
for {
|
151 |
+
if retryCount > 5 {
|
152 |
+
return nil, fmt.Errorf("retry limit exceeded")
|
153 |
+
}
|
154 |
+
_, err = utils.RestyDefaultClient.R().
|
155 |
+
SetContext(ctx).
|
156 |
+
SetHeader("cookie", config.MonicaConfig.MonicaCookie).
|
157 |
+
SetBody(reqMap).
|
158 |
+
SetResult(&batchResp).
|
159 |
+
Post(FileGetURL)
|
160 |
+
if err != nil {
|
161 |
+
return nil, fmt.Errorf("batch get file failed: %v", err)
|
162 |
+
}
|
163 |
+
if len(batchResp.Data.Items) > 0 && batchResp.Data.Items[0].FileChunks > 0 {
|
164 |
+
break
|
165 |
+
} else {
|
166 |
+
retryCount++
|
167 |
+
}
|
168 |
+
time.Sleep(1 * time.Second)
|
169 |
+
}
|
170 |
+
fileInfo.FileChunks = batchResp.Data.Items[0].FileChunks
|
171 |
+
fileInfo.FileTokens = batchResp.Data.Items[0].FileTokens
|
172 |
+
fileInfo.URL = ""
|
173 |
+
fileInfo.ObjectURL = ""
|
174 |
+
|
175 |
+
// 9. 保存到缓存
|
176 |
+
imageCache.Store(cacheKey, fileInfo)
|
177 |
+
|
178 |
+
return fileInfo, nil
|
179 |
+
}
|
180 |
+
|
181 |
+
// validateImageBytes 验证图片字节数据的格式和大小
|
182 |
+
func validateImageBytes(imageData []byte, mimeType string) (*FileInfo, error) {
|
183 |
+
if len(imageData) > MaxFileSize {
|
184 |
+
return nil, fmt.Errorf("file size exceeds limit: %d > %d", len(imageData), MaxFileSize)
|
185 |
+
}
|
186 |
+
|
187 |
+
contentType := http.DetectContentType(imageData)
|
188 |
+
if !SupportedImageTypes[contentType] {
|
189 |
+
return nil, fmt.Errorf("unsupported image type: %s", contentType)
|
190 |
+
}
|
191 |
+
|
192 |
+
// 根据MIME类型生成文件扩展名
|
193 |
+
ext := ".png"
|
194 |
+
switch mimeType {
|
195 |
+
case "image/jpeg":
|
196 |
+
ext = ".jpg"
|
197 |
+
case "image/gif":
|
198 |
+
ext = ".gif"
|
199 |
+
case "image/webp":
|
200 |
+
ext = ".webp"
|
201 |
+
}
|
202 |
+
|
203 |
+
fileName := fmt.Sprintf("%s%s", uuid.New().String(), ext)
|
204 |
+
|
205 |
+
return &FileInfo{
|
206 |
+
FileName: fileName,
|
207 |
+
FileSize: int64(len(imageData)),
|
208 |
+
FileType: contentType,
|
209 |
+
}, nil
|
210 |
+
}
|
internal/types/monica.go
ADDED
@@ -0,0 +1,361 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
package types
|
2 |
+
|
3 |
+
import (
|
4 |
+
"context"
|
5 |
+
"fmt"
|
6 |
+
"log"
|
7 |
+
"strings"
|
8 |
+
|
9 |
+
lop "github.com/samber/lo/parallel"
|
10 |
+
|
11 |
+
"github.com/google/uuid"
|
12 |
+
"github.com/sashabaranov/go-openai"
|
13 |
+
)
|
14 |
+
|
15 |
+
const (
|
16 |
+
BotChatURL = "https://api.monica.im/api/custom_bot/chat"
|
17 |
+
PreSignURL = "https://api.monica.im/api/file_object/pre_sign_list_by_module"
|
18 |
+
FileUploadURL = "https://api.monica.im/api/files/batch_create_llm_file"
|
19 |
+
FileGetURL = "https://api.monica.im/api/files/batch_get_file"
|
20 |
+
)
|
21 |
+
|
22 |
+
// 图片相关常量
|
23 |
+
const (
|
24 |
+
MaxImageSize = 10 * 1024 * 1024 // 10MB
|
25 |
+
ImageModule = "chat_bot"
|
26 |
+
ImageLocation = "files"
|
27 |
+
)
|
28 |
+
|
29 |
+
// 支持的图片格式
|
30 |
+
var SupportedImageTypes = map[string]bool{
|
31 |
+
"image/jpeg": true,
|
32 |
+
"image/png": true,
|
33 |
+
"image/gif": true,
|
34 |
+
"image/webp": true,
|
35 |
+
}
|
36 |
+
|
37 |
+
type ChatGPTRequest struct {
|
38 |
+
Model string `json:"model"` // gpt-3.5-turbo, gpt-4, ...
|
39 |
+
Messages []ChatMessage `json:"messages"` // 对话数组
|
40 |
+
Stream bool `json:"stream"` // 是否流式返回
|
41 |
+
}
|
42 |
+
|
43 |
+
type ChatMessage struct {
|
44 |
+
Role string `json:"role"` // "system", "user", "assistant"
|
45 |
+
Content interface{} `json:"content"` // 可以是字符串或MessageContent数组
|
46 |
+
}
|
47 |
+
|
48 |
+
// MessageContent 消息内容
|
49 |
+
type MessageContent struct {
|
50 |
+
Type string `json:"type"` // "text" 或 "image_url"
|
51 |
+
Text string `json:"text,omitempty"` // 文本内容
|
52 |
+
ImageURL string `json:"image_url,omitempty"` // 图片URL
|
53 |
+
}
|
54 |
+
|
55 |
+
// MonicaRequest 为 Monica 自定义 AI 的请求格式
|
56 |
+
type MonicaRequest struct {
|
57 |
+
TaskUID string `json:"task_uid"`
|
58 |
+
BotUID string `json:"bot_uid"`
|
59 |
+
Data DataField `json:"data"`
|
60 |
+
Language string `json:"language"`
|
61 |
+
TaskType string `json:"task_type"`
|
62 |
+
ToolData ToolData `json:"tool_data"`
|
63 |
+
}
|
64 |
+
|
65 |
+
// DataField 在 Monica 的 body 中
|
66 |
+
type DataField struct {
|
67 |
+
ConversationID string `json:"conversation_id"`
|
68 |
+
PreParentItemID string `json:"pre_parent_item_id"`
|
69 |
+
Items []Item `json:"items"`
|
70 |
+
TriggerBy string `json:"trigger_by"`
|
71 |
+
UseModel string `json:"use_model,omitempty"`
|
72 |
+
IsIncognito bool `json:"is_incognito"`
|
73 |
+
UseNewMemory bool `json:"use_new_memory"`
|
74 |
+
}
|
75 |
+
|
76 |
+
type Item struct {
|
77 |
+
ConversationID string `json:"conversation_id"`
|
78 |
+
ParentItemID string `json:"parent_item_id,omitempty"`
|
79 |
+
ItemID string `json:"item_id"`
|
80 |
+
ItemType string `json:"item_type"`
|
81 |
+
Data ItemContent `json:"data"`
|
82 |
+
}
|
83 |
+
|
84 |
+
type ItemContent struct {
|
85 |
+
Type string `json:"type"`
|
86 |
+
Content string `json:"content"`
|
87 |
+
MaxToken int `json:"max_token,omitempty"`
|
88 |
+
IsIncognito bool `json:"is_incognito,omitempty"` // 是否无痕模式
|
89 |
+
FromTaskType string `json:"from_task_type,omitempty"`
|
90 |
+
ManualWebSearchEnabled bool `json:"manual_web_search_enabled,omitempty"` // 网页搜索
|
91 |
+
UseModel string `json:"use_model,omitempty"`
|
92 |
+
FileInfos []FileInfo `json:"file_infos,omitempty"`
|
93 |
+
}
|
94 |
+
|
95 |
+
// ToolData 这里演示放空
|
96 |
+
type ToolData struct {
|
97 |
+
SysSkillList []string `json:"sys_skill_list"`
|
98 |
+
}
|
99 |
+
|
100 |
+
// PreSignRequest 预签名请求
|
101 |
+
type PreSignRequest struct {
|
102 |
+
FilenameList []string `json:"filename_list"`
|
103 |
+
Module string `json:"module"`
|
104 |
+
Location string `json:"location"`
|
105 |
+
ObjID string `json:"obj_id"`
|
106 |
+
}
|
107 |
+
|
108 |
+
// PreSignResponse 预签名响应
|
109 |
+
type PreSignResponse struct {
|
110 |
+
Code int `json:"code"`
|
111 |
+
Msg string `json:"msg"`
|
112 |
+
Data struct {
|
113 |
+
PreSignURLList []string `json:"pre_sign_url_list"`
|
114 |
+
ObjectURLList []string `json:"object_url_list"`
|
115 |
+
CDNURLList []string `json:"cdn_url_list"`
|
116 |
+
} `json:"data"`
|
117 |
+
}
|
118 |
+
|
119 |
+
// FileInfo 文件信息
|
120 |
+
type FileInfo struct {
|
121 |
+
URL string `json:"url,omitempty"`
|
122 |
+
FileURL string `json:"file_url"`
|
123 |
+
FileUID string `json:"file_uid"`
|
124 |
+
Parse bool `json:"parse"`
|
125 |
+
FileName string `json:"file_name"`
|
126 |
+
FileSize int64 `json:"file_size"`
|
127 |
+
FileType string `json:"file_type"`
|
128 |
+
FileExt string `json:"file_ext"`
|
129 |
+
FileTokens int64 `json:"file_tokens"`
|
130 |
+
FileChunks int64 `json:"file_chunks"`
|
131 |
+
ObjectURL string `json:"object_url,omitempty"`
|
132 |
+
//Embedding bool `json:"embedding"`
|
133 |
+
FileMetaInfo map[string]interface{} `json:"file_meta_info,omitempty"`
|
134 |
+
UseFullText bool `json:"use_full_text"`
|
135 |
+
}
|
136 |
+
|
137 |
+
// FileUploadRequest 文件上传请求
|
138 |
+
type FileUploadRequest struct {
|
139 |
+
Data []FileInfo `json:"data"`
|
140 |
+
}
|
141 |
+
|
142 |
+
// FileUploadResponse 文件上传响应
|
143 |
+
type FileUploadResponse struct {
|
144 |
+
Code int `json:"code"`
|
145 |
+
Msg string `json:"msg"`
|
146 |
+
Data struct {
|
147 |
+
Items []struct {
|
148 |
+
FileName string `json:"file_name"`
|
149 |
+
FileType string `json:"file_type"`
|
150 |
+
FileSize int64 `json:"file_size"`
|
151 |
+
FileUID string `json:"file_uid"`
|
152 |
+
FileTokens int64 `json:"file_tokens"`
|
153 |
+
FileChunks int64 `json:"file_chunks"`
|
154 |
+
// 其他字段暂时不需要
|
155 |
+
} `json:"items"`
|
156 |
+
} `json:"data"`
|
157 |
+
}
|
158 |
+
|
159 |
+
// FileBatchGetResponse 获取文件llm处理是否完成
|
160 |
+
type FileBatchGetResponse struct {
|
161 |
+
Data struct {
|
162 |
+
Items []struct {
|
163 |
+
FileName string `json:"file_name"`
|
164 |
+
FileType string `json:"file_type"`
|
165 |
+
FileSize int `json:"file_size"`
|
166 |
+
ObjectUrl string `json:"object_url"`
|
167 |
+
Url string `json:"url"`
|
168 |
+
FileMetaInfo struct {
|
169 |
+
} `json:"file_meta_info"`
|
170 |
+
DriveFileUid string `json:"drive_file_uid"`
|
171 |
+
FileUid string `json:"file_uid"`
|
172 |
+
IndexState int `json:"index_state"`
|
173 |
+
IndexDesc string `json:"index_desc"`
|
174 |
+
ErrorMessage string `json:"error_message"`
|
175 |
+
FileTokens int64 `json:"file_tokens"`
|
176 |
+
FileChunks int64 `json:"file_chunks"`
|
177 |
+
IndexProgress int `json:"index_progress"`
|
178 |
+
} `json:"items"`
|
179 |
+
} `json:"data"`
|
180 |
+
}
|
181 |
+
|
182 |
+
// OpenAIModel represents a model in the OpenAI API format
|
183 |
+
type OpenAIModel struct {
|
184 |
+
ID string `json:"id"`
|
185 |
+
Object string `json:"object"`
|
186 |
+
OwnedBy string `json:"owned_by"`
|
187 |
+
}
|
188 |
+
|
189 |
+
// OpenAIModelList represents the response format for the /v1/models endpoint
|
190 |
+
type OpenAIModelList struct {
|
191 |
+
Object string `json:"object"`
|
192 |
+
Data []OpenAIModel `json:"data"`
|
193 |
+
}
|
194 |
+
|
195 |
+
// GetSupportedModels returns all supported models in OpenAI format
|
196 |
+
func GetSupportedModels() OpenAIModelList {
|
197 |
+
models := []OpenAIModel{
|
198 |
+
{ID: "gpt-4o-mini", Object: "model", OwnedBy: "monica"},
|
199 |
+
{ID: "gpt-4o", Object: "model", OwnedBy: "monica"},
|
200 |
+
{ID: "claude-3-7-sonnet", Object: "model", OwnedBy: "monica"},
|
201 |
+
{ID: "claude-3-5-sonnet", Object: "model", OwnedBy: "monica"},
|
202 |
+
{ID: "claude-3-5-haiku", Object: "model", OwnedBy: "monica"},
|
203 |
+
{ID: "gemini-2.0-pro", Object: "model", OwnedBy: "monica"},
|
204 |
+
{ID: "gemini-2.0-flash", Object: "model", OwnedBy: "monica"},
|
205 |
+
{ID: "gemini-1.5-pro", Object: "model", OwnedBy: "monica"},
|
206 |
+
{ID: "o3-mini", Object: "model", OwnedBy: "monica"},
|
207 |
+
{ID: "o1-preview", Object: "model", OwnedBy: "monica"},
|
208 |
+
{ID: "deepseek-reasoner", Object: "model", OwnedBy: "monica"},
|
209 |
+
{ID: "deepseek-chat", Object: "model", OwnedBy: "monica"},
|
210 |
+
{ID: "deepclaude", Object: "model", OwnedBy: "monica"},
|
211 |
+
{ID: "sonar", Object: "model", OwnedBy: "monica"},
|
212 |
+
}
|
213 |
+
|
214 |
+
return OpenAIModelList{
|
215 |
+
Object: "list",
|
216 |
+
Data: models,
|
217 |
+
}
|
218 |
+
}
|
219 |
+
|
220 |
+
// ChatGPTToMonica 将 ChatGPTRequest 转换为 MonicaRequest
|
221 |
+
func ChatGPTToMonica(chatReq openai.ChatCompletionRequest) (*MonicaRequest, error) {
|
222 |
+
if len(chatReq.Messages) == 0 {
|
223 |
+
return nil, fmt.Errorf("empty messages")
|
224 |
+
}
|
225 |
+
|
226 |
+
// 生成会话ID
|
227 |
+
conversationID := fmt.Sprintf("conv:%s", uuid.New().String())
|
228 |
+
|
229 |
+
// 转换消息
|
230 |
+
|
231 |
+
// 设置默认欢迎消息头,不加上就有几率去掉问题最后的十几个token,不清楚是不是bug
|
232 |
+
defaultItem := Item{
|
233 |
+
ItemID: fmt.Sprintf("msg:%s", uuid.New().String()),
|
234 |
+
ConversationID: conversationID,
|
235 |
+
ItemType: "reply",
|
236 |
+
Data: ItemContent{Type: "text", Content: "__RENDER_BOT_WELCOME_MSG__"},
|
237 |
+
}
|
238 |
+
var items = make([]Item, 1, len(chatReq.Messages))
|
239 |
+
items[0] = defaultItem
|
240 |
+
preItemID := defaultItem.ItemID
|
241 |
+
|
242 |
+
for _, msg := range chatReq.Messages {
|
243 |
+
if msg.Role == "system" {
|
244 |
+
// monica不支持设置prompt,所以直接跳过
|
245 |
+
continue
|
246 |
+
}
|
247 |
+
var msgContext string
|
248 |
+
var imgUrl []*openai.ChatMessageImageURL
|
249 |
+
if len(msg.MultiContent) > 0 { // 说明应该是多内容,可能是图片内容
|
250 |
+
for _, content := range msg.MultiContent {
|
251 |
+
switch content.Type {
|
252 |
+
case "text":
|
253 |
+
msgContext = content.Text
|
254 |
+
case "image_url":
|
255 |
+
imgUrl = append(imgUrl, content.ImageURL)
|
256 |
+
}
|
257 |
+
}
|
258 |
+
}
|
259 |
+
itemID := fmt.Sprintf("msg:%s", uuid.New().String())
|
260 |
+
itemType := "question"
|
261 |
+
if msg.Role == "assistant" {
|
262 |
+
itemType = "reply"
|
263 |
+
}
|
264 |
+
|
265 |
+
var content ItemContent
|
266 |
+
if len(imgUrl) > 0 {
|
267 |
+
ctx := context.Background()
|
268 |
+
fileIfoList := lop.Map(imgUrl, func(item *openai.ChatMessageImageURL, _ int) FileInfo {
|
269 |
+
f, err := UploadBase64Image(ctx, item.URL)
|
270 |
+
if err != nil {
|
271 |
+
log.Println(err)
|
272 |
+
return FileInfo{}
|
273 |
+
}
|
274 |
+
return *f
|
275 |
+
})
|
276 |
+
|
277 |
+
content = ItemContent{
|
278 |
+
Type: "file_with_text",
|
279 |
+
Content: msgContext,
|
280 |
+
FileInfos: fileIfoList,
|
281 |
+
IsIncognito: true,
|
282 |
+
}
|
283 |
+
} else {
|
284 |
+
content = ItemContent{
|
285 |
+
Type: "text",
|
286 |
+
Content: msg.Content,
|
287 |
+
IsIncognito: true,
|
288 |
+
}
|
289 |
+
}
|
290 |
+
|
291 |
+
item := Item{
|
292 |
+
ConversationID: conversationID,
|
293 |
+
ItemID: itemID,
|
294 |
+
ParentItemID: preItemID,
|
295 |
+
ItemType: itemType,
|
296 |
+
Data: content,
|
297 |
+
}
|
298 |
+
items = append(items, item)
|
299 |
+
preItemID = itemID
|
300 |
+
}
|
301 |
+
|
302 |
+
// 构建请求
|
303 |
+
mReq := &MonicaRequest{
|
304 |
+
TaskUID: fmt.Sprintf("task:%s", uuid.New().String()),
|
305 |
+
BotUID: modelToBot(chatReq.Model),
|
306 |
+
Data: DataField{
|
307 |
+
ConversationID: conversationID,
|
308 |
+
Items: items,
|
309 |
+
PreParentItemID: preItemID,
|
310 |
+
TriggerBy: "auto",
|
311 |
+
IsIncognito: true,
|
312 |
+
UseModel: "", //TODO 好像写啥都没影响
|
313 |
+
UseNewMemory: false,
|
314 |
+
},
|
315 |
+
Language: "auto",
|
316 |
+
TaskType: "chat",
|
317 |
+
}
|
318 |
+
|
319 |
+
// indent, err := json.MarshalIndent(mReq, "", " ")
|
320 |
+
// if err != nil {
|
321 |
+
// return nil, err
|
322 |
+
// }
|
323 |
+
// log.Printf("send: \n%s\n", indent)
|
324 |
+
|
325 |
+
return mReq, nil
|
326 |
+
}
|
327 |
+
|
328 |
+
func modelToBot(model string) string {
|
329 |
+
switch {
|
330 |
+
case strings.HasPrefix(model, "gpt-4o-mini"):
|
331 |
+
return "gpt_4_o_mini_chat"
|
332 |
+
case strings.HasPrefix(model, "gpt-4o"):
|
333 |
+
return "gpt_4_o_chat"
|
334 |
+
case strings.HasPrefix(model, "claude-3-7-sonnet"):
|
335 |
+
return "claude_3_7_sonnet"
|
336 |
+
case strings.HasPrefix(model, "claude-3-5-sonnet"):
|
337 |
+
return "claude_3.5_sonnet"
|
338 |
+
case strings.HasPrefix(model, "claude-3-5-haiku"):
|
339 |
+
return "claude_3.5_haiku"
|
340 |
+
case strings.HasPrefix(model, "gemini-2.0-pro"):
|
341 |
+
return "gemini_2_0_pro"
|
342 |
+
case strings.HasPrefix(model, "gemini-2.0-flash"):
|
343 |
+
return "gemini_2_0"
|
344 |
+
case strings.HasPrefix(model, "gemini-1"):
|
345 |
+
return "gemini_1_5"
|
346 |
+
case strings.HasPrefix(model, "o1-preview"):
|
347 |
+
return "openai_o_1"
|
348 |
+
case strings.HasPrefix(model, "o3-mini"):
|
349 |
+
return "openai_o_3_mini"
|
350 |
+
case model == "deepseek-reasoner":
|
351 |
+
return "deepseek_reasoner"
|
352 |
+
case model == "deepseek-chat":
|
353 |
+
return "deepseek_chat"
|
354 |
+
case model == "deepclaude":
|
355 |
+
return "deepclaude"
|
356 |
+
case model == "sonar":
|
357 |
+
return "sonar"
|
358 |
+
default:
|
359 |
+
return model
|
360 |
+
}
|
361 |
+
}
|
internal/types/openai.go
ADDED
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
package types
|
2 |
+
|
3 |
+
import "github.com/sashabaranov/go-openai"
|
4 |
+
|
5 |
+
type ChatCompletionStreamResponse struct {
|
6 |
+
ID string `json:"id"`
|
7 |
+
Object string `json:"object"`
|
8 |
+
Created int64 `json:"created"`
|
9 |
+
Model string `json:"model"`
|
10 |
+
Choices []ChatCompletionStreamChoice `json:"choices"`
|
11 |
+
SystemFingerprint string `json:"system_fingerprint"`
|
12 |
+
PromptAnnotations []openai.PromptAnnotation `json:"prompt_annotations,omitempty"`
|
13 |
+
PromptFilterResults []openai.PromptFilterResult `json:"prompt_filter_results,omitempty"`
|
14 |
+
Usage *openai.Usage `json:"usage,omitempty"`
|
15 |
+
}
|
16 |
+
|
17 |
+
type ChatCompletionStreamChoice struct {
|
18 |
+
Index int `json:"index"`
|
19 |
+
Delta openai.ChatCompletionStreamChoiceDelta `json:"delta"`
|
20 |
+
Logprobs *openai.ChatCompletionStreamChoiceLogprobs `json:"logprobs,omitempty"`
|
21 |
+
FinishReason openai.FinishReason `json:"finish_reason"`
|
22 |
+
}
|
internal/utils/base64.go
ADDED
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
package utils
|
2 |
+
|
3 |
+
import (
|
4 |
+
"encoding/base64"
|
5 |
+
)
|
6 |
+
|
7 |
+
// Base64Decode 解码base64字符串为字节数组
|
8 |
+
func Base64Decode(data string) ([]byte, error) {
|
9 |
+
return base64.StdEncoding.DecodeString(data)
|
10 |
+
}
|
internal/utils/req_client.go
ADDED
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
package utils
|
2 |
+
|
3 |
+
import (
|
4 |
+
"crypto/tls"
|
5 |
+
"fmt"
|
6 |
+
"github.com/go-resty/resty/v2"
|
7 |
+
"time"
|
8 |
+
)
|
9 |
+
|
10 |
+
var (
|
11 |
+
RestySSEClient = resty.New().
|
12 |
+
SetTimeout(3 * time.Minute).
|
13 |
+
SetTLSClientConfig(&tls.Config{InsecureSkipVerify: true}).
|
14 |
+
SetDoNotParseResponse(true). // 告诉 Resty,不要自动读取/解析 Body,让我们自己来处理流
|
15 |
+
SetHeaders(map[string]string{
|
16 |
+
"Content-Type": "application/json",
|
17 |
+
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
18 |
+
"x-client-locale": "zh_CN", // 可以不传,但默认会返回英文回答
|
19 |
+
}).
|
20 |
+
OnAfterResponse(func(c *resty.Client, resp *resty.Response) error {
|
21 |
+
// 如果不是 200,尝试把 body 打印出来
|
22 |
+
if resp.StatusCode() != 200 {
|
23 |
+
return fmt.Errorf("monica API error: status %d, body: %s",
|
24 |
+
resp.StatusCode(), resp.String())
|
25 |
+
}
|
26 |
+
return nil
|
27 |
+
})
|
28 |
+
|
29 |
+
RestyDefaultClient = resty.New().
|
30 |
+
SetTimeout(time.Second * 30).
|
31 |
+
SetTLSClientConfig(&tls.Config{InsecureSkipVerify: true}).
|
32 |
+
SetHeaders(map[string]string{
|
33 |
+
"Content-Type": "application/json",
|
34 |
+
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
35 |
+
}).
|
36 |
+
OnAfterResponse(func(c *resty.Client, resp *resty.Response) error {
|
37 |
+
// 如果不是 200,尝试把 body 打印出来
|
38 |
+
if resp.StatusCode() != 200 {
|
39 |
+
return fmt.Errorf("monica API error: status %d, body: %s",
|
40 |
+
resp.StatusCode(), resp.String())
|
41 |
+
}
|
42 |
+
return nil
|
43 |
+
})
|
44 |
+
)
|
internal/utils/string.go
ADDED
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
package utils
|
2 |
+
|
3 |
+
import (
|
4 |
+
"math/rand"
|
5 |
+
"time"
|
6 |
+
)
|
7 |
+
|
8 |
+
var randSource = rand.New(rand.NewSource(time.Now().UnixNano()))
|
9 |
+
|
10 |
+
// RandStringUsingMathRand 生成指定长度的随机字符串
|
11 |
+
func RandStringUsingMathRand(n int) string {
|
12 |
+
var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
|
13 |
+
|
14 |
+
// 创建一个长度为 n 的切片,用来存放随机字符
|
15 |
+
result := make([]rune, n)
|
16 |
+
for i := 0; i < n; i++ {
|
17 |
+
result[i] = letters[randSource.Intn(len(letters))]
|
18 |
+
}
|
19 |
+
|
20 |
+
return string(result)
|
21 |
+
}
|
main.go
ADDED
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
package main
|
2 |
+
|
3 |
+
import (
|
4 |
+
"errors"
|
5 |
+
"log"
|
6 |
+
"monica-proxy/internal/apiserver"
|
7 |
+
"monica-proxy/internal/config"
|
8 |
+
"net/http"
|
9 |
+
|
10 |
+
"github.com/labstack/echo/v4/middleware"
|
11 |
+
|
12 |
+
"github.com/labstack/echo/v4"
|
13 |
+
)
|
14 |
+
|
15 |
+
func main() {
|
16 |
+
// 加载配置
|
17 |
+
cfg := config.LoadConfig()
|
18 |
+
if cfg.MonicaCookie == "" {
|
19 |
+
log.Fatal("MONICA_COOKIE environment variable is required")
|
20 |
+
}
|
21 |
+
|
22 |
+
e := echo.New()
|
23 |
+
e.Use(middleware.Logger())
|
24 |
+
e.Use(middleware.Recover())
|
25 |
+
// 注册路由
|
26 |
+
apiserver.RegisterRoutes(e)
|
27 |
+
// 启动服务
|
28 |
+
if err := e.Start("0.0.0.0:8080"); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
29 |
+
log.Fatalf("start server error: %v", err)
|
30 |
+
}
|
31 |
+
}
|
nginx.conf
ADDED
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
events {
|
2 |
+
worker_connections 1024;
|
3 |
+
}
|
4 |
+
|
5 |
+
http {
|
6 |
+
upstream monica-proxy {
|
7 |
+
server monica-proxy:8080;
|
8 |
+
}
|
9 |
+
|
10 |
+
server {
|
11 |
+
listen 80;
|
12 |
+
|
13 |
+
location / {
|
14 |
+
# 必须使用 HTTP/1.1,才能支持 chunked 传输
|
15 |
+
proxy_http_version 1.1;
|
16 |
+
# 去掉 Connection: close,避免长连接被关闭
|
17 |
+
proxy_set_header Connection '';
|
18 |
+
|
19 |
+
# 指定后端地址
|
20 |
+
proxy_pass http://monica-proxy;
|
21 |
+
|
22 |
+
# 关闭 Nginx 的各种缓存与缓冲
|
23 |
+
proxy_buffering off;
|
24 |
+
proxy_cache off;
|
25 |
+
# 这一行可以确保 Nginx 不再做加速层的缓冲
|
26 |
+
proxy_set_header X-Accel-Buffering off;
|
27 |
+
|
28 |
+
# 打开分块传输
|
29 |
+
chunked_transfer_encoding on;
|
30 |
+
|
31 |
+
proxy_read_timeout 3600s;
|
32 |
+
proxy_send_timeout 3600s;
|
33 |
+
}
|
34 |
+
|
35 |
+
gzip on;
|
36 |
+
# 不包含 text/event-stream
|
37 |
+
gzip_types text/plain application/json;
|
38 |
+
}
|
39 |
+
}
|