first commit

This commit is contained in:
Vaala Cat 2024-02-02 23:21:43 +08:00
commit 0b86be3426
52 changed files with 4390 additions and 0 deletions

41
.gitignore vendored Normal file
View File

@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
*.txt
.env
data
ai-search

7
Dockerfile Normal file
View File

@ -0,0 +1,7 @@
FROM debian
WORKDIR /app
COPY ai-search .
CMD [ "./ai-search" ]

36
README.md Normal file
View File

@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.

4
build.sh Normal file
View File

@ -0,0 +1,4 @@
#!/bin/bash
pnpm build
GOOS=linux GOARCH=amd64 go build -o ai-search *.go
docker buildx build -f ./Dockerfile -t git.vaala.cloud/vaalacat/ai-search --platform=linux/amd64 . --push

28
docker-compose.yaml Normal file
View File

@ -0,0 +1,28 @@
version: "3"
services:
search:
image: git.vaala.cloud/vaalacat/ddgs:latest
environment:
HTTP_PROXY: http://xxxxxxxx
HTTPS_PROXY: http://xxxxxxxx
restart: always
ai:
image: git.vaala.cloud/vaalacat/ai-search
ports:
- 8080:8080
restart: always
volumes:
- ./data:/data
environment:
RPC_SEARCH_URL: http://search:8000/search
# 下面两个都填一样的
OPENAI_ENDPOINT: https://xxxxxxxx/v1
OPENAI_API_KEY: sk-ssssssssss
OPENAI_CHAT_ENDPOINT: https://xxxxxxxx/v1
OPENAI_CHAT_API_KEY: sk-ssssssssss
RAG_MAX_TOKENS: 4096
RAG_MORE_QUESTIONS_MAX_TOKENS: 4096
RAG_TEMPERATURE: 0.9
RAG_MORE_QUESTIONS_TEMPERATURE: 0.7
PROMPT_RAG_PATH: /data/rag.txt
PROMPT_MORE_QUESTIONS_PATH: /data/more.txt

61
go.mod Normal file
View File

@ -0,0 +1,61 @@
module ai-search
go 1.21.4
require (
github.com/coocood/freecache v1.2.4
github.com/gin-contrib/static v0.0.1
github.com/gin-gonic/gin v1.9.1
github.com/ilyakaznacheev/cleanenv v1.5.0
github.com/imroc/req/v3 v3.42.3
github.com/joho/godotenv v1.5.1
github.com/sashabaranov/go-openai v1.19.2
github.com/sirupsen/logrus v1.9.3
)
require (
github.com/BurntSushi/toml v1.2.1 // indirect
github.com/andybalholm/brotli v1.0.6 // indirect
github.com/bytedance/sonic v1.9.1 // indirect
github.com/cespare/xxhash/v2 v2.1.2 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/cloudflare/circl v1.3.7 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.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.14.0 // indirect
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/google/pprof v0.0.0-20231229205709-960ae82b1e42 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.17.4 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/onsi/ginkgo/v2 v2.13.2 // indirect
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
github.com/quic-go/qpack v0.4.0 // indirect
github.com/quic-go/qtls-go1-20 v0.4.1 // indirect
github.com/quic-go/quic-go v0.40.1 // indirect
github.com/refraction-networking/utls v1.6.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
go.uber.org/mock v0.4.0 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/crypto v0.17.0 // indirect
golang.org/x/exp v0.0.0-20240103183307-be819d1f06fc // indirect
golang.org/x/mod v0.14.0 // indirect
golang.org/x/net v0.19.0 // indirect
golang.org/x/sys v0.16.0 // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/tools v0.16.1 // indirect
google.golang.org/protobuf v1.30.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 // indirect
)

174
go.sum Normal file
View File

@ -0,0 +1,174 @@
github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak=
github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI=
github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU=
github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA=
github.com/coocood/freecache v1.2.4 h1:UdR6Yz/X1HW4fZOuH0Z94KwG851GWOSknua5VUbb/5M=
github.com/coocood/freecache v1.2.4/go.mod h1:RBUWa/Cy+OHdfTGFEhEuE1pMCMX51Ncizj7rthiQ3vk=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
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/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-contrib/static v0.0.1 h1:JVxuvHPuUfkoul12N7dtQw7KRn/pSMq7Ue1Va9Swm1U=
github.com/gin-contrib/static v0.0.1/go.mod h1:CSxeF+wep05e0kCOsqWdAWbSszmc31zTIbD8TvWl7Hs=
github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/go-logr/logr v1.3.0 h1:2y3SDp0ZXuc6/cjLSZ+Q3ir+QB9T/iG5yYRXqsagWSY=
github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
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.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
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.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
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.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI=
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20231229205709-960ae82b1e42 h1:dHLYa5D8/Ta0aLR2XcPsrkpAgGeFs6thhMcQK0oQ0n8=
github.com/google/pprof v0.0.0-20231229205709-960ae82b1e42/go.mod h1:czg5+yv1E0ZGTi6S6vVK1mke0fV+FaUhNGcd6VRS9Ik=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/ilyakaznacheev/cleanenv v1.5.0 h1:0VNZXggJE2OYdXE87bfSSwGxeiGt9moSR2lOrsHHvr4=
github.com/ilyakaznacheev/cleanenv v1.5.0/go.mod h1:a5aDzaJrLCQZsazHol1w8InnDcOX0OColm64SlIi6gk=
github.com/imroc/req/v3 v3.42.3 h1:ryPG2AiwouutAopwPxKpWKyxgvO8fB3hts4JXlh3PaE=
github.com/imroc/req/v3 v3.42.3/go.mod h1:Axz9Y/a2b++w5/Jht3IhQsdBzrG1ftJd1OJhu21bB2Q=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
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/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
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.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/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 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
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/onsi/ginkgo/v2 v2.13.2 h1:Bi2gGVkfn6gQcjNjZJVO8Gf0FHzMPf2phUei9tejVMs=
github.com/onsi/ginkgo/v2 v2.13.2/go.mod h1:XStQ8QcGwLyF4HdfcZB8SFOS/MWCgDuXMSBe6zrvLgM=
github.com/onsi/gomega v1.29.0 h1:KIA/t2t5UBzoirT4H9tsML45GEbo3ouUnBHsCfD2tVg=
github.com/onsi/gomega v1.29.0/go.mod h1:9sxs+SwGrKI0+PWe4Fxa9tFQQBG5xSsSbMXOI8PPpoQ=
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
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/quic-go/qpack v0.4.0 h1:Cr9BXA1sQS2SmDUWjSofMPNKmvF6IiIfDRmgU0w1ZCo=
github.com/quic-go/qpack v0.4.0/go.mod h1:UZVnYIfi5GRk+zI9UMaCPsmZ2xKJP7XBUvVyT1Knj9A=
github.com/quic-go/qtls-go1-20 v0.4.1 h1:D33340mCNDAIKBqXuAvexTNMUByrYmFYVfKfDN5nfFs=
github.com/quic-go/qtls-go1-20 v0.4.1/go.mod h1:X9Nh97ZL80Z+bX/gUXMbipO6OxdiDi58b/fMC9mAL+k=
github.com/quic-go/quic-go v0.40.1 h1:X3AGzUNFs0jVuO3esAGnTfvdgvL4fq655WaOi1snv1Q=
github.com/quic-go/quic-go v0.40.1/go.mod h1:PeN7kuVJ4xZbxSv/4OX6S1USOX8MJvydwpTx31vx60c=
github.com/refraction-networking/utls v1.6.0 h1:X5vQMqVx7dY7ehxxqkFER/W6DSjy8TMqSItXm8hRDYQ=
github.com/refraction-networking/utls v1.6.0/go.mod h1:kHJ6R9DFFA0WsRgBM35iiDku4O7AqPR6y79iuzW7b10=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/sashabaranov/go-openai v1.19.2 h1:+dkuCADSnwXV02YVJkdphY8XD9AyHLUWwk6V7LB6EL8=
github.com/sashabaranov/go-openai v1.19.2/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
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/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
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.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
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 v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU=
go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/exp v0.0.0-20240103183307-be819d1f06fc h1:ao2WRsKSzW6KuUY9IWPwWahcHCgR0s52IfwutMfEbdM=
golang.org/x/exp v0.0.0-20240103183307-be819d1f06fc/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI=
golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0=
golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.16.1 h1:TLyB3WofjdOEepBHAU20JdNC1Zbg87elYofWYAY5oZA=
golang.org/x/tools v0.16.1/go.mod h1:kYVVN6I1mBNoB1OX+noeBjbRk4IUEPa7JJ+TJMEooJ0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
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=
olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 h1:slmdOY3vp8a7KQbHkL+FLbvbkgMqmXojpFUO/jENuqQ=
olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3/go.mod h1:oVgVk4OWVDi43qWBEyGhXgYxt7+ED4iYNpTngSLX2Iw=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

22
main.go Normal file
View File

@ -0,0 +1,22 @@
package main
import (
"ai-search/service"
"embed"
"github.com/gin-gonic/gin"
)
//go:embed all:out/*
var fs embed.FS
var (
router *gin.Engine
)
func main() {
router = gin.Default()
service.HandleStaticFile(router, fs)
router.POST("/v1/chat/search", service.SearchHandler)
router.Run(service.GetSettings().ListenAddr)
}

16
next.config.mjs Normal file
View File

@ -0,0 +1,16 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'export',
async rewrites() {
return {
fallback: [
{
source: '/v1/:path*',
destination: `http://localhost:8080/v1/:path*`,
},
],
}
},
};
export default nextConfig;

31
package.json Normal file
View File

@ -0,0 +1,31 @@
{
"name": "my-app",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@radix-ui/react-popover": "^1.0.7",
"clsx": "^2.0.0",
"lucide-react": "^0.301.0",
"nanoid": "^5.0.5",
"next": "14.1.0",
"react": "^18",
"react-dom": "^18",
"react-markdown": "^8.0.7",
"tailwind-merge": "^2.2.0"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"autoprefixer": "^10.0.1",
"postcss": "^8",
"tailwindcss": "^3.3.0",
"typescript": "^5"
}
}

2117
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

1
public/next.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
public/vercel.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 283 64"><path fill="black" d="M141 16c-11 0-19 7-19 18s9 18 20 18c7 0 13-3 16-7l-7-5c-2 3-6 4-9 4-5 0-9-3-10-7h28v-3c0-11-8-18-19-18zm-9 15c1-4 4-7 9-7s8 3 9 7h-18zm117-15c-11 0-19 7-19 18s9 18 20 18c6 0 12-3 16-7l-8-5c-2 3-5 4-8 4-5 0-9-3-11-7h28l1-3c0-11-8-18-19-18zm-10 15c2-4 5-7 10-7s8 3 9 7h-19zm-39 3c0 6 4 10 10 10 4 0 7-2 9-5l8 5c-3 5-9 8-17 8-11 0-19-7-19-18s8-18 19-18c8 0 14 3 17 8l-8 5c-2-3-5-5-9-5-6 0-10 4-10 10zm83-29v46h-9V5h9zM37 0l37 64H0L37 0zm92 5-27 48L74 5h10l18 30 17-30h10zm59 12v10l-3-1c-6 0-10 4-10 10v15h-9V17h9v9c0-5 6-9 13-9z"/></svg>

After

Width:  |  Height:  |  Size: 629 B

18
service/cache.go Normal file
View File

@ -0,0 +1,18 @@
package service
import (
"github.com/coocood/freecache"
)
var (
searchCache *freecache.Cache
)
func InitCache() {
cacheSize := GetSettings().CacheSize * 1024 * 1024 // 100 MB
searchCache = freecache.NewCache(cacheSize)
}
func GetSearchCache() *freecache.Cache {
return searchCache
}

27
service/const.go Normal file
View File

@ -0,0 +1,27 @@
package service
const (
HeaderKey = "header"
AppEntryKey = "entry"
UIDKey = "uid"
EndpointKey = "endpoint"
UserInfoKey = "userinfo"
TokenKey = "token"
AuthorizationKey = "authorization"
SetAuthorizationKey = "x-authorization-token"
TraceIDKey = "x-vaala-trace-id"
ClientRequestIDKey = "x-client-request-id"
SessionKey = "x-vaala-session"
RegistrationSessionKey = "x-vaala-registration-session"
LoginSessionKey = "x-vaala-login-session"
)
const (
RespSuccess = "success"
ValueNone = "none"
)
const (
LangEN = "en"
LangZH = "zh"
)

129
service/helper.go Normal file
View File

@ -0,0 +1,129 @@
package service
import (
"ai-search/service/logger"
"context"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/sashabaranov/go-openai"
)
func getOpenAIConfig(c context.Context, mode string) (token, endpoint string) {
token = GetSettings().OpenAIAPIKey
endpoint = GetSettings().OpenAIEndpint
if mode == "chat" {
token = GetSettings().OpenAIChatAPIKey
endpoint = GetSettings().OpenAIChatEndpoint
}
return
}
type Queue struct {
data []time.Duration
length int
}
func NewQueue(length int, defaultValue int) *Queue {
data := make([]time.Duration, 0, length)
for i := 0; i < length; i++ {
data = append(data, time.Duration(defaultValue)*time.Millisecond)
}
return &Queue{
data: data,
length: length,
}
}
func (q *Queue) Add(value time.Duration) {
if len(q.data) >= q.length {
q.data = q.data[1:]
}
q.data = append(q.data, value)
}
func (q *Queue) Avg(k ...int) time.Duration {
param := 1
if len(k) > 0 {
param = k[0]
}
total := time.Duration(0)
count := 0
for _, value := range q.data {
if value != 0 {
total += value
count++
}
}
if count == 0 {
return time.Duration(0)
}
ans := total * time.Duration(param) / time.Duration(count)
if ans > time.Duration(20)*time.Millisecond {
return time.Duration(20) * time.Millisecond
}
return ans
}
func streamResp(c *gin.Context, resp *openai.ChatCompletionStream) string {
result := ""
if resp == nil {
logger.Logger(c).Error("stream resp is nil")
return result
}
_, ok := c.Writer.(http.Flusher)
if !ok {
logger.Logger(c).Panic("server not support")
}
defer func() {
c.Writer.Flush()
}()
queue := NewQueue(GetSettings().OpenAIChatQueueLen, GetSettings().OpenAIChatNetworkDelay)
ch := make(chan rune, 1024)
go func(c *gin.Context, msgChan chan rune) {
lastTime := time.Now()
for {
line, err := resp.Recv()
if err != nil {
close(msgChan)
logger.Logger(c).WithError(err).Error("read openai completion line error")
return
}
if len(line.Choices[0].Delta.Content) == 0 {
continue
}
nowTime := time.Now()
division := strings.Count(line.Choices[0].Delta.Content, "")
for _, v := range line.Choices[0].Delta.Content {
msgChan <- v
}
during := (nowTime.Sub(lastTime) + (time.Duration(GetSettings().OpenAIChatNetworkDelay) *
time.Millisecond)) / time.Duration(division)
queue.Add(during)
lastTime = nowTime
}
}(c, ch)
for char := range ch {
str := string(char)
_, err := c.Writer.WriteString(str)
result += str
if err != nil {
logger.Logger(c).WithError(err).Error("write string to client error")
return ""
}
c.Writer.Flush()
time.Sleep(queue.Avg(len(str) * 2)) // 英文平均长度为6个字符一个UTF8字符是3个长度试图让一个单词等于一个汉字
}
logger.Logger(c).Info("finish stream text to client")
return result
}

11
service/logger/logger.go Normal file
View File

@ -0,0 +1,11 @@
package logger
import (
"context"
"github.com/sirupsen/logrus"
)
func Logger(c context.Context) *logrus.Entry {
return logrus.WithContext(c)
}

51
service/response.go Normal file
View File

@ -0,0 +1,51 @@
package service
import (
"net/http"
"github.com/gin-gonic/gin"
)
type CommonResp interface {
gin.H
}
func OKResp[T CommonResp](c *gin.Context, origin *T) {
if c.ContentType() == "application/x-protobuf" {
c.ProtoBuf(http.StatusOK, origin)
} else {
c.JSON(http.StatusOK, OK(RespSuccess).WithBody(origin))
}
}
func OKRespWithJsonMarshal[T CommonResp](c *gin.Context, origin *T) {
c.JSON(http.StatusOK, OK(RespSuccess).WithBody(origin))
}
func ErrResp[T CommonResp](c *gin.Context, origin *T, err string, errCode ...int) {
if c.ContentType() == "application/x-protobuf" {
c.ProtoBuf(http.StatusInternalServerError, origin)
} else {
if len(errCode) > 0 {
c.JSON(errCode[0], Err(err).WithBody(origin))
return
}
c.JSON(http.StatusOK, Err(err).WithBody(origin))
}
}
func ErrUnAuthorized(c *gin.Context, err string) {
if c.ContentType() == "application/x-protobuf" {
c.ProtoBuf(http.StatusUnauthorized, nil)
} else {
c.JSON(http.StatusUnauthorized, Err(err).WithBody(nil))
}
}
func ErrNotFound(c *gin.Context, err string) {
if c.ContentType() == "application/x-protobuf" {
c.ProtoBuf(http.StatusNotFound, nil)
} else {
c.JSON(http.StatusNotFound, Err(err).WithBody(nil))
}
}

55
service/result.go Normal file
View File

@ -0,0 +1,55 @@
package service
import (
"github.com/gin-gonic/gin"
)
type Result struct {
Code int `json:"code,omitempty"`
Msg string `json:"msg,omitempty"`
Data gin.H `json:"data,omitempty"`
Body interface{} `json:"body,omitempty"`
}
func (r *Result) WithMsg(message string) *Result {
r.Msg = message
return r
}
func (r *Result) WithData(data gin.H) *Result {
r.Data = data
return r
}
func (r *Result) WithKeyValue(key string, value interface{}) *Result {
if r.Data == nil {
r.Data = gin.H{}
}
r.Data[key] = value
return r
}
func (r *Result) WithBody(body interface{}) *Result {
r.Body = body
return r
}
func newResult(code int, msg string) *Result {
return &Result{
Code: code,
Msg: msg,
Data: nil,
}
}
func OK(msg string) *Result {
return newResult(200, msg)
}
func Err(msg string) *Result {
return newResult(500, msg)
}
func UnAuth(msg string) *Result {
return newResult(401, msg)
}

51
service/rpc.go Normal file
View File

@ -0,0 +1,51 @@
package service
import (
"ai-search/service/logger"
"context"
"fmt"
"github.com/imroc/req/v3"
)
type SearchClient interface {
Search(c context.Context, query string, nums int) (SearchResp, error)
}
type searchClient struct {
URL string
}
type SearchResp struct {
Results []SearchResult `json:"results"`
}
type SearchResult struct {
Body string `json:"body"`
Href string `json:"href"`
Title string `json:"title"`
}
func NewSearchClient() SearchClient {
return &searchClient{
URL: GetSettings().RPCEndpoints.SearchURL,
}
}
func (s *searchClient) Search(c context.Context, query string, nums int) (SearchResp, error) {
resp := SearchResp{}
_, err := req.C().R().
SetFormData(map[string]string{
"q": query,
"max_results": fmt.Sprintf("%d", nums),
}).
SetContentType("application/x-www-form-urlencoded").
SetSuccessResult(&resp).
Post(s.URL)
if err != nil {
logger.Logger(c).Error(err)
}
return resp, err
}

214
service/search_handler.go Normal file
View File

@ -0,0 +1,214 @@
package service
import (
"ai-search/service/logger"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/sashabaranov/go-openai"
)
func SearchHandler(c *gin.Context) {
searchReq := &SearchReq{}
if err := c.Copy().ShouldBindJSON(searchReq); err != nil {
ErrResp[gin.H](c, nil, "error", http.StatusBadRequest)
return
}
cachedResult, err := getCacheResp(c, searchReq.SearchUUID)
if err == nil {
logger.Logger(c).Infof("cache key hit [%s], query: [%s]", searchReq.SearchUUID, searchReq.Query)
c.String(http.StatusOK, cachedResult)
return
}
if searchReq.Query == "" && searchReq.SearchUUID == "" {
ErrResp[gin.H](c, nil, "param is invalid", http.StatusBadRequest)
return
}
if searchReq.Query == "" && searchReq.SearchUUID != "" {
ErrResp[gin.H](c, nil, "content is gone", http.StatusGone)
return
}
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("Access-Control-Allow-Origin", "*")
cli := NewSearchClient()
searchResp, err := cli.Search(c, searchReq.Query, GetSettings().RAGSearchCount)
if err != nil {
logger.Logger(c).WithError(err).Errorf("client.Search error")
return
}
ss := &Sources{}
ss.FromSearchResp(&searchResp, searchReq.Query, searchReq.SearchUUID)
originReq := &openai.ChatCompletionRequest{
Messages: []openai.ChatCompletionMessage{
{
Role: openai.ChatMessageRoleSystem,
Content: fmt.Sprintf(RagPrompt(), getSearchContext(ss)),
},
{
Role: openai.ChatMessageRoleUser,
Content: searchReq.Query,
},
},
Stream: true,
}
apiKey, endpoint := getOpenAIConfig(c, "chat")
conf := openai.DefaultConfig(apiKey)
conf.BaseURL = endpoint
client := openai.NewClientWithConfig(conf)
request := openai.ChatCompletionRequest{
Model: openai.GPT3Dot5Turbo,
Messages: originReq.Messages,
Temperature: GetSettings().RAGParams.Temperature,
MaxTokens: GetSettings().RAGParams.MaxTokens,
Stream: true,
}
resp, err := client.CreateChatCompletionStream(
context.Background(),
request,
)
if err != nil {
logger.Logger(c).WithError(err).Errorf("client.CreateChatCompletionStream error")
}
relatedStrChan := make(chan string)
defer close(relatedStrChan)
go func() {
relatedStrChan <- getRelatedQuestionsResp(c, searchReq.Query, ss)
}()
finalResult := streamSearchItemResp(c, []string{
ss.ToString(),
"\n\n__LLM_RESPONSE__\n\n",
})
finalResult = finalResult + streamResp(c, resp)
finalResult = finalResult + streamSearchItemResp(c, []string{
"\n\n__RELATED_QUESTIONS__\n\n",
// `[{"question": "What is the formal way to say hello in Chinese?"}, {"question": "How do you say 'How are you' in Chinese?"}]`,
<-relatedStrChan,
})
GetSearchCache().Set([]byte(searchReq.SearchUUID), newCachedResult(searchReq.SearchUUID, searchReq.Query, finalResult).ToBytes(), GetSettings().RAGSearchCacheTime)
logger.Logger(c).Infof("cache key miss [%s], query: [%s], set result to cache", searchReq.SearchUUID, searchReq.Query)
}
func getCacheResp(c *gin.Context, searchUUID string) (string, error) {
ans, err := GetSearchCache().Get([]byte(searchUUID))
if err != nil {
return "", err
}
if ans != nil {
cachedResult := &cachedResult{}
cachedResult.FromBytes(ans)
return cachedResult.Result, nil
}
return "", fmt.Errorf("cache not found")
}
func streamSearchItemResp(c *gin.Context, t []string) string {
result := ""
_, ok := c.Writer.(http.Flusher)
if !ok {
logger.Logger(c).Panic("server not support")
}
defer func() {
c.Writer.Flush()
}()
for _, line := range t {
_, err := c.Writer.WriteString(line)
result += line
if err != nil {
logger.Logger(c).WithError(err).Error("write string to client error")
return ""
}
}
logger.Logger(c).Info("finish stream text to client")
return result
}
func getRelatedQuestionsResp(c context.Context, query string, ss *Sources) string {
apiKey, endpoint := getOpenAIConfig(c, "chat")
conf := openai.DefaultConfig(apiKey)
conf.BaseURL = endpoint
client := openai.NewClientWithConfig(conf)
request := openai.ChatCompletionRequest{
Model: openai.GPT3Dot5Turbo,
Messages: []openai.ChatCompletionMessage{
{
Role: openai.ChatMessageRoleUser,
Content: fmt.Sprintf(MoreQuestionsPrompt(), getSearchContext(ss)) + query,
},
},
Temperature: GetSettings().RAGParams.MoreQuestionsTemperature,
MaxTokens: GetSettings().RAGParams.MoreQuestionsMaxTokens,
}
resp, err := client.CreateChatCompletion(
context.Background(),
request,
)
if err != nil {
logger.Logger(c).WithError(err).Errorf("client.CreateChatCompletion error")
}
mode := 1
cs := strings.Split(resp.Choices[0].Message.Content, ". ")
if len(cs) == 1 {
cs = strings.Split(resp.Choices[0].Message.Content, "- ")
mode = 2
}
rq := []string{}
for i, line := range cs {
if len(line) <= 2 {
continue
}
if i != len(cs)-1 && mode == 1 {
line = line[:len(line)-1]
}
rq = append(rq, line)
}
return parseRelatedQuestionsResp(rq)
}
func parseRelatedQuestionsResp(qs []string) string {
q := []struct {
Question string `json:"question"`
}{}
for _, line := range qs {
if len(strings.Trim(line, " ")) <= 2 {
continue
}
q = append(q, struct {
Question string `json:"question"`
}{line})
}
rawBytes, _ := json.Marshal(q)
return string(rawBytes)
}
func getSearchContext(ss *Sources) string {
ans := ""
for i, ctx := range ss.Contexts {
ans = ans + fmt.Sprintf("[[citation:%d]] ", i+1) + ctx.Snippet + "\n\n"
}
return ans
}

69
service/settings.go Normal file
View File

@ -0,0 +1,69 @@
package service
import (
"ai-search/utils"
"github.com/ilyakaznacheev/cleanenv"
"github.com/joho/godotenv"
)
type AppSettings struct {
ListenAddr string `env:"LISTEN_ADDR" env-default:":8080"`
DBPath string `env:"DB_PATH" env-default:"/litefs/db"`
OpenAIEndpint string `env:"OPENAI_ENDPOINT" env-required:"true"`
OpenAIAPIKey string `env:"OPENAI_API_KEY" env-required:"true"`
OpenAIWriterPrompt string `env:"OPENAI_WRITER_PROMPT" env-default:"您是一名人工智能写作助手,可以根据先前文本的上下文继续现有文本。给予后面的字符比开始的字符更多的权重/优先级。而且绝对一定要尽量多使用中文!将您的回答限制在 200 个字符以内,但请确保构建完整的句子。"`
HttpProxy string `env:"HTTP_PROXY"`
IsDebug bool `env:"DEBUG" env-default:"false"`
ChatMaxTokens int `env:"CHAT_MAX_TOKENS" env-default:"400"`
OpenAIChatEndpoint string `env:"OPENAI_CHAT_ENDPOINT" env-required:"true"`
OpenAIChatAPIKey string `env:"OPENAI_CHAT_API_KEY" env-required:"true"`
OpenAIChatNetworkDelay int `env:"OPENAI_CHAT_NETWORK_DELAY" env-default:"5"`
OpenAIChatQueueLen int `env:"OPENAI_CHAT_QUEUE_LEN" env-default:"10"`
CacheSize int `env:"CACHE_SIZE" env-default:"100"` // in MB
RAGSearchCount int `env:"RAG_SEARCH_COUNT" env-default:"8"`
RAGSearchCacheTime int `env:"RAG_SEARCH_CACHE_TIME" env-default:"1200"` // sec
RPCEndpoints RPCEndpoints `env-prefix:"RPC_"`
Prompts Prompts `env-prefix:"PROMPT_"`
RAGParams RAGParams `env-prefix:"RAG_"`
}
type RPCEndpoints struct {
SearchURL string `env:"SEARCH_URL" env-required:"true"`
}
type Prompts struct {
RAGPath string `env:"RAG_PATH" env-default:""`
MoreQuestionsPath string `env:"MORE_QUESTIONS_PATH" env-default:""`
}
type RAGParams struct {
MaxTokens int `env:"MAX_TOKENS" env-default:"2048"`
MoreQuestionsMaxTokens int `env:"MORE_QUESTIONS_MAX_TOKENS" env-default:"1024"`
Temperature float32 `env:"TEMPERATURE" env-default:"0.9"`
MoreQuestionsTemperature float32 `env:"MORE_QUESTIONS_TEMPERATURE" env-default:"0.7"`
}
var (
appSetting *AppSettings
)
func init() {
godotenv.Load()
conf := &AppSettings{}
if err := cleanenv.ReadEnv(conf); err != nil {
panic(err)
}
appSetting = conf
InitCache()
if len(appSetting.Prompts.MoreQuestionsPath) >= 0 {
moreQuestionsPrompt = utils.GetFileContent(appSetting.Prompts.MoreQuestionsPath)
}
if len(appSetting.Prompts.RAGPath) >= 0 {
ragPrompt = utils.GetFileContent(appSetting.Prompts.RAGPath)
}
}
func GetSettings() *AppSettings {
return appSetting
}

51
service/static.go Normal file
View File

@ -0,0 +1,51 @@
package service
import (
"embed"
"io/fs"
"net/http"
"strings"
"github.com/gin-contrib/static"
"github.com/gin-gonic/gin"
)
type embedFileSystem struct {
http.FileSystem
}
func (e embedFileSystem) Exists(prefix string, path string) bool {
_, err := e.Open(path)
return err == nil
}
func EmbedFolder(fsEmbed embed.FS, targetPath string) static.ServeFileSystem {
fsys, err := fs.Sub(fsEmbed, targetPath)
if err != nil {
panic(err)
}
return embedFileSystem{
FileSystem: http.FS(fsys),
}
}
func HandleStaticFile(router *gin.Engine, f embed.FS) {
root := EmbedFolder(f, "out")
router.Use(static.Serve("/", root))
staticServer := static.Serve("/", root)
router.NoRoute(func(c *gin.Context) {
if c.Request.Method == http.MethodGet &&
!strings.ContainsRune(c.Request.URL.Path, '.') &&
!strings.HasPrefix(c.Request.URL.Path, "/v1/") {
if strings.HasSuffix(c.Request.URL.Path, "/") {
c.Request.URL.Path += "index.html"
staticServer(c)
return
}
if !strings.HasSuffix(c.Request.URL.Path, ".html") {
c.Request.URL.Path += ".html"
staticServer(c)
return
}
}
})
}

126
service/types.go Normal file
View File

@ -0,0 +1,126 @@
package service
import (
"encoding/json"
"time"
)
var (
ragPrompt = `You are a large language AI assistant built by VaalaCat. You are given a user question, and please write clean, concise and accurate answer to the question. You will be given a set of related contexts to the question, each starting with a reference number like [[citation:x]], where x is a number. Please use the context and cite the context at the end of each sentence if applicable.
Your answer must be correct, accurate and written by an expert using an unbiased and professional tone. Please limit to 1024 tokens. Do not give any information that is not related to the question, and do not repeat. Say "information is missing on" followed by the related topic, if the given context do not provide sufficient information.
Please cite the contexts with the reference numbers, in the format [citation:x]. If a sentence comes from multiple contexts, please list all applicable citations, like [citation:3][citation:5]. Other than code and specific names and citations, your answer must be written in the same language as the question.
Here are the set of contexts:
%s
Remember, use Chinese more, don't blindly repeat the contexts verbatim. And here is the user question:
`
moreQuestionsPrompt = `You are a helpful assistant that helps the user to ask related questions, based on user's original question and the related contexts. Please identify worthwhile topics that can be follow-ups, and write questions no longer than 20 words each. Please make sure that specifics, like events, names, locations, are included in follow up questions so they can be asked standalone. For example, if the original question asks about "the Manhattan project", in the follow up question, do not just say "the project", but use the full name "the Manhattan project". Your related questions must be in the same language as the original question.
Here are the contexts of the question:
%s
Remember, use Chinese more, based on the original question and related contexts, suggest three such further questions. Do NOT repeat the original question. Each related question should be no longer than 20 words. Here is the original question:
`
)
func RagPrompt() string {
return ragPrompt
}
func MoreQuestionsPrompt() string {
return moreQuestionsPrompt
}
type SearchReq struct {
Query string `json:"query"`
SearchUUID string `json:"search_uuid"`
}
type Sources struct {
Query string `json:"query"`
RID string `json:"rid"`
Contexts []Source `json:"contexts"`
}
func (ss *Sources) FromSearchResp(resp *SearchResp, query, rid string) {
ss.Query = query
ss.RID = rid
ctxs := make([]Source, 0)
for _, ctx := range resp.Results {
ctxs = append(ctxs, Source{
ID: ctx.Href,
Name: ctx.Title,
URL: ctx.Href,
Snippet: ctx.Body,
})
}
ss.Contexts = ctxs
}
func (ss *Sources) ToString() string {
rawBytes, _ := json.Marshal(ss)
return string(rawBytes)
}
type Source struct {
ID string `json:"id"`
Name string `json:"name"`
URL string `json:"url"`
IsFamilyFriendly bool `json:"isFamilyFriendly"`
DisplayURL string `json:"displayUrl"`
Snippet string `json:"snippet"`
DeepLinks []DeepLink `json:"deepLinks"`
DateLastCrawled time.Time `json:"dateLastCrawled"`
CachedPageURL string `json:"cachedPageUrl"`
Language string `json:"language"`
PrimaryImageOfPage *PrimaryImage `json:"primaryImageOfPage,omitempty"`
IsNavigational bool `json:"isNavigational"`
}
type DeepLink struct {
Snippet string `json:"snippet"`
Name string `json:"name"`
URL string `json:"url"`
}
type PrimaryImage struct {
ThumbnailURL string `json:"thumbnailUrl"`
Width int `json:"width"`
Height int `json:"height"`
ImageID string `json:"imageId"`
}
func (s *Source) FromSearchResp(resp *SearchResult) {
s.ID = resp.Href
s.Name = resp.Title
s.URL = resp.Href
s.Snippet = resp.Body
}
type cachedResult struct {
SearchUUID string `json:"search_uuid"`
Query string `json:"query"`
Result string `json:"result"`
}
func (cs *cachedResult) FromBytes(rawBytes []byte) {
json.Unmarshal(rawBytes, cs)
}
func (cs *cachedResult) ToBytes() []byte {
rawBytes, _ := json.Marshal(cs)
return rawBytes
}
func newCachedResult(searchUUID, query, result string) *cachedResult {
return &cachedResult{
SearchUUID: searchUUID,
Query: query,
Result: result,
}
}

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

30
src/app/globals.css Normal file
View File

@ -0,0 +1,30 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* :root {
--foreground-rgb: 0, 0, 0;
--background-start-rgb: 214, 219, 220;
--background-end-rgb: 255, 255, 255;
} */
/* @media (prefers-color-scheme: dark) {
:root {
--foreground-rgb: 255, 255, 255;
--background-start-rgb: 0, 0, 0;
--background-end-rgb: 0, 0, 0;
}
} */
body {
color: rgb(var(--foreground-rgb));
background: linear-gradient(to bottom,
transparent,
rgb(var(--background-end-rgb))) rgb(var(--background-start-rgb));
}
@layer utilities {
.text-balance {
text-wrap: balance;
}
}

22
src/app/layout.tsx Normal file
View File

@ -0,0 +1,22 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "AI Search",
description: "AI 搜索",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
</html>
);
}

26
src/app/page.tsx Normal file
View File

@ -0,0 +1,26 @@
"use client";
import { Footer } from "@/components/search/footer";
import { Logo } from "@/components/search/logo";
import { PresetQuery } from "@/components/search/preset-query";
import { Search } from "@/components/search/search";
import React from "react";
export default function SearchHome() {
return (
<div className="flex flex-col">
<div className="absolute inset-0 min-h-[500px] flex items-center justify-center">
<div className="relative flex flex-col gap-8 px-4 -mt-24">
<Logo></Logo>
<Search autofucs={true}></Search>
<div className="flex gap-2 flex-wrap justify-center">
<PresetQuery query="“我思故我在”是谁的名言?"></PresetQuery>
<PresetQuery query="太阳和地球相距多远?"></PresetQuery>
</div>
<Footer></Footer>
</div>
</div>
</div>
);
}

39
src/app/search/page.tsx Normal file
View File

@ -0,0 +1,39 @@
"use client";
import { Result } from "@/components/search/result";
import { Search } from "@/components/search/search";
import { Title } from "@/components/search/title";
import { useSearchParams } from "next/navigation";
import React, { Suspense, useState } from "react";
function SearchPage() {
const searchParams = useSearchParams();
const query = decodeURIComponent(searchParams.get("q") || "");
const rid = decodeURIComponent(searchParams.get("rid") || "");
const [serverQuery, setServerQuery] = useState("");
return (
<div className="flex flex-col">
<div className="absolute inset-0 bg-[url('/bg.svg')] mt-20">
<div className="mx-auto max-w-3xl absolute inset-4 md:inset-8 bg-white">
<div className="h-20 pointer-events-none rounded-t-2xl w-full backdrop-filter absolute top-0 bg-gradient-to-t from-transparent to-white [mask-image:linear-gradient(to_bottom,white,transparent)]"></div>
<div className="px-4 md:px-8 pt-6 pb-24 rounded-2xl ring-8 ring-zinc-300/20 border border-zinc-200 h-full overflow-auto">
<Title key={query} query={query == undefined || query == null || query == "" ? serverQuery : query}></Title>
<Result key={rid} query={query} rid={rid} updateQuery={setServerQuery}></Result>
</div>
<div className="h-80 pointer-events-none w-full rounded-b-2xl backdrop-filter absolute bottom-0 bg-gradient-to-b from-transparent to-white [mask-image:linear-gradient(to_top,white,transparent)]"></div>
<div className="absolute z-10 flex items-center justify-center bottom-6 px-4 md:px-8 w-full">
<div className="w-full">
<Search autofucs={false} ></Search>
</div>
</div>
</div>
</div>
</div>
);
}
export default function Page() {
return <Suspense>
<SearchPage />
</Suspense>
}

View File

@ -0,0 +1,111 @@
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/search/popover";
import { Skeleton } from "@/components/search/skeleton";
import { Wrapper } from "@/components/search/wrapper";
import { Source } from "@/types/source";
import { BookOpenText } from "lucide-react";
import { FC } from "react";
import Markdown from "react-markdown";
export const Answer: FC<{ markdown: string; sources: Source[] }> = ({
markdown,
sources,
}) => {
return (
<Wrapper
title={
<>
<BookOpenText></BookOpenText>
</>
}
content={
markdown && markdown.trim().length > 0 ? (
<div className="prose prose-sm max-w-full">
<Markdown
components={{
a: ({ node: _, ...props }) => {
if (!props.href) return <></>;
const source = sources[+props.href - 1];
if (!source) return <></>;
return (
<span className="inline-block w-4">
<Popover>
<PopoverTrigger asChild>
<span
title={source.name}
className="inline-block cursor-pointer transform scale-[60%] no-underline font-medium bg-zinc-300 hover:bg-zinc-400 w-6 text-center h-6 rounded-full origin-top-left"
>
{props.href}
</span>
</PopoverTrigger>
<PopoverContent
align={"start"}
className="max-w-screen-md flex flex-col gap-2 bg-white shadow-transparent ring-zinc-50 ring-4 text-xs"
>
<div className="text-ellipsis overflow-hidden whitespace-nowrap font-medium">
{source.name}
</div>
<div className="flex gap-4">
{source.primaryImageOfPage?.thumbnailUrl && (
<div className="flex-none">
<img
className="rounded h-16 w-16"
width={source.primaryImageOfPage?.width}
height={source.primaryImageOfPage?.height}
src={source.primaryImageOfPage?.thumbnailUrl}
/>
</div>
)}
<div className="flex-1">
<div className="line-clamp-4 text-zinc-500 break-words">
{source.snippet}
</div>
</div>
</div>
<div className="flex gap-2 items-center">
<div className="flex-1 overflow-hidden">
<div className="text-ellipsis text-blue-500 overflow-hidden whitespace-nowrap">
<a
title={source.name}
href={source.url}
target="_blank"
>
{source.url}
</a>
</div>
</div>
<div className="flex-none flex items-center relative">
<img
className="h-3 w-3"
alt={source.url}
src={`https://www.google.com/s2/favicons?domain=${source.url}&sz=${16}`}
/>
</div>
</div>
</PopoverContent>
</Popover>
</span>
);
},
}}
>
{markdown}
</Markdown>
</div>
) : (
<div className="flex flex-col gap-2">
<Skeleton className="max-w-sm h-4 bg-zinc-200"></Skeleton>
<Skeleton className="max-w-lg h-4 bg-zinc-200"></Skeleton>
<Skeleton className="max-w-2xl h-4 bg-zinc-200"></Skeleton>
<Skeleton className="max-w-lg h-4 bg-zinc-200"></Skeleton>
<Skeleton className="max-w-xl h-4 bg-zinc-200"></Skeleton>
</div>
)
}
></Wrapper>
);
};

View File

@ -0,0 +1,37 @@
import { Mails } from "lucide-react";
import { FC } from "react";
export const Footer: FC = () => {
return (
<div className="text-center flex flex-col items-center text-xs text-zinc-700 gap-1">
<div className="text-zinc-400">
VaalaAI不为您提供任何保证
</div>
<div className="text-zinc-400">
Vaala/VaalaAI ,
<a className="text-blue-500" href="https://github.com/leptonai/search_with_lepton">Lepton AI</a>
</div>
<div className="flex gap-2 justify-center">
<div></div>
<div>
<a
className="text-blue-500 font-medium inline-flex gap-1 items-center flex-nowrap text-nowrap"
href="mailto:me@vaala.cat"
>
<Mails size={8} />
</a>
</div>
</div>
<div className="flex items-center justify-center flex-wrap gap-x-4 gap-y-2 mt-2 text-zinc-400">
<a className="hover:text-zinc-950" href="https://api.vaa.la/">
VaalaAI
</a>
<a className="hover:text-zinc-950" href="https://vaala.cat/">
Blog
</a>
</div>
</div>
);
};

View File

@ -0,0 +1,15 @@
import { FC } from "react";
export const Logo: FC = () => {
return (
<div className="flex gap-4 items-center justify-center cursor-default select-none relative">
<img src="https://vaala.cat/favicon.ico" alt="vaala logo" className="h-10 w-10"></img>
<div className="text-center font-medium text-2xl md:text-3xl text-zinc-950 relative text-nowrap">
VaalaAI Search
</div>
<div className="transform scale-75 origin-left border items-center rounded-lg bg-gray-100 px-2 py-1 text-xs font-medium text-zinc-600">
beta
</div>
</div>
);
};

View File

@ -0,0 +1,31 @@
"use client";
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "@/utils/cn";
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent };

View File

@ -0,0 +1,17 @@
import { getSearchUrl } from "@/utils/get-search-url";
import { nanoid } from "nanoid";
import { useRouter } from "next/navigation";
import { FC } from "react";
export const PresetQuery: FC<{ query: string }> = ({ query }) => {
const router = useRouter()
return (
<div
title={query}
className="border border-zinc-200/50 text-ellipsis overflow-hidden text-nowrap items-center rounded-lg bg-zinc-100 hover:bg-zinc-200/80 hover:text-zinc-950 px-2 py-1 text-xs font-medium text-zinc-600"
onClick={() => { router.push(getSearchUrl(encodeURIComponent(query), nanoid())) }}
>
{query}
</div>
);
};

View File

@ -0,0 +1,41 @@
import { PresetQuery } from "@/components/search/preset-query";
import { Skeleton } from "@/components/search/skeleton";
import { Wrapper } from "@/components/search/wrapper";
import { Relate } from "@/types/relate";
import { MessageSquareQuote } from "lucide-react";
import { FC } from "react";
export const Relates: FC<{ relates: Relate[] | null }> = ({ relates }) => {
return (
<Wrapper
title={
<>
<MessageSquareQuote></MessageSquareQuote>
</>
}
content={
<div className="flex gap-2 flex-col">
{relates !== null ? (
relates.length > 0 ? (
relates.map(({ question }) => (
<PresetQuery key={question} query={question}></PresetQuery>
))
) : (
<div className="text-sm">.</div>
)
) : (
<>
<Skeleton className="w-full h-5 bg-zinc-200/80"></Skeleton>
<Skeleton className="w-full h-5 bg-zinc-200/80"></Skeleton>
<Skeleton className="w-full h-5 bg-zinc-200/80"></Skeleton>
</>
)}
<div className="text-xs text-zinc-500 text-center mt-4">
<div>VaalaAI回答完成后</div>
<div>20</div>
</div>
</div>
}
></Wrapper>
);
};

View File

@ -0,0 +1,74 @@
"use client";
import { Answer } from "@/components/search/answer";
import { Relates } from "@/components/search/relates";
import { Sources } from "@/components/search/sources";
import { Relate } from "@/types/relate";
import { Source } from "@/types/source";
import { parseStreaming } from "@/utils/parse-streaming";
import { Annoyed, Mails } from "lucide-react";
import { FC, useEffect, useState } from "react";
export const Result: FC<{ query: string; rid: string, updateQuery: (query: string) => void }> = ({ query, rid, updateQuery }) => {
const [sources, setSources] = useState<Source[]>([]);
const [markdown, setMarkdown] = useState<string>("");
const [relates, setRelates] = useState<Relate[] | null>(null);
const [error, setError] = useState<number | null>(null);
const [serverQuery, setServerQuery] = useState("");
useEffect(() => {
const controller = new AbortController();
void parseStreaming(
controller,
query,
rid,
"query",
setSources,
setMarkdown,
setRelates,
setServerQuery,
setError,
);
return () => {
controller.abort();
};
}, [query, rid]);
useEffect(() => {
updateQuery(serverQuery);
}, [serverQuery, updateQuery]);
return (
<div className="flex flex-col gap-8" >
<Answer markdown={markdown} sources={sources}></Answer>
<Sources sources={sources}></Sources>
<Relates relates={relates}></Relates>
{error && (
<div className="absolute inset-4 flex items-center justify-center bg-white/40 backdrop-blur-sm">
<div className="p-4 bg-white shadow-2xl rounded text-blue-500 font-medium flex gap-4">
<Annoyed></Annoyed>
{error === 429
? "抱歉您的请求过于频繁Vaala被累死了QwQ请稍后再试吧。"
: <div className="text-center">{
error === 400 ?
<div></div>
: (error === 401 ?
<div></div>
: (error === 410 ?
(true ? <div></div> : <div></div>)
: (<div>Vaala紧急修复中
<div>
<a
className="text-blue-500 font-medium inline-flex gap-1 items-center flex-nowrap text-nowrap"
href="mailto:me@vaala.cat"
>
<Mails size={8} />
</a>
</div>
</div>)))
}</div>}
</div>
</div>
)}
</div>
);
};

View File

@ -0,0 +1,43 @@
"use client";
import { getSearchUrl } from "@/utils/get-search-url";
import { ArrowRight } from "lucide-react";
import { nanoid } from "nanoid";
import { useRouter } from "next/navigation";
import { FC, useState } from "react";
export const Search: FC<{ autofucs?: boolean }> = ({ autofucs }) => {
const [value, setValue] = useState("");
const router = useRouter()
return (
<form
onSubmit={(e) => {
e.preventDefault();
if (value) {
setValue("");
router.push(getSearchUrl(encodeURIComponent(value), nanoid()));
}
}}
>
<label
className="relative bg-white flex items-center justify-center border ring-8 ring-zinc-300/20 py-2 px-2 rounded-lg gap-2 focus-within:border-zinc-300"
htmlFor="search-bar"
>
<input
id="search-bar"
value={value}
onChange={(e) => setValue(e.target.value)}
autoFocus={autofucs}
placeholder="询问Vaala任何问题 ..."
className="px-2 pr-6 text-sm w-full rounded-md flex-1 outline-none bg-white"
/>
<button
type="submit"
key={Math.random()}
className="w-auto py-1 px-2 bg-black border-black text-white fill-white active:scale-95 border overflow-hidden relative rounded-xl"
>
<ArrowRight size={16} />
</button>
</label>
</form>
);
};

View File

@ -0,0 +1,13 @@
import { cn } from "@/utils/cn";
import { HTMLAttributes } from "react";
function Skeleton({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
);
}
export { Skeleton };

View File

@ -0,0 +1,70 @@
import { Skeleton } from "@/components/search/skeleton";
import { Wrapper } from "@/components/search/wrapper";
import { Source } from "@/types/source";
import { BookText } from "lucide-react";
import { FC } from "react";
const SourceItem: FC<{ source: Source; index: number }> = ({
source,
index,
}) => {
const { id, name, url } = source;
const domain = new URL(url).hostname;
return (
<div
className="relative text-xs py-3 px-3 bg-zinc-100 hover:bg-zinc-200 rounded-lg flex flex-col gap-2"
key={id}
>
<a href={url} target="_blank" className="absolute inset-0"></a>
<div className="font-medium text-zinc-950 text-ellipsis overflow-hidden whitespace-nowrap break-words">
{name}
</div>
<div className="flex gap-2 items-center">
<div className="flex-1 overflow-hidden">
<div className="text-ellipsis whitespace-nowrap break-all text-zinc-400 overflow-hidden w-full">
{index + 1} - {domain}
</div>
</div>
<div className="flex-none flex items-center">
<img
className="h-3 w-3"
alt={domain}
src={`https://www.google.com/s2/favicons?domain=${domain}&sz=${16}`}
/>
</div>
</div>
</div>
);
};
export const Sources: FC<{ sources: Source[] }> = ({ sources }) => {
return (
<Wrapper
title={
<>
<BookText></BookText>
</>
}
content={
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
{sources.length > 0 ? (
sources.map((item, index) => (
<SourceItem
key={item.id}
index={index}
source={item}
></SourceItem>
))
) : (
<>
<Skeleton className="max-w-sm h-16 bg-zinc-200/80"></Skeleton>
<Skeleton className="max-w-sm h-16 bg-zinc-200/80"></Skeleton>
<Skeleton className="max-w-sm h-16 bg-zinc-200/80"></Skeleton>
<Skeleton className="max-w-sm h-16 bg-zinc-200/80"></Skeleton>
</>
)}
</div>
}
></Wrapper>
);
};

View File

@ -0,0 +1,30 @@
"use client";
import { getSearchUrl } from "@/utils/get-search-url";
import { RefreshCcw } from "lucide-react";
import { nanoid } from "nanoid";
import { useRouter } from "next/navigation";
export const Title = ({ query }: { query: string }) => {
const router = useRouter()
return (
<div className="flex items-center pb-4 mb-6 border-b gap-4">
<div
className="flex-1 text-lg sm:text-xl text-black text-ellipsis overflow-hidden whitespace-nowrap"
title={query}
>
{query}
</div>
<div className="flex-none">
<button
onClick={() => {
router.push(getSearchUrl(encodeURIComponent(query), nanoid()));
}}
type="button"
className="rounded flex gap-2 items-center bg-transparent px-2 py-1 text-xs font-semibold text-blue-500 hover:bg-zinc-100"
>
<RefreshCcw size={12}></RefreshCcw>
</button>
</div>
</div>
);
};

View File

@ -0,0 +1,13 @@
import { FC, ReactNode } from "react";
export const Wrapper: FC<{
title: ReactNode;
content: ReactNode;
}> = ({ title, content }) => {
return (
<div className="flex flex-col gap-4 w-full">
<div className="flex gap-2 text-blue-500">{title}</div>
{content}
</div>
);
};

3
src/types/relate.ts Normal file
View File

@ -0,0 +1,3 @@
export interface Relate {
question: string;
}

19
src/types/source.ts Normal file
View File

@ -0,0 +1,19 @@
export interface Source {
id: string;
name: string;
url: string;
isFamilyFriendly: boolean;
displayUrl: string;
snippet: string;
deepLinks: { snippet: string; name: string; url: string }[];
dateLastCrawled: string;
cachedPageUrl: string;
language: string;
primaryImageOfPage?: {
thumbnailUrl: string;
width: number;
height: number;
imageId: string;
};
isNavigational: boolean;
}

217
src/utils/base64.ts Normal file
View File

@ -0,0 +1,217 @@
/*
This file is a work taken from the following location: https://gist.github.com/enepomnyaschih/72c423f727d395eeaa09697058238727
It has been modified using the below algorithms to make it work for the standard web encoding.
MIT License
Copyright (c) 2020 Egor Nepomnyaschih
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
/*
// This constant can also be computed with the following algorithm:
const base64Chars = [],
A = "A".charCodeAt(0),
a = "a".charCodeAt(0),
n = "0".charCodeAt(0);
for (let i = 0; i < 26; ++i) {
base64Chars.push(String.fromCharCode(A + i));
}
for (let i = 0; i < 26; ++i) {
base64Chars.push(String.fromCharCode(a + i));
}
for (let i = 0; i < 10; ++i) {
base64Chars.push(String.fromCharCode(n + i));
}
base64Chars.push("+");
base64Chars.push("/");
*/
const base64Chars = [
"A",
"B",
"C",
"D",
"E",
"F",
"G",
"H",
"I",
"J",
"K",
"L",
"M",
"N",
"O",
"P",
"Q",
"R",
"S",
"T",
"U",
"V",
"W",
"X",
"Y",
"Z",
"a",
"b",
"c",
"d",
"e",
"f",
"g",
"h",
"i",
"j",
"k",
"l",
"m",
"n",
"o",
"p",
"q",
"r",
"s",
"t",
"u",
"v",
"w",
"x",
"y",
"z",
"0",
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
"+",
"/",
];
/*
// This constant can also be computed with the following algorithm:
const l = 256, base64codes = new Uint8Array(l);
for (let i = 0; i < l; ++i) {
base64codes[i] = 255; // invalid character
}
base64Chars.forEach((char, index) => {
base64codes[char.charCodeAt(0)] = index;
});
base64codes["=".charCodeAt(0)] = 0; // ignored anyway, so we just need to prevent an error
*/
const base64Codes = [
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 62, 255, 255, 255, 63,
52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 255, 255, 255, 0, 255, 255,
255, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14,
15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 255, 255, 255, 255, 255,
255, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40,
41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51
];
function getBase64Code(charCode: number) {
if (charCode >= base64Codes.length) {
throw new Error("Unable to parse base64 string.");
}
const code = base64Codes[charCode];
if (code === 255) {
throw new Error("Unable to parse base64 string.");
}
return code;
}
export function getBase64FromBytes(bytes: number[] | Uint8Array): string {
let result = "",
i,
l = bytes.length;
for (i = 2; i < l; i += 3) {
result += base64Chars[bytes[i - 2] >> 2];
result += base64Chars[((bytes[i - 2] & 0x03) << 4) | (bytes[i - 1] >> 4)];
result += base64Chars[((bytes[i - 1] & 0x0F) << 2) | (bytes[i] >> 6)];
result += base64Chars[bytes[i] & 0x3F];
}
if (i === l + 1) { // 1 octet yet to write
result += base64Chars[bytes[i - 2] >> 2];
result += base64Chars[(bytes[i - 2] & 0x03) << 4];
result += "==";
}
if (i === l) { // 2 octets yet to write
result += base64Chars[bytes[i - 2] >> 2];
result += base64Chars[((bytes[i - 2] & 0x03) << 4) | (bytes[i - 1] >> 4)];
result += base64Chars[(bytes[i - 1] & 0x0F) << 2];
result += "=";
}
return result;
}
export function getBase64WebEncodingFromBytes(bytes: number[] | Uint8Array): string {
return getBase64FromBytes(bytes)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
}
export function getBytesFromBase64(str: string): Uint8Array {
str = str.replace(/\-/g, "+")
.replace(/\_/g, "/");
// if (str.length % 4 !== 0) {
// throw new Error("Unable to parse base64 string.");
// }
const index = str.indexOf("=");
if (index !== -1 && index < str.length - 2) {
throw new Error("Unable to parse base64 string.");
}
let missingOctets = str.endsWith("==") ? 2 : str.endsWith("=") ? 1 : 0,
n = str.length,
result = new Uint8Array(3 * (n / 4)),
buffer;
for (let i = 0, j = 0; i < n; i += 4, j += 3) {
buffer =
getBase64Code(str.charCodeAt(i)) << 18 |
getBase64Code(str.charCodeAt(i + 1)) << 12 |
getBase64Code(str.charCodeAt(i + 2)) << 6 |
getBase64Code(str.charCodeAt(i + 3));
result[j] = buffer >> 16;
result[j + 1] = (buffer >> 8) & 0xFF;
result[j + 2] = buffer & 0xFF;
}
return result.subarray(0, result.length - missingOctets);
}

6
src/utils/cn.ts Normal file
View File

@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

26
src/utils/fetch-stream.ts Normal file
View File

@ -0,0 +1,26 @@
async function pump(
reader: ReadableStreamDefaultReader<Uint8Array>,
controller: ReadableStreamDefaultController,
onChunk?: (chunk: Uint8Array) => void,
onDone?: () => void,
): Promise<ReadableStreamReadResult<Uint8Array> | undefined> {
const { done, value } = await reader.read();
if (done) {
onDone && onDone();
controller.close();
return;
}
onChunk && onChunk(value);
controller.enqueue(value);
return pump(reader, controller, onChunk, onDone);
}
export const fetchStream = (
response: Response,
onChunk?: (chunk: Uint8Array) => void,
onDone?: () => void,
): ReadableStream<string> => {
const reader = response.body!.getReader();
return new ReadableStream({
start: (controller) => pump(reader, controller, onChunk, onDone),
});
};

View File

@ -0,0 +1,4 @@
export const getSearchUrl = (query: string, search_uuid: string) => {
const prefix = "/search";
return `${prefix}?q=${encodeURIComponent(query)}&rid=${search_uuid}`;
};

View File

@ -0,0 +1,85 @@
import { Source } from "@/types/source";
import { Relate } from "@/types/relate";
import { fetchStream } from "@/utils/fetch-stream";
const LLM_SPLIT = "__LLM_RESPONSE__";
const RELATED_SPLIT = "__RELATED_QUESTIONS__";
export const parseStreaming = async (
controller: AbortController,
query: string,
search_uuid: string,
mode: "query" | "share",
onSources: (value: Source[]) => void,
onMarkdown: (value: string) => void,
onRelates: (value: Relate[]) => void,
onServerQuery: (value: string) => void,
onError?: (status: number) => void,
) => {
const decoder = new TextDecoder();
let uint8Array = new Uint8Array();
let chunks = "";
let sourcesEmitted = false;
const response = await fetch(mode === "query" ? `/v1/chat/search` : `/v1/chat/share`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "*./*",
},
signal: controller.signal,
body: JSON.stringify({
query,
search_uuid,
}),
});
if (response.status !== 200) {
onError?.(response.status);
return;
}
const markdownParse = (text: string) => {
onMarkdown(
text
.replace(/\[\[([cC])itation/g, "[citation")
.replace(/[cC]itation:(\d+)]]/g, "citation:$1]")
.replace(/\[\[([cC]itation:\d+)]](?!])/g, `[$1]`)
.replace(/\[[cC]itation:(\d+)]/g, "[citation]($1)")
.replace(/\[(\d+)\]/g, "[citation]($1)")
.replace(/\[引用:(\d+)\]/g, "[citation]($1)")
.replace(/\[引用(\d+)\]/g, "[citation]($1)"),
);
};
fetchStream(
response,
(chunk) => {
uint8Array = new Uint8Array([...uint8Array, ...chunk]);
chunks = decoder.decode(uint8Array, { stream: true });
if (chunks.includes(LLM_SPLIT)) {
const [sources, rest] = chunks.split(LLM_SPLIT);
if (!sourcesEmitted) {
try {
onServerQuery((JSON.parse(sources)).query);
onSources((JSON.parse(sources)).contexts);
} catch (e) {
onSources([]);
}
}
sourcesEmitted = true;
if (rest.includes(RELATED_SPLIT)) {
const [md] = rest.split(RELATED_SPLIT);
markdownParse(md);
} else {
markdownParse(rest);
}
}
},
() => {
const [_, relates] = chunks.split(RELATED_SPLIT);
try {
onRelates(JSON.parse(relates));
window.history.replaceState(null, "", "/search?rid=" + search_uuid);
} catch (e) {
onRelates([]);
}
},
);
};

20
tailwind.config.ts Normal file
View File

@ -0,0 +1,20 @@
import type { Config } from "tailwindcss";
const config: Config = {
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
backgroundImage: {
"gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
"gradient-conic":
"conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))",
},
},
},
plugins: [],
};
export default config;

40
tsconfig.json Normal file
View File

@ -0,0 +1,40 @@
{
"compilerOptions": {
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"target": "ES2020",
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}

11
utils/file.go Normal file
View File

@ -0,0 +1,11 @@
package utils
import "os"
func GetFileContent(path string) string {
content, err := os.ReadFile(path)
if err != nil {
panic(err)
}
return string(content)
}