From 239c9493f8d56e418c134f42f8c06a89d3e5f7f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E6=96=87=E5=B3=B0?= Date: Tue, 9 Jun 2026 12:04:12 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A4=9A=E6=A8=A1=E6=80=81=E8=81=8A=E5=A4=A9?= =?UTF-8?q?=E7=AA=97=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 23 + go.mod | 45 ++ go.sum | 184 ++++++++ main.go | 826 +++++++++++++++++++++++++++++++++++ templates/chat.html | 1009 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 2087 insertions(+) create mode 100644 .gitignore create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 templates/chat.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..132deba --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +# 编译产物 +aichat +aichat.exe + +# Unix socket +*.sock + +# IDE +.idea/ +.vscode/ + +# 配置文件(含 API Key) +config.yaml + +# 本地配置覆盖 +config.local.yaml + +conversations + +tmp + +*.exe +*.exe~ \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..bcaef50 --- /dev/null +++ b/go.mod @@ -0,0 +1,45 @@ +module aichat + +go 1.25.0 + +require ( + github.com/gin-gonic/gin v1.12.0 + github.com/volcengine/volcengine-go-sdk v1.2.28 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.1 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/google/uuid v1.3.0 // indirect + github.com/jmespath/go-jmespath v0.4.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + github.com/volcengine/volc-sdk-golang v1.0.23 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect + gopkg.in/yaml.v2 v2.2.8 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..0905cb7 --- /dev/null +++ b/go.sum @@ -0,0 +1,184 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/volcengine/volc-sdk-golang v1.0.23 h1:anOslb2Qp6ywnsbyq9jqR0ljuO63kg9PY+4OehIk5R8= +github.com/volcengine/volc-sdk-golang v1.0.23/go.mod h1:AfG/PZRUkHJ9inETvbjNifTDgut25Wbkm2QoYBTbvyU= +github.com/volcengine/volcengine-go-sdk v1.2.28 h1:UEudE9oIhsESxY7aMhMIFVTiwjKgi6oY7PthPre5puc= +github.com/volcengine/volcengine-go-sdk v1.2.28/go.mod h1:oxoVo+A17kvkwPkIeIHPVLjSw7EQAm+l/Vau1YGHN+A= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/main.go b/main.go new file mode 100644 index 0000000..cbdfcbd --- /dev/null +++ b/main.go @@ -0,0 +1,826 @@ +package main + +import ( + "context" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" + + "github.com/gin-gonic/gin" + ark "github.com/volcengine/volcengine-go-sdk/service/arkruntime" + "github.com/volcengine/volcengine-go-sdk/service/arkruntime/model" + "gopkg.in/yaml.v3" +) + +// ─── 配置 ───────────────────────────────────────────────── + +type Config struct { + Server struct { + Mode string `yaml:"mode"` + Address string `yaml:"address"` + } `yaml:"server"` + OpenAI struct { + APIKey string `yaml:"api_key"` + BaseURL string `yaml:"base_url"` + Model string `yaml:"model"` + Timeout int `yaml:"timeout"` + } `yaml:"openai"` + Search struct { + Enabled bool `yaml:"enabled"` + Provider string `yaml:"provider"` + APIKey string `yaml:"api_key"` + BaseURL string `yaml:"base_url"` + Count int `yaml:"count"` + Timeout int `yaml:"timeout"` + } `yaml:"search"` +} + +func defaultConfig() Config { + var cfg Config + cfg.Server.Mode = "tcp" + cfg.Server.Address = "0.0.0.0:8080" + cfg.OpenAI.BaseURL = "https://ark.cn-beijing.volces.com/api/v3" + cfg.OpenAI.Timeout = 120 + cfg.Search.Provider = "brave" + cfg.Search.BaseURL = "https://api.search.brave.com/res/v1/web/search" + cfg.Search.Count = 5 + cfg.Search.Timeout = 10 + return cfg +} + +func loadConfig(path string) (*Config, error) { + if err := ensureConfigFile(path); err != nil { + return nil, err + } + + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("读取配置文件失败: %w", err) + } + var cfg Config + if err = yaml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("解析配置文件失败: %w", err) + } + // 环境变量优先 + if key := os.Getenv("ARK_API_KEY"); key != "" { + cfg.OpenAI.APIKey = key + } + if key := os.Getenv("BRAVE_SEARCH_API_KEY"); key != "" { + cfg.Search.APIKey = key + } + return &cfg, nil +} + +func ensureConfigFile(path string) error { + defaults := defaultConfig() + if _, err := os.Stat(path); err != nil { + if !os.IsNotExist(err) { + return fmt.Errorf("检查配置文件失败: %w", err) + } + return writeConfig(path, defaults) + } + + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("读取配置文件失败: %w", err) + } + var cfg Config + if err = yaml.Unmarshal(data, &cfg); err != nil { + return fmt.Errorf("解析配置文件失败: %w", err) + } + var raw map[string]any + if err = yaml.Unmarshal(data, &raw); err != nil { + return fmt.Errorf("解析配置文件失败: %w", err) + } + + changed := false + server, _ := raw["server"].(map[string]any) + if server == nil { + cfg.Server = defaults.Server + changed = true + } else { + if _, ok := server["mode"]; !ok { + cfg.Server.Mode = defaults.Server.Mode + changed = true + } + if _, ok := server["address"]; !ok { + cfg.Server.Address = defaults.Server.Address + changed = true + } + } + + openai, _ := raw["openai"].(map[string]any) + if openai == nil { + cfg.OpenAI = defaults.OpenAI + changed = true + } else { + if _, ok := openai["api_key"]; !ok { + cfg.OpenAI.APIKey = defaults.OpenAI.APIKey + changed = true + } + if _, ok := openai["base_url"]; !ok { + cfg.OpenAI.BaseURL = defaults.OpenAI.BaseURL + changed = true + } + if _, ok := openai["model"]; !ok { + cfg.OpenAI.Model = defaults.OpenAI.Model + changed = true + } + if _, ok := openai["timeout"]; !ok { + cfg.OpenAI.Timeout = defaults.OpenAI.Timeout + changed = true + } + } + + search, _ := raw["search"].(map[string]any) + if search == nil { + cfg.Search = defaults.Search + changed = true + } else { + if _, ok := search["enabled"]; !ok { + cfg.Search.Enabled = defaults.Search.Enabled + changed = true + } + if _, ok := search["provider"]; !ok { + cfg.Search.Provider = defaults.Search.Provider + changed = true + } + if _, ok := search["api_key"]; !ok { + cfg.Search.APIKey = defaults.Search.APIKey + changed = true + } + if _, ok := search["base_url"]; !ok { + cfg.Search.BaseURL = defaults.Search.BaseURL + changed = true + } + if _, ok := search["count"]; !ok { + cfg.Search.Count = defaults.Search.Count + changed = true + } + if _, ok := search["timeout"]; !ok { + cfg.Search.Timeout = defaults.Search.Timeout + changed = true + } + } + + if !changed { + return nil + } + return writeConfig(path, cfg) +} + +func writeConfig(path string, cfg Config) error { + data, err := yaml.Marshal(&cfg) + if err != nil { + return fmt.Errorf("生成配置文件失败: %w", err) + } + if err := os.WriteFile(path, data, 0644); err != nil { + return fmt.Errorf("写入配置文件失败: %w", err) + } + return nil +} + +// ─── 请求结构 ───────────────────────────────────────────── + +type ChatMessage struct { + Role string `json:"role"` + Content string `json:"content"` + ImageURL string `json:"image_url,omitempty"` // base64 data URI 或 http URL + ImageURLAlias string `json:"imageURL,omitempty"` + Hidden bool `json:"hidden,omitempty"` +} + +type ChatRequest struct { + ConversationID string `json:"conversation_id,omitempty"` + Messages []ChatMessage `json:"messages"` + WebSearch bool `json:"web_search,omitempty"` +} + +type Conversation struct { + ID string `json:"id"` + Title string `json:"title"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Messages []ChatMessage `json:"messages,omitempty"` +} + +type ConvStore struct { + dir string + mu sync.Mutex +} + +// ─── 全局变量 ───────────────────────────────────────────── + +var ( + cfg *Config + aiClient *ark.Client + store *ConvStore +) + +// ─── 路由 ───────────────────────────────────────────────── + +func indexHandler(c *gin.Context) { + c.HTML(http.StatusOK, "chat.html", gin.H{ + "Title": "AI 对话", + "Model": cfg.OpenAI.Model, + }) +} + +func listConversationsHandler(c *gin.Context) { + convs, err := store.List() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, convs) +} + +func createConversationHandler(c *gin.Context) { + conv, err := store.Create() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "创建对话失败: " + err.Error()}) + return + } + c.JSON(http.StatusOK, conv) +} + +func getConversationHandler(c *gin.Context) { + conv, err := store.Get(c.Param("id")) + if err != nil { + status := http.StatusInternalServerError + if err.Error() == "对话不存在" { + status = http.StatusNotFound + } + c.JSON(status, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, conv) +} + +func deleteConversationHandler(c *gin.Context) { + if err := store.Delete(c.Param("id")); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.Status(http.StatusNoContent) +} + +// chatHandler 流式 SSE 对话接口 +func chatHandler(c *gin.Context) { + var req ChatRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请求格式错误: " + err.Error()}) + return + } + if len(req.Messages) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "消息不能为空"}) + return + } + + chatMessages := req.Messages + if req.WebSearch { + withSearch, err := enrichMessagesWithSearch(c.Request.Context(), req.Messages) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + chatMessages = withSearch + } + + // 构建 ark 消息列表 + messages, err := buildArkMessages(chatMessages) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // SSE 头 + c.Writer.Header().Set("Content-Type", "text/event-stream") + c.Writer.Header().Set("Cache-Control", "no-cache") + c.Writer.Header().Set("Connection", "keep-alive") + c.Writer.Header().Set("X-Accel-Buffering", "no") + c.Writer.WriteHeader(http.StatusOK) + flusher, ok := c.Writer.(http.Flusher) + if !ok { + c.JSON(http.StatusInternalServerError, gin.H{"error": "服务器不支持流式响应"}) + return + } + + // 超时 context + timeout := time.Duration(cfg.OpenAI.Timeout) * time.Second + ctx, cancel := context.WithTimeout(c.Request.Context(), timeout) + defer cancel() + + // 发起流式请求(使用 CreateChatCompletionStream) + stream, err := aiClient.CreateChatCompletionStream(ctx, model.CreateChatCompletionRequest{ + Model: cfg.OpenAI.Model, + Messages: messages, + MaxTokens: intPtr(4096), + }.WithStream(true)) + if err != nil { + fmt.Fprintf(c.Writer, "data: {\"error\":%s}\n\n", toJSON(err.Error())) + flusher.Flush() + return + } + defer stream.Close() + + var full strings.Builder + for { + resp, err := stream.Recv() + if errors.Is(err, io.EOF) { + if req.ConversationID != "" { + if err := saveConversationMessages(req.ConversationID, req.Messages, full.String()); err != nil { + fmt.Fprintln(os.Stderr, "保存对话失败:", err) + } + } + fmt.Fprintf(c.Writer, "data: [DONE]\n\n") + flusher.Flush() + return + } + if err != nil { + fmt.Fprintf(c.Writer, "data: {\"error\":%s}\n\n", toJSON(err.Error())) + flusher.Flush() + return + } + if len(resp.Choices) > 0 { + delta := resp.Choices[0].Delta.Content + if delta != "" { + full.WriteString(delta) + fmt.Fprintf(c.Writer, "data: %s\n\n", toSSE(delta)) + flusher.Flush() + } + } + } +} + +// ─── 辅助函数 ───────────────────────────────────────────── + +type searchResult struct { + Title string `json:"title"` + URL string `json:"url"` + Description string `json:"description"` +} + +type braveSearchResponse struct { + Web struct { + Results []searchResult `json:"results"` + } `json:"web"` +} + +func enrichMessagesWithSearch(ctx context.Context, messages []ChatMessage) ([]ChatMessage, error) { + if !cfg.Search.Enabled { + return nil, errors.New("联网搜索未启用,请先在 config.yaml 中配置 search.enabled") + } + if strings.ToLower(cfg.Search.Provider) != "brave" { + return nil, fmt.Errorf("暂不支持搜索服务: %s", cfg.Search.Provider) + } + if cfg.Search.APIKey == "" { + return nil, errors.New("联网搜索未配置 API Key,请设置 search.api_key 或环境变量 BRAVE_SEARCH_API_KEY") + } + + query := latestUserQuery(messages) + if query == "" { + return nil, errors.New("联网搜索需要输入文本问题") + } + + results, err := braveWebSearch(ctx, query) + if err != nil { + return nil, err + } + if len(results) == 0 { + return nil, errors.New("未搜索到相关网页结果") + } + + searchContext := buildSearchContext(query, results) + withSearch := make([]ChatMessage, 0, len(messages)+1) + withSearch = append(withSearch, ChatMessage{Role: "system", Content: searchContext, Hidden: true}) + withSearch = append(withSearch, messages...) + return withSearch, nil +} + +func latestUserQuery(messages []ChatMessage) string { + for i := len(messages) - 1; i >= 0; i-- { + if messages[i].Role == "user" { + return strings.TrimSpace(messages[i].Content) + } + } + return "" +} + +func braveWebSearch(ctx context.Context, query string) ([]searchResult, error) { + searchCtx, cancel := context.WithTimeout(ctx, time.Duration(cfg.Search.Timeout)*time.Second) + defer cancel() + + u, err := url.Parse(cfg.Search.BaseURL) + if err != nil { + return nil, fmt.Errorf("搜索服务地址无效: %w", err) + } + q := u.Query() + q.Set("q", query) + q.Set("count", fmt.Sprintf("%d", cfg.Search.Count)) + q.Set("search_lang", "zh-hans") + q.Set("country", "CN") + u.RawQuery = q.Encode() + + req, err := http.NewRequestWithContext(searchCtx, http.MethodGet, u.String(), nil) + if err != nil { + return nil, fmt.Errorf("创建搜索请求失败: %w", err) + } + req.Header.Set("Accept", "application/json") + req.Header.Set("X-Subscription-Token", cfg.Search.APIKey) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("联网搜索失败: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024)) + if err != nil { + return nil, fmt.Errorf("读取搜索响应失败: %w", err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("搜索服务返回错误 %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + + var parsed braveSearchResponse + if err := json.Unmarshal(body, &parsed); err != nil { + return nil, fmt.Errorf("解析搜索响应失败: %w", err) + } + return parsed.Web.Results, nil +} + +func buildSearchContext(query string, results []searchResult) string { + var b strings.Builder + fmt.Fprintf(&b, "用户开启了联网搜索。请优先根据以下网页搜索结果回答,并在合适位置标注来源链接。\n") + fmt.Fprintf(&b, "搜索时间: %s\n", time.Now().Format("2006-01-02 15:04:05")) + fmt.Fprintf(&b, "搜索词: %s\n\n", query) + fmt.Fprintln(&b, "搜索结果:") + for i, r := range results { + fmt.Fprintf(&b, "%d. 标题: %s\n", i+1, strings.TrimSpace(r.Title)) + fmt.Fprintf(&b, " 链接: %s\n", strings.TrimSpace(r.URL)) + if strings.TrimSpace(r.Description) != "" { + fmt.Fprintf(&b, " 摘要: %s\n", strings.TrimSpace(r.Description)) + } + } + fmt.Fprintln(&b, "\n如果搜索结果不足以回答,请明确说明不确定,不要编造。") + return b.String() +} + +func newUUID() string { + b := make([]byte, 16) + _, _ = rand.Read(b) + b[6] = (b[6] & 0x0f) | 0x40 + b[8] = (b[8] & 0x3f) | 0x80 + return hex.EncodeToString(b[:4]) + "-" + hex.EncodeToString(b[4:6]) + "-" + + hex.EncodeToString(b[6:8]) + "-" + hex.EncodeToString(b[8:10]) + "-" + + hex.EncodeToString(b[10:]) +} + +// ─── ConvStore ───────────────────────────────────────────── + +func NewConvStore(dir string) *ConvStore { + os.MkdirAll(dir, 0755) + return &ConvStore{dir: dir} +} + +func (s *ConvStore) path(id string) string { + return filepath.Join(s.dir, id+".json") +} + +func (s *ConvStore) Create() (*Conversation, error) { + conv := &Conversation{ + ID: newUUID(), + Title: "新对话", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + if err := s.Save(conv); err != nil { + return nil, err + } + return conv, nil +} + +func (s *ConvStore) Save(conv *Conversation) error { + s.mu.Lock() + defer s.mu.Unlock() + conv.UpdatedAt = time.Now() + return atomicWriteJSON(s.path(conv.ID), conv) +} + +func (s *ConvStore) Get(id string) (*Conversation, error) { + s.mu.Lock() + defer s.mu.Unlock() + data, err := os.ReadFile(s.path(id)) + if err != nil { + if os.IsNotExist(err) { + return nil, errors.New("对话不存在") + } + return nil, fmt.Errorf("读取对话失败: %w", err) + } + var conv Conversation + if err := json.Unmarshal(data, &conv); err != nil { + return nil, fmt.Errorf("解析对话失败: %w", err) + } + return &conv, nil +} + +func (s *ConvStore) List() ([]Conversation, error) { + s.mu.Lock() + defer s.mu.Unlock() + + entries, err := os.ReadDir(s.dir) + if err != nil { + return nil, fmt.Errorf("读取对话目录失败: %w", err) + } + + var list []Conversation + for _, e := range entries { + if e.IsDir() || filepath.Ext(e.Name()) != ".json" { + continue + } + data, err := os.ReadFile(filepath.Join(s.dir, e.Name())) + if err != nil { + continue + } + var conv Conversation + if err := json.Unmarshal(data, &conv); err != nil { + continue + } + conv.Messages = nil // 列表不返回消息体 + list = append(list, conv) + } + + sort.Slice(list, func(i, j int) bool { + return list[i].UpdatedAt.After(list[j].UpdatedAt) + }) + return list, nil +} + +func (s *ConvStore) Delete(id string) error { + s.mu.Lock() + defer s.mu.Unlock() + if err := os.Remove(s.path(id)); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("删除对话失败: %w", err) + } + return nil +} + +func atomicWriteJSON(path string, v any) error { + tmp := path + ".tmp" + data, err := json.Marshal(v) + if err != nil { + return err + } + if err := os.WriteFile(tmp, data, 0644); err != nil { + return err + } + return os.Rename(tmp, path) +} + +func saveConversationMessages(id string, messages []ChatMessage, assistantContent string) error { + conv, err := store.Get(id) + if err != nil { + return err + } + conv.Messages = append([]ChatMessage(nil), messages...) + conv.Messages = append(conv.Messages, ChatMessage{Role: "assistant", Content: assistantContent}) + if conv.Title == "" || conv.Title == "新对话" { + conv.Title = genConvTitle(conv.Messages) + } + return store.Save(conv) +} + +func genConvTitle(messages []ChatMessage) string { + for _, m := range messages { + if m.Hidden { + continue + } + if m.Role == "user" && strings.TrimSpace(m.Content) != "" { + title := strings.TrimSpace(m.Content) + title = strings.ReplaceAll(title, "\r\n", " ") + title = strings.ReplaceAll(title, "\n", " ") + runes := []rune(title) + if len(runes) > 30 { + return string(runes[:30]) + "..." + } + return title + } + } + return "新对话" +} + +const maxImageSize = 4 * 1024 * 1024 + +var allowedImageTypes = map[string]bool{ + "image/jpeg": true, + "image/png": true, + "image/webp": true, + "image/gif": true, +} + +func buildArkMessages(chatMessages []ChatMessage) ([]*model.ChatCompletionMessage, error) { + messages := make([]*model.ChatCompletionMessage, 0, len(chatMessages)) + for _, m := range chatMessages { + msg, err := buildArkMessage(m) + if err != nil { + return nil, err + } + messages = append(messages, msg) + } + return messages, nil +} + +func buildArkMessage(m ChatMessage) (*model.ChatCompletionMessage, error) { + msg := &model.ChatCompletionMessage{Role: m.Role} + + if m.ImageURL == "" && m.ImageURLAlias != "" { + m.ImageURL = m.ImageURLAlias + } + + if m.ImageURL == "" { + msg.Content = &model.ChatCompletionMessageContent{ + StringValue: &m.Content, + } + return msg, nil + } + + imageURL, err := normalizeImageURL(m.ImageURL) + if err != nil { + return nil, err + } + + // 有图片时:文字内容可有可无(图片 caption 场景),均构造多模态消息 + // 若无文字,则只传图片 part;若同时有图片和文字,先图后文 + parts := []*model.ChatCompletionMessageContentPart{imagePart(imageURL)} + if m.Content != "" { + parts = append(parts, textPart(m.Content)) + } + msg.Content = &model.ChatCompletionMessageContent{ListValue: parts} + return msg, nil +} + +func imagePart(url string) *model.ChatCompletionMessageContentPart { + return &model.ChatCompletionMessageContentPart{ + Type: model.ChatCompletionMessageContentPartTypeImageURL, + ImageURL: &model.ChatMessageImageURL{ + URL: url, + Detail: model.ImageURLDetailAuto, + }, + } +} + +func textPart(text string) *model.ChatCompletionMessageContentPart { + return &model.ChatCompletionMessageContentPart{ + Type: model.ChatCompletionMessageContentPartTypeText, + Text: text, + } +} + +func normalizeImageURL(raw string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", errors.New("图片地址不能为空") + } + + lower := strings.ToLower(raw) + if strings.HasPrefix(lower, "data:") { + return normalizeImageDataURI(raw) + } + + u, err := url.Parse(raw) + if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") { + return "", errors.New("图片地址无效,仅支持 http/https URL 或 base64 data URI") + } + return raw, nil +} + +func normalizeImageDataURI(raw string) (string, error) { + comma := strings.Index(raw, ",") + if comma < 0 { + return "", errors.New("图片 base64 数据格式错误") + } + + meta := strings.ToLower(strings.TrimSpace(raw[5:comma])) + payload := strings.TrimSpace(raw[comma+1:]) + if payload == "" { + return "", errors.New("图片 base64 数据不能为空") + } + parts := strings.Split(meta, ";") + if len(parts) < 2 || !contains(parts[1:], "base64") { + return "", errors.New("图片 data URI 必须使用 base64 编码") + } + + mime := parts[0] + if !allowedImageTypes[mime] { + return "", errors.New("图片格式不支持,仅支持 jpeg/png/webp/gif") + } + + decoded, err := base64.StdEncoding.DecodeString(payload) + if err != nil { + return "", errors.New("图片 base64 数据无效") + } + if len(decoded) > maxImageSize { + return "", errors.New("图片过大,请选择小于 4MB 的图片") + } + + return "data:" + mime + ";base64," + payload, nil +} + +func contains(items []string, target string) bool { + for _, item := range items { + if strings.TrimSpace(item) == target { + return true + } + } + return false +} + +func intPtr(i int) *int { return &i } + +func toJSON(s string) string { + b, _ := json.Marshal(s) + return string(b) +} + +func toSSE(s string) string { + s = strings.ReplaceAll(s, `\`, `\\`) + s = strings.ReplaceAll(s, "\n", `\n`) + s = strings.ReplaceAll(s, "\r", "") + s = strings.ReplaceAll(s, `"`, `\"`) + return fmt.Sprintf(`"%s"`, s) +} + +// ─── 入口 ───────────────────────────────────────────────── + +func main() { + var err error + cfg, err = loadConfig("config.yaml") + if err != nil { + fmt.Fprintln(os.Stderr, "配置加载失败:", err) + os.Exit(1) + } + + if cfg.OpenAI.APIKey == "" { + fmt.Fprintln(os.Stderr, "错误: openai.api_key 未配置,也未设置环境变量 ARK_API_KEY") + os.Exit(1) + } + + // 初始化火山方舟 SDK 客户端 + aiClient = ark.NewClientWithApiKey( + cfg.OpenAI.APIKey, + ark.WithBaseUrl(cfg.OpenAI.BaseURL), + ark.WithTimeout(time.Duration(cfg.OpenAI.Timeout)*time.Second), + ) + store = NewConvStore("conversations") + + // Gin 路由 + r := gin.Default() + r.LoadHTMLGlob("templates/*") + r.Static("/static", "./static") + + r.GET("/", indexHandler) + r.POST("/api/chat", chatHandler) + r.GET("/api/conversations", listConversationsHandler) + r.POST("/api/conversations", createConversationHandler) + r.GET("/api/conversations/:id", getConversationHandler) + r.DELETE("/api/conversations/:id", deleteConversationHandler) + + // 根据配置选择监听方式 + switch strings.ToLower(cfg.Server.Mode) { + case "unix": + socketPath := cfg.Server.Address + if _, statErr := os.Stat(socketPath); statErr == nil { + os.Remove(socketPath) + } + ln, listenErr := net.Listen("unix", socketPath) + if listenErr != nil { + fmt.Fprintln(os.Stderr, "监听 Unix socket 失败:", listenErr) + os.Exit(1) + } + fmt.Println("服务已启动,监听 Unix socket:", socketPath) + if serveErr := http.Serve(ln, r); serveErr != nil { + fmt.Fprintln(os.Stderr, "服务异常退出:", serveErr) + os.Exit(1) + } + default: + fmt.Println("服务已启动,监听 TCP:", cfg.Server.Address) + if runErr := r.Run(cfg.Server.Address); runErr != nil { + fmt.Fprintln(os.Stderr, "服务异常退出:", runErr) + os.Exit(1) + } + } +} diff --git a/templates/chat.html b/templates/chat.html new file mode 100644 index 0000000..db76a64 --- /dev/null +++ b/templates/chat.html @@ -0,0 +1,1009 @@ + + + + + + {{ .Title }} + + + + + + +
+
+
+ + + + + {{ .Title }} +
+
+ {{ .Model }} + + + +
+
+ +
+
+ + + + + + +

发送消息开始对话,支持上传图片进行多模态对话

+
+
+ +
+
+ 预览 + + +
+
+ + + + +
+

Enter 发送  ·  Shift+Enter 换行  ·  支持图片多模态

+
+
+ +
+
+

预先提示词

+

保存后,每个新对话会先发送该提示词并生成开场白,开场白完成后才能开始聊天。留空表示关闭。

+ +
+ + + +
+
+
+ + + +