diff --git a/cloudflare-worker/README.md b/cloudflare-worker/README.md new file mode 100644 index 00000000..8f684d95 --- /dev/null +++ b/cloudflare-worker/README.md @@ -0,0 +1,143 @@ +# GitHub Stars Vectorize Worker + +极简 Cloudflare Worker,作为 Cloudflare Vectorize 的代理。前端负责 Embedding 生成,Worker 只负责向量的存/查/删。 + +## 前置条件 + +- [Cloudflare 账号](https://dash.cloudflare.com/) +- [Wrangler CLI](https://developers.cloudflare.com/workers/wrangler/install-and-update/) (`npm install -g wrangler`) +- 已登录 Wrangler (`wrangler login`) + +## 首次部署 + +### 1. 创建 Vectorize 索引 + +索引维度必须与你选择的 Embedding 模型一致: + +```bash +# OpenAI text-embedding-3-small (1536维) +npx wrangler vectorize create github-stars --dimensions=1536 --metric=cosine + +# Ollama nomic-embed-text (768维) +npx wrangler vectorize create github-stars --dimensions=768 --metric=cosine + +# Cohere embed-multilingual-v3.0 (1024维) +npx wrangler vectorize create github-stars --dimensions=1024 --metric=cosine + +# Gemini text-embedding-004 (768维) +npx wrangler vectorize create github-stars --dimensions=768 --metric=cosine + +# 硅基流动 BAAI/bge-large-zh-v1.5 (1024维) +npx wrangler vectorize create github-stars --dimensions=1024 --metric=cosine +``` + +### 2. 安装依赖 + +```bash +npm install +``` + +### 3. 设置认证令牌 + +```bash +wrangler secret put AUTH_TOKEN +# 输入一个安全的随机字符串,例如:openssl rand -hex 32 +``` + +### 4. 部署 + +```bash +npm run deploy +``` + +部署成功后,Wrangler 会输出 Worker 的 URL,格式类似: +```text +https://github-stars-vectorize..workers.dev +``` + +### 5. 在 App 中配置 + +在 GitHub Stars Manager 的 **设置 → 向量搜索** 中: +- **Worker 地址**: 填入上一步的 URL +- **认证 Token**: 填入你设置的 AUTH_TOKEN 值 + +### 6. 测试连接 + +在设置页点击 **测试 Worker 连接**,看到 "连接成功" 即可。 + +--- + +## 更新部署(代码变更后) + +当你更新了 Worker 代码(例如从 GitHub 拉取了新版本),需要重新部署: + +```bash +cd cloudflare-worker + +# 如果依赖有变更(package.json 更新了) +npm install + +# 重新部署 +npm run deploy +``` + +> **注意**:更新部署**不需要**重新创建 Vectorize 索引,已有向量数据不受影响。 + +--- + +## 更换 Embedding 模型 + +> ⚠️ **更换模型后必须重建索引!** 不同模型生成的向量维度不同,混用会导致查询失败。 + +步骤: +1. 在 App 设置中更换 Embedding 模型 +2. 如果新模型的维度与旧模型不同,需要**删除旧索引并创建新索引**: + ```bash + # 删除旧索引 + npx wrangler vectorize delete github-stars + + # 创建新索引(维度与新模型一致) + npx wrangler vectorize create github-stars --dimensions=1024 --metric=cosine + ``` +3. 在 App 中点击 **重建向量索引** + +--- + +## 文件说明 + +| 文件 | 说明 | +|------|------| +| `src/index.ts` | Worker 源码(TypeScript,CLI 部署使用) | +| `worker.js` | Worker 代码(纯 JS,备用) | +| `wrangler.toml` | Wrangler 部署配置 | +| `package.json` | 依赖声明 | + +## API 接口 + +| 方法 | 路径 | 说明 | +|------|------|------| +| POST | `/upsert` | 批量写入向量 | +| POST | `/query` | 向量相似度查询 | +| POST | `/delete` | 删除指定向量 | +| POST | `/cleanup` | 清理不在 keepIds 列表中的向量 | +| GET | `/status` | 获取索引状态 | + +所有请求需要 `Authorization: Bearer ` 头。 + +## 本地开发 + +```bash +npm run dev +``` + +## 常见 Embedding 模型维度参考 + +| 模型 | 维度 | 多语言 | 价格 | +|------|------|--------|------| +| OpenAI text-embedding-3-small | **1536** | ✅ | $0.02/M | +| OpenAI text-embedding-3-large | **3072** | ✅ | $0.13/M | +| Gemini text-embedding-004 | **768** | ✅ | 免费 | +| Cohere embed-multilingual-v3.0 | **1024** | ✅ | $0.1/M | +| Ollama nomic-embed-text | **768** | ✅ | 免费 | +| Ollama bge-m3 | **1024** | ✅ | 免费 | +| 硅基流动 BAAI/bge-large-zh-v1.5 | **1024** | ✅ | ¥0.5/M | diff --git a/cloudflare-worker/package-lock.json b/cloudflare-worker/package-lock.json new file mode 100644 index 00000000..f728a393 --- /dev/null +++ b/cloudflare-worker/package-lock.json @@ -0,0 +1,1642 @@ +{ + "name": "github-stars-vectorize-worker", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "github-stars-vectorize-worker", + "version": "1.0.0", + "devDependencies": { + "@cloudflare/workers-types": "^4.20240117.0", + "typescript": "^5.5.0", + "wrangler": "^3.0.0" + } + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.3.4.tgz", + "integrity": "sha512-YLPHc8yASwjNkmcDMQMY35yiWjoKAKnhUbPRszBRS0YgH+IXtsMp61j+yTcnCE3oO2DgP0U3iejLC8FTtKDC8Q==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "mime": "^3.0.0" + }, + "engines": { + "node": ">=16.13" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.0.2.tgz", + "integrity": "sha512-nyzYnlZjjV5xT3LizahG1Iu6mnrCaxglJ04rZLpDwlDVDZ7v46lNsfxhV3A/xtfgQuSHmLnc6SVI+KwBpc3Lwg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.14", + "workerd": "^1.20250124.0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20250718.0.tgz", + "integrity": "sha512-FHf4t7zbVN8yyXgQ/r/GqLPaYZSGUVzeR7RnL28Mwj2djyw2ZergvytVc7fdGcczl6PQh+VKGfZCfUqpJlbi9g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20250718.0.tgz", + "integrity": "sha512-fUiyUJYyqqp4NqJ0YgGtp4WJh/II/YZsUnEb6vVy5Oeas8lUOxnN+ZOJ8N/6/5LQCVAtYCChRiIrBbfhTn5Z8Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20250718.0.tgz", + "integrity": "sha512-5+eb3rtJMiEwp08Kryqzzu8d1rUcK+gdE442auo5eniMpT170Dz0QxBrqkg2Z48SFUPYbj+6uknuA5tzdRSUSg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20250718.0.tgz", + "integrity": "sha512-Aa2M/DVBEBQDdATMbn217zCSFKE+ud/teS+fFS+OQqKABLn0azO2qq6ANAHYOIE6Q3Sq4CxDIQr8lGdaJHwUog==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20250718.0.tgz", + "integrity": "sha512-dY16RXKffmugnc67LTbyjdDHZn5NoTF1yHEf2fN4+OaOnoGSp3N1x77QubTDwqZ9zECWxgQfDLjddcH8dWeFhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workers-types": { + "version": "4.20260624.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260624.1.tgz", + "integrity": "sha512-o8i70Nycnm6KpPPfdjHek09IkG3hoIAv88d1pn90Djn2oXcQ35dYuzKYZUBd0eE2Tpsd5yz8L/a6FvI6CKFBQw==", + "dev": true, + "license": "MIT OR Apache-2.0" + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild-plugins/node-globals-polyfill": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@esbuild-plugins/node-globals-polyfill/-/node-globals-polyfill-0.2.3.tgz", + "integrity": "sha512-r3MIryXDeXDOZh7ih1l/yE9ZLORCd5e8vWg02azWRGj5SPTuoh69A2AIyn0Z31V/kHBfZ4HgWJ+OK3GTTwLmnw==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "esbuild": "*" + } + }, + "node_modules/@esbuild-plugins/node-modules-polyfill": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@esbuild-plugins/node-modules-polyfill/-/node-modules-polyfill-0.2.2.tgz", + "integrity": "sha512-LXV7QsWJxRuMYvKbiznh+U1ilIop3g2TeKRzUxOG5X3YITc8JyyTa90BmLwqqv0YnX4v32CSlG+vsziZp9dMvA==", + "dev": true, + "license": "ISC", + "dependencies": { + "escape-string-regexp": "^4.0.0", + "rollup-plugin-node-polyfills": "^0.2.1" + }, + "peerDependencies": { + "esbuild": "*" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.17.19.tgz", + "integrity": "sha512-rIKddzqhmav7MSmoFCmDIb6e2W57geRsM94gV2l38fzhXMwq7hZoClug9USI2pFRGL06f4IOPHHpFNOkWieR8A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.17.19.tgz", + "integrity": "sha512-KBMWvEZooR7+kzY0BtbTQn0OAYY7CsiydT63pVEaPtVYF0hXbUaOyZog37DKxK7NF3XacBJOpYT4adIJh+avxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.17.19.tgz", + "integrity": "sha512-uUTTc4xGNDT7YSArp/zbtmbhO0uEEK9/ETW29Wk1thYUJBz3IVnvgEiEwEa9IeLyvnpKrWK64Utw2bgUmDveww==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.17.19.tgz", + "integrity": "sha512-80wEoCfF/hFKM6WE1FyBHc9SfUblloAWx6FJkFWTWiCoht9Mc0ARGEM47e67W9rI09YoUxJL68WHfDRYEAvOhg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.17.19.tgz", + "integrity": "sha512-IJM4JJsLhRYr9xdtLytPLSH9k/oxR3boaUIYiHkAawtwNOXKE8KoU8tMvryogdcT8AU+Bflmh81Xn6Q0vTZbQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.17.19.tgz", + "integrity": "sha512-pBwbc7DufluUeGdjSU5Si+P3SoMF5DQ/F/UmTSb8HXO80ZEAJmrykPyzo1IfNbAoaqw48YRpv8shwd1NoI0jcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.17.19.tgz", + "integrity": "sha512-4lu+n8Wk0XlajEhbEffdy2xy53dpR06SlzvhGByyg36qJw6Kpfk7cp45DR/62aPH9mtJRmIyrXAS5UWBrJT6TQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.17.19.tgz", + "integrity": "sha512-cdmT3KxjlOQ/gZ2cjfrQOtmhG4HJs6hhvm3mWSRDPtZ/lP5oe8FWceS10JaSJC13GBd4eH/haHnqf7hhGNLerA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.17.19.tgz", + "integrity": "sha512-ct1Tg3WGwd3P+oZYqic+YZF4snNl2bsnMKRkb3ozHmnM0dGWuxcPTTntAF6bOP0Sp4x0PjSF+4uHQ1xvxfRKqg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.17.19.tgz", + "integrity": "sha512-w4IRhSy1VbsNxHRQpeGCHEmibqdTUx61Vc38APcsRbuVgK0OPEnQ0YD39Brymn96mOx48Y2laBQGqgZ0j9w6SQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.17.19.tgz", + "integrity": "sha512-2iAngUbBPMq439a+z//gE+9WBldoMp1s5GWsUSgqHLzLJ9WoZLZhpwWuym0u0u/4XmZ3gpHmzV84PonE+9IIdQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.17.19.tgz", + "integrity": "sha512-LKJltc4LVdMKHsrFe4MGNPp0hqDFA1Wpt3jE1gEyM3nKUvOiO//9PheZZHfYRfYl6AwdTH4aTcXSqBerX0ml4A==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.17.19.tgz", + "integrity": "sha512-/c/DGybs95WXNS8y3Ti/ytqETiW7EU44MEKuCAcpPto3YjQbyK3IQVKfF6nbghD7EcLUGl0NbiL5Rt5DMhn5tg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.17.19.tgz", + "integrity": "sha512-FC3nUAWhvFoutlhAkgHf8f5HwFWUL6bYdvLc/TTuxKlvLi3+pPzdZiFKSWz/PF30TB1K19SuCxDTI5KcqASJqA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.17.19.tgz", + "integrity": "sha512-IbFsFbxMWLuKEbH+7sTkKzL6NJmG2vRyy6K7JJo55w+8xDk7RElYn6xvXtDW8HCfoKBFK69f3pgBJSUSQPr+4Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.17.19.tgz", + "integrity": "sha512-68ngA9lg2H6zkZcyp22tsVt38mlhWde8l3eJLWkyLrp4HwMUr3c1s/M2t7+kHIhvMjglIBrFpncX1SzMckomGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.17.19.tgz", + "integrity": "sha512-CwFq42rXCR8TYIjIfpXCbRX0rp1jo6cPIUPSaWwzbVI4aOfX96OXY8M6KNmtPcg7QjYeDmN+DD0Wp3LaBOLf4Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.17.19.tgz", + "integrity": "sha512-cnq5brJYrSZ2CF6c35eCmviIN3k3RczmHz8eYaVlNasVqsNY+JKohZU5MKmaOI+KkllCdzOKKdPs762VCPC20g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.17.19.tgz", + "integrity": "sha512-vCRT7yP3zX+bKWFeP/zdS6SqdWB8OIpaRq/mbXQxTGHnIxspRtigpkUcDMlSCOejlHowLqII7K2JKevwyRP2rg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.17.19.tgz", + "integrity": "sha512-yYx+8jwowUstVdorcMdNlzklLYhPxjniHWFKgRqH7IFlUEa0Umu3KuYplf1HUZZ422e3NU9F4LGb+4O0Kdcaag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.17.19.tgz", + "integrity": "sha512-eggDKanJszUtCdlVs0RB+h35wNlb5v4TWEkq4vZcmVt5u/HiDZrTXe2bWFQUez3RgNHwx/x4sk5++4NSSicKkw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.17.19.tgz", + "integrity": "sha512-lAhycmKnVOuRYNtRtatQR1LPQf2oYCkRGkSFnseDAKPl8lu5SOsK/e1sXe5a0Pc5kHIHe6P2I/ilntNv2xf3cA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@fastify/busboy": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", + "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.5" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.2.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/acorn": { + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", + "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", + "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/as-table": { + "version": "1.0.55", + "resolved": "https://registry.npmjs.org/as-table/-/as-table-1.0.55.tgz", + "integrity": "sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "printable-characters": "^1.0.42" + } + }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-2.0.2.tgz", + "integrity": "sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==", + "dev": true, + "license": "MIT" + }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/esbuild": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.17.19.tgz", + "integrity": "sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.17.19", + "@esbuild/android-arm64": "0.17.19", + "@esbuild/android-x64": "0.17.19", + "@esbuild/darwin-arm64": "0.17.19", + "@esbuild/darwin-x64": "0.17.19", + "@esbuild/freebsd-arm64": "0.17.19", + "@esbuild/freebsd-x64": "0.17.19", + "@esbuild/linux-arm": "0.17.19", + "@esbuild/linux-arm64": "0.17.19", + "@esbuild/linux-ia32": "0.17.19", + "@esbuild/linux-loong64": "0.17.19", + "@esbuild/linux-mips64el": "0.17.19", + "@esbuild/linux-ppc64": "0.17.19", + "@esbuild/linux-riscv64": "0.17.19", + "@esbuild/linux-s390x": "0.17.19", + "@esbuild/linux-x64": "0.17.19", + "@esbuild/netbsd-x64": "0.17.19", + "@esbuild/openbsd-x64": "0.17.19", + "@esbuild/sunos-x64": "0.17.19", + "@esbuild/win32-arm64": "0.17.19", + "@esbuild/win32-ia32": "0.17.19", + "@esbuild/win32-x64": "0.17.19" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-walker": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-0.6.1.tgz", + "integrity": "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/exit-hook": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-2.2.1.tgz", + "integrity": "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/exsolve": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.0.tgz", + "integrity": "sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-source": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/get-source/-/get-source-2.0.12.tgz", + "integrity": "sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w==", + "dev": true, + "license": "Unlicense", + "dependencies": { + "data-uri-to-buffer": "^2.0.0", + "source-map": "^0.6.1" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/magic-string": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", + "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "sourcemap-codec": "^1.4.8" + } + }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/miniflare": { + "version": "3.20250718.3", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-3.20250718.3.tgz", + "integrity": "sha512-JuPrDJhwLrNLEJiNLWO7ZzJrv/Vv9kZuwMYCfv0LskQDM6Eonw4OvywO3CH/wCGjgHzha/qyjUh8JQ068TjDgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "acorn": "8.14.0", + "acorn-walk": "8.3.2", + "exit-hook": "2.2.1", + "glob-to-regexp": "0.4.1", + "stoppable": "1.1.0", + "undici": "^5.28.5", + "workerd": "1.20250718.0", + "ws": "8.18.0", + "youch": "3.3.4", + "zod": "3.22.3" + }, + "bin": { + "miniflare": "bootstrap.js" + }, + "engines": { + "node": ">=16.13" + } + }, + "node_modules/mustache": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", + "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", + "dev": true, + "license": "MIT", + "bin": { + "mustache": "bin/mustache" + } + }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/printable-characters": { + "version": "1.0.42", + "resolved": "https://registry.npmjs.org/printable-characters/-/printable-characters-1.0.42.tgz", + "integrity": "sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==", + "dev": true, + "license": "Unlicense" + }, + "node_modules/rollup-plugin-inject": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rollup-plugin-inject/-/rollup-plugin-inject-3.0.2.tgz", + "integrity": "sha512-ptg9PQwzs3orn4jkgXJ74bfs5vYz1NCZlSQMBUA0wKcGp5i5pA1AO3fOUEte8enhGUC+iapTCzEWw2jEFFUO/w==", + "deprecated": "This package has been deprecated and is no longer maintained. Please use @rollup/plugin-inject.", + "dev": true, + "license": "MIT", + "dependencies": { + "estree-walker": "^0.6.1", + "magic-string": "^0.25.3", + "rollup-pluginutils": "^2.8.1" + } + }, + "node_modules/rollup-plugin-node-polyfills": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/rollup-plugin-node-polyfills/-/rollup-plugin-node-polyfills-0.2.1.tgz", + "integrity": "sha512-4kCrKPTJ6sK4/gLL/U5QzVT8cxJcofO0OU74tnB19F40cmuAKSzH5/siithxlofFEjwvw1YAhPmbvGNA6jEroA==", + "dev": true, + "license": "MIT", + "dependencies": { + "rollup-plugin-inject": "^3.0.0" + } + }, + "node_modules/rollup-pluginutils": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/rollup-pluginutils/-/rollup-pluginutils-2.8.2.tgz", + "integrity": "sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "estree-walker": "^0.6.1" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.3", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.5", + "@img/sharp-darwin-x64": "0.33.5", + "@img/sharp-libvips-darwin-arm64": "1.0.4", + "@img/sharp-libvips-darwin-x64": "1.0.4", + "@img/sharp-libvips-linux-arm": "1.0.5", + "@img/sharp-libvips-linux-arm64": "1.0.4", + "@img/sharp-libvips-linux-s390x": "1.0.4", + "@img/sharp-libvips-linux-x64": "1.0.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", + "@img/sharp-linux-arm": "0.33.5", + "@img/sharp-linux-arm64": "0.33.5", + "@img/sharp-linux-s390x": "0.33.5", + "@img/sharp-linux-x64": "0.33.5", + "@img/sharp-linuxmusl-arm64": "0.33.5", + "@img/sharp-linuxmusl-x64": "0.33.5", + "@img/sharp-wasm32": "0.33.5", + "@img/sharp-win32-ia32": "0.33.5", + "@img/sharp-win32-x64": "0.33.5" + } + }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sourcemap-codec": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", + "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", + "deprecated": "Please use @jridgewell/sourcemap-codec instead", + "dev": true, + "license": "MIT" + }, + "node_modules/stacktracey": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/stacktracey/-/stacktracey-2.2.0.tgz", + "integrity": "sha512-ETyQEz+CzXiLjEbyJqpbp+/T79RQD/6wqFucRBIlVNZfYq2Ay7wbretD4cxpbymZlaPWx58aIhPEY1Cr8DlVvg==", + "dev": true, + "license": "Unlicense", + "dependencies": { + "as-table": "^1.0.36", + "get-source": "^2.0.12" + } + }, + "node_modules/stoppable": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stoppable/-/stoppable-1.1.0.tgz", + "integrity": "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4", + "npm": ">=6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici": { + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", + "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, + "engines": { + "node": ">=14.0" + } + }, + "node_modules/unenv": { + "version": "2.0.0-rc.14", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.14.tgz", + "integrity": "sha512-od496pShMen7nOy5VmVJCnq8rptd45vh6Nx/r2iPbrba6pa6p+tS2ywuIHRZ/OBvSbQZB0kWvpO9XBNVFXHD3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "defu": "^6.1.4", + "exsolve": "^1.0.1", + "ohash": "^2.0.10", + "pathe": "^2.0.3", + "ufo": "^1.5.4" + } + }, + "node_modules/workerd": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20250718.0.tgz", + "integrity": "sha512-kqkIJP/eOfDlUyBzU7joBg+tl8aB25gEAGqDap+nFWb+WHhnooxjGHgxPBy3ipw2hnShPFNOQt5lFRxbwALirg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20250718.0", + "@cloudflare/workerd-darwin-arm64": "1.20250718.0", + "@cloudflare/workerd-linux-64": "1.20250718.0", + "@cloudflare/workerd-linux-arm64": "1.20250718.0", + "@cloudflare/workerd-windows-64": "1.20250718.0" + } + }, + "node_modules/wrangler": { + "version": "3.114.17", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-3.114.17.tgz", + "integrity": "sha512-tAvf7ly+tB+zwwrmjsCyJ2pJnnc7SZhbnNwXbH+OIdVas3zTSmjcZOjmLKcGGptssAA3RyTKhcF9BvKZzMUycA==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.3.4", + "@cloudflare/unenv-preset": "2.0.2", + "@esbuild-plugins/node-globals-polyfill": "0.2.3", + "@esbuild-plugins/node-modules-polyfill": "0.2.2", + "blake3-wasm": "2.1.5", + "esbuild": "0.17.19", + "miniflare": "3.20250718.3", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.14", + "workerd": "1.20250718.0" + }, + "bin": { + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=16.17.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2", + "sharp": "^0.33.5" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^4.20250408.0" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/youch": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/youch/-/youch-3.3.4.tgz", + "integrity": "sha512-UeVBXie8cA35DS6+nBkls68xaBBXCye0CNznrhszZjTbRVnJKQuNsyLKBTTL4ln1o1rh2PKtv35twV7irj5SEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie": "^0.7.1", + "mustache": "^4.2.0", + "stacktracey": "^2.1.8" + } + }, + "node_modules/zod": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.3.tgz", + "integrity": "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/cloudflare-worker/package.json b/cloudflare-worker/package.json new file mode 100644 index 00000000..b1d5a010 --- /dev/null +++ b/cloudflare-worker/package.json @@ -0,0 +1,14 @@ +{ + "name": "github-stars-vectorize-worker", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20240117.0", + "typescript": "^5.5.0", + "wrangler": "^3.71.0" + } +} diff --git a/cloudflare-worker/src/index.ts b/cloudflare-worker/src/index.ts new file mode 100644 index 00000000..55b687ec --- /dev/null +++ b/cloudflare-worker/src/index.ts @@ -0,0 +1,144 @@ +/** + * GitHub Stars Vectorize — 极简代理 Worker + * + * 纯 Vectorize 存/查/删代理,不持有任何 AI Key。 + * 前端负责 Embedding 生成,Worker 只负责向量存储和检索。 + */ + +interface Env { + VECTORIZE: Vectorize; + AUTH_TOKEN: string; +} + +interface QueryRequest { + vector: number[]; + topK?: number; + threshold?: number; +} + +interface DeleteRequest { + ids: string[]; +} + +interface UpsertRequest { + vectors: VectorizeVector[]; +} + +const CORS_HEADERS: Record = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', +}; + +function jsonResponse(data: unknown, status = 200): Response { + return Response.json(data, { + status, + headers: CORS_HEADERS, + }); +} + +export default { + async fetch(request: Request, env: Env): Promise { + // CORS 预检 + if (request.method === 'OPTIONS') { + return new Response(null, { headers: CORS_HEADERS }); + } + + // 认证 + if (!env.AUTH_TOKEN) { + return jsonResponse({ success: false, error: 'Server auth not configured' }, 500); + } + const token = request.headers.get('Authorization')?.replace('Bearer ', '') ?? ''; + if (token !== env.AUTH_TOKEN) { + return jsonResponse({ success: false, error: 'Unauthorized' }, 401); + } + + const url = new URL(request.url); + + try { + // POST /upsert — 批量写入向量 + if (request.method === 'POST' && url.pathname === '/upsert') { + const { vectors } = (await request.json()) as UpsertRequest; + if (!Array.isArray(vectors) || vectors.length === 0) { + return jsonResponse({ success: false, error: 'vectors array required' }, 400); + } + await env.VECTORIZE.upsert(vectors); + return jsonResponse({ success: true, upserted: vectors.length }); + } + + // POST /query — 向量相似度查询 + if (request.method === 'POST' && url.pathname === '/query') { + const { vector, topK = 20, threshold = 0.3 } = (await request.json()) as QueryRequest; + if (!Array.isArray(vector) || vector.length === 0) { + return jsonResponse({ success: false, error: 'vector array required' }, 400); + } + // returnMetadata:'all' caps topK at 50; clamp to avoid silent truncation + const clampedTopK = Math.min(topK, 50); + const matches = await env.VECTORIZE.query(vector, { + topK: clampedTopK, + returnMetadata: 'all' as const, + }); + // 过滤低分结果 + const filtered = matches.matches.filter((m) => m.score >= threshold); + return jsonResponse({ success: true, matches: filtered }); + } + + // POST /delete — 删除指定向量 + if (request.method === 'POST' && url.pathname === '/delete') { + const { ids } = (await request.json()) as DeleteRequest; + if (!Array.isArray(ids) || ids.length === 0) { + return jsonResponse({ success: false, error: 'ids array required' }, 400); + } + await env.VECTORIZE.deleteByIds(ids); + return jsonResponse({ success: true, deleted: ids.length }); + } + + // POST /cleanup — 删除不在 keepIds 列表中的向量(清理已 unstar 的仓库) + if (request.method === 'POST' && url.pathname === '/cleanup') { + const { keepIds } = (await request.json()) as { keepIds: string[] }; + if (!Array.isArray(keepIds)) { + return jsonResponse({ success: false, error: 'keepIds array required' }, 400); + } + const keepSet = new Set(keepIds); + const info = await env.VECTORIZE.describe(); + if ((info.vectorCount ?? 0) === 0) { + return jsonResponse({ success: true, deleted: 0 }); + } + // 使用 query + 零向量采样,循环删除不在 keepSet 中的向量 + // Vectorize binding 不支持 listVectors,topK 上限 100 + const dimensions = info.dimensions ?? 1536; + let totalDeleted = 0; + const zeroVector = new Array(dimensions).fill(0); + // 最多迭代 10 轮(覆盖最多 1000 个向量) + for (let round = 0; round < 10; round++) { + const result = await env.VECTORIZE.query(zeroVector, { + topK: 100, + returnMetadata: false, + }); + const staleIds = result.matches + .filter((m) => !keepSet.has(m.id)) + .map((m) => m.id); + if (staleIds.length === 0) break; + await env.VECTORIZE.deleteByIds(staleIds); + totalDeleted += staleIds.length; + } + return jsonResponse({ success: true, deleted: totalDeleted }); + } + + // GET /status — 返回索引信息 + if (request.method === 'GET' && url.pathname === '/status') { + const info = await env.VECTORIZE.describe(); + return jsonResponse({ + success: true, + vectorCount: info.vectorCount ?? 0, + dimensions: info.dimensions ?? 0, + }); + } + + return jsonResponse({ error: 'Not Found' }, 404); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + return jsonResponse({ success: false, error: message }, 500); + } + }, +}; diff --git a/cloudflare-worker/tsconfig.json b/cloudflare-worker/tsconfig.json new file mode 100644 index 00000000..7d5d300f --- /dev/null +++ b/cloudflare-worker/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "types": ["@cloudflare/workers-types"], + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*.ts"] +} diff --git a/cloudflare-worker/worker.js b/cloudflare-worker/worker.js new file mode 100644 index 00000000..79e8922b --- /dev/null +++ b/cloudflare-worker/worker.js @@ -0,0 +1,127 @@ +/** + * GitHub Stars Vectorize — 极简代理 Worker + * + * 纯 Vectorize 存/查/删代理,不持有任何 AI Key。 + * 前端负责 Embedding 生成,Worker 只负责向量存储和检索。 + * + * ── 部署方式 ── + * 方式一:wrangler deploy(CLI) + * 方式二:Cloudflare Dashboard → Workers & Pages → 创建 → 粘贴本文件内容 + */ + +const CORS_HEADERS = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', +}; + +function jsonResponse(data, status = 200) { + return Response.json(data, { + status, + headers: CORS_HEADERS, + }); +} + +export default { + async fetch(request, env) { + // CORS 预检 + if (request.method === 'OPTIONS') { + return new Response(null, { headers: CORS_HEADERS }); + } + + // 认证 + if (!env.AUTH_TOKEN) { + return jsonResponse({ success: false, error: 'Server auth not configured' }, 500); + } + const token = request.headers.get('Authorization')?.replace('Bearer ', '') ?? ''; + if (token !== env.AUTH_TOKEN) { + return jsonResponse({ success: false, error: 'Unauthorized' }, 401); + } + + const url = new URL(request.url); + + try { + // POST /upsert — 批量写入向量 + if (request.method === 'POST' && url.pathname === '/upsert') { + const { vectors } = await request.json(); + if (!Array.isArray(vectors) || vectors.length === 0) { + return jsonResponse({ success: false, error: 'vectors array required' }, 400); + } + await env.VECTORIZE.upsert(vectors); + return jsonResponse({ success: true, upserted: vectors.length }); + } + + // POST /query — 向量相似度查询 + if (request.method === 'POST' && url.pathname === '/query') { + const { vector, topK = 20, threshold = 0.3 } = await request.json(); + if (!Array.isArray(vector) || vector.length === 0) { + return jsonResponse({ success: false, error: 'vector array required' }, 400); + } + // returnMetadata:'all' caps topK at 50; clamp to avoid silent truncation + const clampedTopK = Math.min(topK, 50); + const matches = await env.VECTORIZE.query(vector, { + topK: clampedTopK, + returnMetadata: 'all', + }); + // 过滤低分结果 + const filtered = matches.matches.filter((m) => m.score >= threshold); + return jsonResponse({ success: true, matches: filtered }); + } + + // POST /delete — 删除指定向量 + if (request.method === 'POST' && url.pathname === '/delete') { + const { ids } = await request.json(); + if (!Array.isArray(ids) || ids.length === 0) { + return jsonResponse({ success: false, error: 'ids array required' }, 400); + } + await env.VECTORIZE.deleteByIds(ids); + return jsonResponse({ success: true, deleted: ids.length }); + } + + // POST /cleanup — 删除不在 keepIds 列表中的向量(清理已 unstar 的仓库) + if (request.method === 'POST' && url.pathname === '/cleanup') { + const { keepIds } = await request.json(); + if (!Array.isArray(keepIds)) { + return jsonResponse({ success: false, error: 'keepIds array required' }, 400); + } + const keepSet = new Set(keepIds); + const info = await env.VECTORIZE.describe(); + if ((info.vectorCount ?? 0) === 0) { + return jsonResponse({ success: true, deleted: 0 }); + } + // 使用 query + 零向量采样,循环删除不在 keepSet 中的向量 + const dimensions = info.dimensions ?? 1536; + let totalDeleted = 0; + const zeroVector = new Array(dimensions).fill(0); + for (let round = 0; round < 10; round++) { + const result = await env.VECTORIZE.query(zeroVector, { + topK: 100, + returnMetadata: false, + }); + const staleIds = result.matches + .filter((m) => !keepSet.has(m.id)) + .map((m) => m.id); + if (staleIds.length === 0) break; + await env.VECTORIZE.deleteByIds(staleIds); + totalDeleted += staleIds.length; + } + return jsonResponse({ success: true, deleted: totalDeleted }); + } + + // GET /status — 返回索引信息 + if (request.method === 'GET' && url.pathname === '/status') { + const info = await env.VECTORIZE.describe(); + return jsonResponse({ + success: true, + vectorCount: info.vectorCount ?? 0, + dimensions: info.dimensions ?? 0, + }); + } + + return jsonResponse({ error: 'Not Found' }, 404); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return jsonResponse({ success: false, error: message }, 500); + } + }, +}; diff --git a/cloudflare-worker/wrangler.toml b/cloudflare-worker/wrangler.toml new file mode 100644 index 00000000..79a78cf0 --- /dev/null +++ b/cloudflare-worker/wrangler.toml @@ -0,0 +1,9 @@ +name = "github-stars-vectorize" +main = "src/index.ts" +compatibility_date = "2024-01-01" + +# AUTH_TOKEN 通过 wrangler secret put AUTH_TOKEN 设置,不要写在 [vars] 中 + +[[vectorize]] +binding = "VECTORIZE" +index_name = "github-stars" diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 7b7a01d1..f5bfdc8d 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -107,6 +107,33 @@ export function initializeSchema(db: Database.Database): void { key TEXT PRIMARY KEY, value TEXT ); + + CREATE TABLE IF NOT EXISTS embedding_configs ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + api_type TEXT NOT NULL DEFAULT 'openai', + base_url TEXT NOT NULL DEFAULT '', + api_key_encrypted TEXT NOT NULL DEFAULT '', + model TEXT NOT NULL DEFAULT '', + dimensions INTEGER NOT NULL DEFAULT 1536, + is_active INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS vector_search_configs ( + id TEXT PRIMARY KEY DEFAULT 'default', + enabled INTEGER NOT NULL DEFAULT 0, + worker_url TEXT NOT NULL DEFAULT '', + auth_token_encrypted TEXT NOT NULL DEFAULT '', + embedding_config_id TEXT, + index_mode TEXT NOT NULL DEFAULT 'readme', + readme_max_chars INTEGER NOT NULL DEFAULT 6000, + status_json TEXT, + last_sync_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); `); addColumnIfMissing(db, 'ai_configs', 'reasoning_effort', 'TEXT'); @@ -120,4 +147,6 @@ export function initializeSchema(db: Database.Database): void { addColumnIfMissing(db, 'asset_filters', 'description', 'TEXT'); addColumnIfMissing(db, 'asset_filters', 'platform', 'TEXT'); addColumnIfMissing(db, 'asset_filters', 'sort_order', 'INTEGER DEFAULT 0'); + addColumnIfMissing(db, 'vector_search_configs', 'index_mode', "TEXT NOT NULL DEFAULT 'readme'"); + addColumnIfMissing(db, 'vector_search_configs', 'readme_max_chars', 'INTEGER NOT NULL DEFAULT 6000'); } diff --git a/server/src/routes/configs.ts b/server/src/routes/configs.ts index 9f2c7c07..22ecb417 100644 --- a/server/src/routes/configs.ts +++ b/server/src/routes/configs.ts @@ -1,3 +1,4 @@ +import { randomUUID } from 'crypto'; import { Router } from 'express'; import { getDb } from '../db/connection.js'; import { encrypt, decrypt } from '../services/crypto.js'; @@ -32,7 +33,7 @@ function getMaskedSecretResult(params: { } } -// ── AI Configs ── +// ── Helpers ── function maskApiKey(key: string | null | undefined): string { if (!key || typeof key !== 'string') return ''; @@ -40,6 +41,172 @@ function maskApiKey(key: string | null | undefined): string { return '***' + key.slice(-4); } +/** + * 为加密配置表注册 bulk sync / single update / delete 路由 + * 消除 AI configs 和 embedding configs 之间的重复代码 + */ +function registerEncryptedConfigRoutes(opts: { + router: ReturnType; + basePath: string; // e.g. '/api/configs/ai' + table: string; // e.g. 'ai_configs' + secretColumn: string; // e.g. 'api_key_encrypted' + label: string; // e.g. 'AI config' (for error messages) + logPrefix: string; // e.g. 'configs.ai' + insertSql: string; // full INSERT statement with ? placeholders + updateSql: string; // full UPDATE statement with ? placeholders (last ? is id) + /** Extract ordered bind params from a config body item for INSERT */ + insertParams: (c: Record, encryptedKey: string) => unknown[]; + /** Extract ordered bind params from a req.body for UPDATE (last element must be id) */ + updateParams: (body: Record, id: string, encryptedKey: string) => unknown[]; + /** Shape the response object for a single config */ + shapeResponse: (c: Record, id: string | number, maskedKey: string) => Record; + /** If false, configs without a secret are allowed (e.g. Ollama). Default true. */ + requiresSecret?: boolean; +}): void { + const { router, basePath, table, secretColumn, label, logPrefix, insertSql, updateSql, insertParams, updateParams, shapeResponse, requiresSecret = true } = opts; + + // PUT /bulk — replace all configs (for sync) + router.put(`${basePath}/bulk`, (req, res) => { + const syncResult = { inserted: 0, skipped: [] as Array<{ id: string; name: string; reason: string }> }; + + try { + const db = getDb(); + const configs = req.body.configs as Array>; + + if (!Array.isArray(configs)) { + res.status(400).json({ error: 'configs array required', code: 'INVALID_REQUEST' }); + return; + } + + const bulkSync = db.transaction(() => { + const existingKeys = new Map(); + const existingRows = db.prepare(`SELECT id, ${secretColumn} FROM ${table}`).all() as Array<{ id: string; [key: string]: string }>; + for (const row of existingRows) { + if (row[secretColumn]) existingKeys.set(String(row.id), row[secretColumn]); + } + + db.prepare(`DELETE FROM ${table}`).run(); + const stmt = db.prepare(insertSql); + + for (const c of configs) { + let encryptedKey = ''; + const rawKey = c.apiKey ?? c.password; + if (rawKey && typeof rawKey === 'string' && !rawKey.startsWith('***')) { + try { + encryptedKey = encrypt(String(rawKey), config.encryptionKey); + } catch (encErr) { + logger.errorFromError(`${logPrefix}.encrypt`, `Failed to encrypt secret for ${label}`, encErr, { configId: c.id, configName: c.name }); + encryptedKey = existingKeys.get(String(c.id)) ?? ''; + if (!encryptedKey) { + syncResult.skipped.push({ id: String(c.id), name: String(c.name ?? ''), reason: 'encrypt_failed' }); + continue; + } + } + } else if (rawKey === '') { + // Explicit empty string = user wants to clear the secret + encryptedKey = ''; + } else { + // Omitted or masked = reuse existing + encryptedKey = existingKeys.get(String(c.id)) ?? ''; + } + + if (!encryptedKey && requiresSecret) { + syncResult.skipped.push({ + id: String(c.id), + name: String(c.name ?? ''), + reason: (typeof rawKey === 'string' && rawKey.startsWith('***')) + ? 'Secret is masked and no existing key found' + : 'Secret is empty', + }); + continue; + } + + stmt.run(...insertParams(c, encryptedKey)); + syncResult.inserted++; + } + + // Rollback if any config was skipped (prevents partial replacement) + if (syncResult.skipped.length > 0) { + throw new Error('SOME_CONFIGS_SKIPPED'); + } + }); + + bulkSync(); + res.json({ synced: syncResult.inserted, skipped: 0, errors: [] }); + } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err); + logger.errorFromError(`${logPrefix}.bulk`, `PUT ${basePath}/bulk error`, err); + if (errMsg === 'SOME_CONFIGS_SKIPPED') { + res.status(422).json({ + error: `Some ${label} configs were skipped — check the errors field for per-config reasons`, + code: `SYNC_${logPrefix.toUpperCase().replace(/\./g, '_')}_PARTIAL_SKIP`, + synced: 0, + skipped: syncResult.skipped.length, + errors: syncResult.skipped, + }); + } else { + res.status(500).json({ error: `Failed to sync ${label} configs`, code: `SYNC_${logPrefix.toUpperCase().replace(/\./g, '_')}_FAILED` }); + } + } + }); + + // PUT /:id — update single config + router.put(`${basePath}/:id`, (req, res) => { + try { + const db = getDb(); + const id = req.params.id; + const body = req.body as Record; + const rawKey = body.apiKey ?? body.password; + + let encryptedKey: string | null = null; + if (rawKey && typeof rawKey === 'string' && !rawKey.startsWith('***')) { + encryptedKey = encrypt(rawKey, config.encryptionKey); + } else if (rawKey === '') { + // Explicit empty string = user wants to clear the secret + encryptedKey = ''; + } else { + // Omitted or masked = reuse existing + const existing = db.prepare(`SELECT ${secretColumn} FROM ${table} WHERE id = ?`).get(id) as Record | undefined; + encryptedKey = (existing?.[secretColumn] as string) ?? null; + } + + const result = db.prepare(updateSql).run(...updateParams(body, id, encryptedKey ?? '')); + + if (result.changes === 0) { + res.status(404).json({ error: `${label} not found`, code: `${logPrefix.toUpperCase().replace(/\./g, '_')}_NOT_FOUND` }); + return; + } + + let maskedKey = ''; + if (encryptedKey) { + try { maskedKey = maskApiKey(decrypt(encryptedKey, config.encryptionKey)); } catch { maskedKey = '****'; } + } + + res.json(shapeResponse(body, id, maskedKey)); + } catch (err) { + logger.errorFromError(`${logPrefix}.update`, `PUT ${basePath}/:id error`, err); + res.status(500).json({ error: `Failed to update ${label}`, code: `UPDATE_${logPrefix.toUpperCase().replace(/\./g, '_')}_FAILED` }); + } + }); + + // DELETE /:id + router.delete(`${basePath}/:id`, (req, res) => { + try { + const db = getDb(); + const id = req.params.id; + const result = db.prepare(`DELETE FROM ${table} WHERE id = ?`).run(id); + if (result.changes === 0) { + res.status(404).json({ error: `${label} not found`, code: `${logPrefix.toUpperCase().replace(/\./g, '_')}_NOT_FOUND` }); + return; + } + res.json({ deleted: true }); + } catch (err) { + logger.errorFromError(`${logPrefix}.delete`, `DELETE ${basePath}/:id error`, err); + res.status(500).json({ error: `Failed to delete ${label}`, code: `DELETE_${logPrefix.toUpperCase().replace(/\./g, '_')}_FAILED` }); + } + }); +} + // GET /api/configs/ai router.get('/api/configs/ai', (req, res) => { try { @@ -99,164 +266,31 @@ router.post('/api/configs/ai', (req, res) => { } }); -// PUT /api/configs/ai/bulk — replace all AI configs (for sync) -// MUST be registered before :id route to avoid matching 'bulk' as an id -router.put('/api/configs/ai/bulk', (req, res) => { - // Shared between transaction, response, and error handler - const syncResult = { inserted: 0, skipped: [] as Array<{ id: string; name: string; reason: string }> }; - - try { - const db = getDb(); - const configs = req.body.configs as Array<{ - id: string; - name: string; - apiType?: string; - baseUrl: string; - apiKey: string; - model: string; - isActive: boolean; - customPrompt?: string; - useCustomPrompt?: boolean; - concurrency?: number; - reasoningEffort?: string; - mimoPlan?: string; - }>; - - if (!Array.isArray(configs)) { - res.status(400).json({ error: 'configs array required', code: 'INVALID_REQUEST' }); - return; - } - - const bulkSync = db.transaction(() => { - const existingKeys = new Map(); - const existingRows = db.prepare('SELECT id, api_key_encrypted FROM ai_configs').all() as Array<{ id: string; api_key_encrypted: string }>; - for (const row of existingRows) { - if (row.api_key_encrypted) existingKeys.set(String(row.id), row.api_key_encrypted); - } - - db.prepare('DELETE FROM ai_configs').run(); - - const stmt = db.prepare(` - INSERT INTO ai_configs (id, name, api_type, base_url, api_key_encrypted, model, is_active, custom_prompt, use_custom_prompt, concurrency, reasoning_effort, mimo_plan) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `); - - for (const c of configs) { - let encryptedKey = ''; - if (c.apiKey && !c.apiKey.startsWith('***')) { - try { - encryptedKey = encrypt(String(c.apiKey), config.encryptionKey); - } catch (encErr) { - logger.errorFromError('configs.encryptAIKey', 'Failed to encrypt API key for config', encErr, { configId: c.id, configName: c.name }); - encryptedKey = existingKeys.get(String(c.id)) ?? ''; - if (!encryptedKey) { - syncResult.skipped.push({ id: c.id, name: c.name ?? '', reason: 'encrypt_failed' }); - continue; - } - } - } else { - encryptedKey = existingKeys.get(String(c.id)) ?? ''; - } - - if (!encryptedKey) { - syncResult.skipped.push({ - id: c.id, - name: c.name ?? '', - reason: c.apiKey?.startsWith('***') - ? 'API key is masked and no existing key found' - : 'API key is empty', - }); - continue; - } - - stmt.run( - c.id, c.name ?? '', c.apiType ?? 'openai', c.baseUrl ?? '', - encryptedKey, c.model ?? '', c.isActive ? 1 : 0, - c.customPrompt ?? null, c.useCustomPrompt ? 1 : 0, c.concurrency ?? 1, c.reasoningEffort ?? null, c.mimoPlan ?? null - ); - syncResult.inserted++; - } - - if (syncResult.skipped.length > 0) { - logger.warn('configs.bulkAI', 'Skipped AI configs with missing keys', { skippedCount: syncResult.skipped.length, skipped: syncResult.skipped }); - } - - // Safety guard: prevent committing an empty database when all configs were skipped - if (syncResult.inserted === 0 && configs.length > 0) { - throw new Error('ALL_CONFIGS_SKIPPED'); - } - }); - - bulkSync(); - res.json({ synced: syncResult.inserted, skipped: syncResult.skipped.length, errors: syncResult.skipped }); - } catch (err) { - const errMsg = err instanceof Error ? err.message : String(err); - logger.errorFromError('configs.bulkAI', 'PUT /api/configs/ai/bulk error', err); - if (errMsg === 'ALL_CONFIGS_SKIPPED') { - res.status(422).json({ - error: 'All AI configs were skipped — check the errors field for per-config reasons', - code: 'SYNC_AI_CONFIGS_ALL_SKIPPED', - synced: 0, - skipped: syncResult.skipped.length, - errors: syncResult.skipped, - }); - } else { - res.status(500).json({ error: 'Failed to sync AI configs', code: 'SYNC_AI_CONFIGS_FAILED' }); - } - } -}); - -// PUT /api/configs/ai/:id -router.put('/api/configs/ai/:id', (req, res) => { - try { - const db = getDb(); - const id = req.params.id; - const { name, apiType, model, baseUrl, apiKey, isActive, customPrompt, useCustomPrompt, concurrency, reasoningEffort, mimoPlan } = req.body as Record; - - let encryptedKey: string | null = null; - if (apiKey && typeof apiKey === 'string' && !apiKey.startsWith('***')) { - encryptedKey = encrypt(apiKey, config.encryptionKey); - } else { - // Keep existing encrypted key - const existing = db.prepare('SELECT api_key_encrypted FROM ai_configs WHERE id = ?').get(id) as Record | undefined; - encryptedKey = (existing?.api_key_encrypted as string) ?? null; - } - - const result = db.prepare( - 'UPDATE ai_configs SET name = ?, api_type = ?, model = ?, base_url = ?, api_key_encrypted = ?, is_active = ?, custom_prompt = ?, use_custom_prompt = ?, concurrency = ?, reasoning_effort = ?, mimo_plan = ? WHERE id = ?' - ).run(name ?? '', apiType ?? 'openai', model ?? '', baseUrl ?? null, encryptedKey, isActive ? 1 : 0, customPrompt ?? null, useCustomPrompt ? 1 : 0, concurrency ?? 1, reasoningEffort ?? null, mimoPlan ?? null, id); - - if (result.changes === 0) { - res.status(404).json({ error: 'AI config not found', code: 'AI_CONFIG_NOT_FOUND' }); - return; - } - let maskedKey = ''; - if (encryptedKey) { - try { maskedKey = maskApiKey(decrypt(encryptedKey, config.encryptionKey)); } catch { maskedKey = '****'; } - } - - res.json({ id, name, apiType, model, baseUrl, apiKey: maskedKey, isActive: !!isActive, reasoningEffort: reasoningEffort ?? null, mimoPlan: mimoPlan ?? null }); - } catch (err) { - logger.errorFromError('configs.updateAI', 'PUT /api/configs/ai error', err); - res.status(500).json({ error: 'Failed to update AI config', code: 'UPDATE_AI_CONFIG_FAILED' }); - } -}); - -// DELETE /api/configs/ai/:id -router.delete('/api/configs/ai/:id', (req, res) => { - try { - const db = getDb(); - const id = req.params.id; - const result = db.prepare('DELETE FROM ai_configs WHERE id = ?').run(id); - if (result.changes === 0) { - res.status(404).json({ error: 'AI config not found', code: 'AI_CONFIG_NOT_FOUND' }); - return; - } - res.json({ deleted: true }); - } catch (err) { - logger.errorFromError('configs.deleteAI', 'DELETE /api/configs/ai error', err); - res.status(500).json({ error: 'Failed to delete AI config', code: 'DELETE_AI_CONFIG_FAILED' }); - } +// AI config bulk/update/delete — delegated to shared factory +registerEncryptedConfigRoutes({ + router, + basePath: '/api/configs/ai', + table: 'ai_configs', + secretColumn: 'api_key_encrypted', + label: 'AI config', + logPrefix: 'configs.ai', + insertSql: `INSERT INTO ai_configs (id, name, api_type, base_url, api_key_encrypted, model, is_active, custom_prompt, use_custom_prompt, concurrency, reasoning_effort, mimo_plan) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + updateSql: `UPDATE ai_configs SET name = ?, api_type = ?, model = ?, base_url = ?, api_key_encrypted = ?, is_active = ?, custom_prompt = ?, use_custom_prompt = ?, concurrency = ?, reasoning_effort = ?, mimo_plan = ? WHERE id = ?`, + insertParams: (c, ek) => [ + c.id, c.name ?? '', c.apiType ?? 'openai', c.baseUrl ?? '', + ek, c.model ?? '', c.isActive ? 1 : 0, + c.customPrompt ?? null, c.useCustomPrompt ? 1 : 0, c.concurrency ?? 1, c.reasoningEffort ?? null, c.mimoPlan ?? null, + ], + updateParams: (b, id, ek) => [ + b.name ?? '', b.apiType ?? 'openai', b.model ?? '', b.baseUrl ?? null, + ek, b.isActive ? 1 : 0, b.customPrompt ?? null, b.useCustomPrompt ? 1 : 0, + b.concurrency ?? 1, b.reasoningEffort ?? null, b.mimoPlan ?? null, id, + ], + shapeResponse: (c, id, maskedKey) => ({ + id, name: c.name, apiType: c.apiType, model: c.model, baseUrl: c.baseUrl, + apiKey: maskedKey, isActive: !!c.isActive, + reasoningEffort: c.reasoningEffort ?? null, mimoPlan: c.mimoPlan ?? null, + }), }); // ── WebDAV Configs ── @@ -548,4 +582,176 @@ router.put('/api/settings', (req, res) => { } }); +// ── Embedding Configs ── + +// GET /api/configs/embedding +router.get('/api/configs/embedding', (req, res) => { + try { + const db = getDb(); + const shouldDecrypt = req.query.decrypt === 'true'; + const rows = db.prepare('SELECT * FROM embedding_configs ORDER BY id ASC').all() as Record[]; + const configs = rows.map((row) => { + const { decryptedValue, status } = getMaskedSecretResult({ + encryptedValue: row.api_key_encrypted, + encryptionKey: config.encryptionKey, + kind: 'AI API key', + configId: row.id, + configName: row.name, + }); + return { + id: row.id, + name: row.name, + apiType: row.api_type, + baseUrl: row.base_url, + apiKey: shouldDecrypt ? decryptedValue : maskApiKey(decryptedValue), + apiKeyStatus: status, + model: row.model, + dimensions: row.dimensions, + isActive: !!row.is_active, + }; + }); + res.json(configs); + } catch (err) { + logger.errorFromError('configs.getEmbedding', 'GET /api/configs/embedding error', err); + res.status(500).json({ error: 'Failed to fetch embedding configs', code: 'FETCH_EMBEDDING_CONFIGS_FAILED' }); + } +}); + +// POST /api/configs/embedding +router.post('/api/configs/embedding', (req, res) => { + try { + const db = getDb(); + const { name, apiType, baseUrl, apiKey, model, dimensions, isActive } = req.body as Record; + + const id = typeof req.body.id === 'string' && req.body.id ? req.body.id : randomUUID(); + const encryptedKey = apiKey && typeof apiKey === 'string' ? encrypt(apiKey, config.encryptionKey) : ''; + + db.prepare( + 'INSERT INTO embedding_configs (id, name, api_type, base_url, api_key_encrypted, model, dimensions, is_active) VALUES (?, ?, ?, ?, ?, ?, ?, ?)' + ).run(id, name ?? '', apiType ?? 'openai', baseUrl ?? '', encryptedKey, model ?? '', dimensions ?? 1536, isActive ? 1 : 0); + + res.status(201).json({ + id, + name, + apiType, + baseUrl, + apiKey: maskApiKey(apiKey as string), + model, + dimensions: dimensions ?? 1536, + isActive: !!isActive, + }); + } catch (err) { + logger.errorFromError('configs.createEmbedding', 'POST /api/configs/embedding error', err); + res.status(500).json({ error: 'Failed to create embedding config', code: 'CREATE_EMBEDDING_CONFIG_FAILED' }); + } +}); + +// Embedding config bulk/update/delete — delegated to shared factory +registerEncryptedConfigRoutes({ + router, + basePath: '/api/configs/embedding', + table: 'embedding_configs', + secretColumn: 'api_key_encrypted', + label: 'Embedding config', + logPrefix: 'configs.embedding', + insertSql: `INSERT INTO embedding_configs (id, name, api_type, base_url, api_key_encrypted, model, dimensions, is_active) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + updateSql: `UPDATE embedding_configs SET name = ?, api_type = ?, base_url = ?, api_key_encrypted = ?, model = ?, dimensions = ?, is_active = ?, updated_at = datetime('now') WHERE id = ?`, + insertParams: (c, ek) => [ + c.id, c.name ?? '', c.apiType ?? 'openai', c.baseUrl ?? '', + ek, c.model ?? '', c.dimensions ?? 1536, c.isActive ? 1 : 0, + ], + updateParams: (b, id, ek) => [ + b.name ?? '', b.apiType ?? 'openai', b.baseUrl ?? '', + ek, b.model ?? '', b.dimensions ?? 1536, b.isActive ? 1 : 0, id, + ], + shapeResponse: (c, id, maskedKey) => ({ + id, name: c.name, apiType: c.apiType, baseUrl: c.baseUrl, + apiKey: maskedKey, model: c.model, dimensions: c.dimensions ?? 1536, isActive: !!c.isActive, + }), + requiresSecret: false, // Ollama 等本地模型不需要 API Key +}); + +// ── Vector Search Config ── + +// GET /api/configs/vector-search +router.get('/api/configs/vector-search', (req, res) => { + try { + const db = getDb(); + const shouldDecrypt = req.query.decrypt === 'true'; + const row = db.prepare('SELECT * FROM vector_search_configs WHERE id = ?').get('default') as Record | undefined; + + if (!row) { + res.json({ enabled: false, workerUrl: '', authToken: '', embeddingConfigId: '', indexMode: 'readme', readmeMaxChars: 6000 }); + return; + } + + let authToken = ''; + let authTokenStatus: SecretStatus = 'empty'; + if (row.auth_token_encrypted) { + const result = getMaskedSecretResult({ + encryptedValue: row.auth_token_encrypted, + encryptionKey: config.encryptionKey, + kind: 'AI API key', + }); + authToken = shouldDecrypt ? result.decryptedValue : maskApiKey(result.decryptedValue); + authTokenStatus = result.status; + } + + let status = undefined; + if (row.status_json && typeof row.status_json === 'string') { + try { status = JSON.parse(row.status_json); } catch { /* ignore */ } + } + + res.json({ + enabled: !!row.enabled, + workerUrl: row.worker_url ?? '', + authToken, + authTokenStatus, + embeddingConfigId: row.embedding_config_id ?? '', + indexMode: row.index_mode ?? 'readme', + readmeMaxChars: row.readme_max_chars ?? 6000, + status, + lastSyncAt: row.last_sync_at ?? null, + }); + } catch (err) { + logger.errorFromError('configs.getVectorSearch', 'GET /api/configs/vector-search error', err); + res.status(500).json({ error: 'Failed to fetch vector search config', code: 'FETCH_VECTOR_SEARCH_CONFIG_FAILED' }); + } +}); + +// PUT /api/configs/vector-search +router.put('/api/configs/vector-search', (req, res) => { + try { + const db = getDb(); + const { enabled, workerUrl, authToken, embeddingConfigId, indexMode, readmeMaxChars, status, lastSyncAt } = req.body as Record; + + let encryptedToken = ''; + const hasAuthToken = Object.prototype.hasOwnProperty.call(req.body, 'authToken'); + if (hasAuthToken && authToken === '') { + // Explicit empty string = user wants to clear the token + encryptedToken = ''; + } else if (authToken && typeof authToken === 'string' && !authToken.startsWith('***')) { + encryptedToken = encrypt(authToken, config.encryptionKey); + } else { + // Omitted or masked = reuse existing + const existing = db.prepare('SELECT auth_token_encrypted FROM vector_search_configs WHERE id = ?').get('default') as Record | undefined; + encryptedToken = (existing?.auth_token_encrypted as string) ?? ''; + } + + const statusJson = status ? JSON.stringify(status) : null; + const mode = indexMode === 'description' ? 'description' : 'readme'; + const maxChars = typeof readmeMaxChars === 'number' && readmeMaxChars > 0 ? readmeMaxChars : 6000; + + db.prepare(` + INSERT OR REPLACE INTO vector_search_configs (id, enabled, worker_url, auth_token_encrypted, embedding_config_id, index_mode, readme_max_chars, status_json, last_sync_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now')) + `).run('default', enabled ? 1 : 0, workerUrl ?? '', encryptedToken, embeddingConfigId ?? '', mode, maxChars, statusJson, lastSyncAt ?? null); + + res.json({ updated: true }); + } catch (err) { + logger.errorFromError('configs.updateVectorSearch', 'PUT /api/configs/vector-search error', err); + res.status(500).json({ error: 'Failed to update vector search config', code: 'UPDATE_VECTOR_SEARCH_CONFIG_FAILED' }); + } +}); + export default router; diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx index 253ad88f..86ed856e 100644 --- a/src/components/SearchBar.tsx +++ b/src/components/SearchBar.tsx @@ -7,7 +7,7 @@ import { forceSyncToBackend } from '../services/autoSync'; import { Repository } from '../types'; import { useSearchShortcuts } from '../hooks/useSearchShortcuts'; import { useDialog } from '../hooks/useDialog'; -import { getAICategory, getDefaultCategory } from '../utils/categoryUtils'; +import { isRepoCustomized } from '../utils/repoUtils'; import { NumberInput } from './ui/NumberInput'; type SortBy = 'stars' | 'updated' | 'name' | 'starred'; @@ -143,36 +143,8 @@ export const SearchBar: React.FC = () => { stats.notSubscribed++; } - // 自定义状态统计 - 与编辑页面逻辑一致 - // 描述:有自定义描述标记(包括明确清空),且内容与AI/原始不同 - const hasCustomDesc = repo.custom_description !== undefined; - const repoDesc = (repo.description || '').trim(); - const aiDesc = (repo.ai_summary || '').trim(); - const customDesc = (repo.custom_description || '').trim(); - const isDescEdited = hasCustomDesc && - (customDesc === '' || (customDesc !== repoDesc && customDesc !== aiDesc)); - - // 标签:有自定义标签标记(包括明确清空),且内容与AI/Topics不同 - const hasCustomTags = repo.custom_tags !== undefined; - const aiTags = repo.ai_tags || []; - const topics = repo.topics || []; - const customTags = repo.custom_tags || []; - const isTagsEdited = hasCustomTags && - (customTags.length === 0 || ( - JSON.stringify([...customTags].sort()) !== JSON.stringify([...aiTags].sort()) && - JSON.stringify([...customTags].sort()) !== JSON.stringify([...topics].sort()) - )); - - // 分类:有自定义分类标记(包括明确清空),且与AI/默认不一致 - const aiCat = getAICategory(repo, allCategories); - const defaultCat = getDefaultCategory(repo, allCategories); - const customCat = repo.custom_category; - const isCategoryEdited = customCat !== undefined && - (customCat === '' || (customCat !== aiCat && customCat !== defaultCat)); - - // 任意一个为true则视为已编辑(注意:分类锁定不算编辑) - const isCustomized = isDescEdited || isTagsEdited || isCategoryEdited; - if (isCustomized) { + // 自定义状态统计 + if (isRepoCustomized(repo, allCategories)) { stats.edited++; } else { stats.notEdited++; @@ -194,6 +166,9 @@ export const SearchBar: React.FC = () => { const [searchSuggestions, setSearchSuggestions] = useState([]); const [showSuggestions, setShowSuggestions] = useState(false); const searchInputRef = useRef(null); + const skipNextTextSearchRef = useRef(false); + const vectorScoreMapRef = useRef<{ query: string; scores: Map } | null>(null); + const [searchPhase, setSearchPhase] = useState(null); const filterChipBaseClass = 'flex items-center space-x-2 px-3 py-1.5 rounded-lg text-sm border transition-colors'; const filterChipActiveClass = 'bg-brand-indigo text-white border-brand-indigo shadow-sm dark:bg-brand-indigo/80 dark:text-white dark:border-brand-indigo/70 font-medium'; const filterChipInactiveClass = 'bg-white border-black/[0.06] text-gray-700 dark:bg-white/[0.04] dark:border-white/[0.04] dark:text-text-secondary hover:bg-gray-50 hover:text-gray-900 dark:hover:bg-white/[0.08] dark:hover:text-text-primary'; @@ -236,9 +211,32 @@ export const SearchBar: React.FC = () => { useEffect(() => { const performSearch = async () => { + // Skip if vector search just set results + if (skipNextTextSearchRef.current) { + skipNextTextSearchRef.current = false; + return; + } + // Check if vector search is still enabled + const vsEnabled = useAppStore.getState().vectorSearchConfig.enabled; + if (!vsEnabled) { + vectorScoreMapRef.current = null; + } if (!searchFilters.query) { + vectorScoreMapRef.current = null; performBasicFilter(); + } else if (vectorScoreMapRef.current && vectorScoreMapRef.current.query === searchFilters.query && vsEnabled) { + // Vector results exist for this exact query and vector search is enabled — re-apply filters and re-sort by score + const { scores } = vectorScoreMapRef.current; + const reFiltered = applyFilters(repositories.filter(r => scores.has(String(r.id)))); + const reSorted = reFiltered.sort( + (a, b) => (scores.get(String(b.id)) ?? 0) - (scores.get(String(a.id)) ?? 0) + ); + setSearchResults(reSorted); } else { + // Query changed or vector search disabled — clear stale ref and do text search + vectorScoreMapRef.current = null; + } + if (!vectorScoreMapRef.current) { const textResults = performBasicTextSearch(repositories, searchFilters.query); const finalFiltered = applyFilters(textResults); setSearchResults(finalFiltered); @@ -376,36 +374,11 @@ export const SearchBar: React.FC = () => { ); } - // 自定义筛选 - 与编辑页面逻辑一致 + // 自定义筛选 if (searchFilters.isEdited !== undefined) { - filtered = filtered.filter(repo => { - const hasCustomDesc = repo.custom_description !== undefined; - const repoDesc = (repo.description || '').trim(); - const aiDesc = (repo.ai_summary || '').trim(); - const customDesc = (repo.custom_description || '').trim(); - const isDescEdited = hasCustomDesc && - (customDesc === '' || (customDesc !== repoDesc && customDesc !== aiDesc)); - - const hasCustomTags = repo.custom_tags !== undefined; - const aiTags = repo.ai_tags || []; - const topics = repo.topics || []; - const customTags = repo.custom_tags || []; - const isTagsEdited = hasCustomTags && - (customTags.length === 0 || ( - JSON.stringify([...customTags].sort()) !== JSON.stringify([...aiTags].sort()) && - JSON.stringify([...customTags].sort()) !== JSON.stringify([...topics].sort()) - )); - - // 分类:有自定义分类标记(包括明确清空),且与AI/默认不一致 - const aiCat = getAICategory(repo, allCategories); - const defaultCat = getDefaultCategory(repo, allCategories); - const customCat = repo.custom_category; - const isCategoryEdited = customCat !== undefined && - (customCat === '' || (customCat !== aiCat && customCat !== defaultCat)); - - const isRepoCustomized = isDescEdited || isTagsEdited || isCategoryEdited; - return searchFilters.isEdited ? isRepoCustomized : !isRepoCustomized; - }); + filtered = filtered.filter(repo => + searchFilters.isEdited ? isRepoCustomized(repo, allCategories) : !isRepoCustomized(repo, allCategories) + ); } // Category locked filter - 检查分类是否被锁定 @@ -492,29 +465,8 @@ export const SearchBar: React.FC = () => { // Edited filter if (searchFilters.isEdited !== undefined) { - const hasCustomDesc = repo.custom_description !== undefined; - const repoDesc = (repo.description || '').trim(); - const aiDesc = (repo.ai_summary || '').trim(); - const customDesc = (repo.custom_description || '').trim(); - const isDescEdited = hasCustomDesc && - (customDesc === '' || (customDesc !== repoDesc && customDesc !== aiDesc)); - const hasCustomTags = repo.custom_tags !== undefined; - const aiTags = repo.ai_tags || []; - const topics = repo.topics || []; - const customTags = repo.custom_tags || []; - const isTagsEdited = hasCustomTags && - (customTags.length === 0 || ( - JSON.stringify([...customTags].sort()) !== JSON.stringify([...aiTags].sort()) && - JSON.stringify([...customTags].sort()) !== JSON.stringify([...topics].sort()) - )); - // 分类:有自定义分类标记(包括明确清空),且与AI/默认不一致 - const aiCat = getAICategory(repo, allCategories); - const defaultCat = getDefaultCategory(repo, allCategories); - const customCat = repo.custom_category; - const isCategoryEdited = customCat !== undefined && - (customCat === '' || (customCat !== aiCat && customCat !== defaultCat)); - const isRepoCustomized = isDescEdited || isTagsEdited || isCategoryEdited; - tempFiltered = tempFiltered && (searchFilters.isEdited ? isRepoCustomized : !isRepoCustomized); + const customized = isRepoCustomized(repo, allCategories); + tempFiltered = tempFiltered && (searchFilters.isEdited ? customized : !customized); } // Analysis failed filter @@ -569,21 +521,95 @@ export const SearchBar: React.FC = () => { // Trigger AI search immediately setIsSearching(true); + setSearchPhase(null); + vectorScoreMapRef.current = null; console.log('🔍 Starting AI search for query:', searchQuery); - + try { let filtered = repositories; - + + // ====== 向量搜索分支 ====== + const vsConfig = useAppStore.getState().vectorSearchConfig; + const embConfigs = useAppStore.getState().embeddingConfigs; + const activeEmbConfig = embConfigs.find(c => c.id === vsConfig?.embeddingConfigId); + + if (vsConfig?.enabled && vsConfig?.workerUrl && activeEmbConfig) { + try { + const { VectorSearchService, EmbeddingClient } = await import('../services/vectorSearchService'); + const embeddingClient = new EmbeddingClient(activeEmbConfig); + const vectorService = new VectorSearchService(vsConfig); + + // 1. 前端调用 Embedding API 生成查询向量 + setSearchPhase(t('生成查询向量...', 'Generating query vector...')); + const queryVectors = await embeddingClient.embed([searchQuery], 'query'); + if (queryVectors && queryVectors.length > 0) { + // 2. 前端将查询向量发送到 Worker + setSearchPhase(t('检索向量库...', 'Searching vector index...')); + const vectorResults = await vectorService.query(queryVectors[0], { topK: 30, threshold: 0.3 }); + + if (vectorResults.length > 0) { + // 3. 从本地仓库数据中取出匹配结果,按相似度排序 + const scoreMap = new Map(vectorResults.map(r => [r.id, r.score])); + const scoredRepos = filtered + .filter(repo => scoreMap.has(String(repo.id))) + .map(repo => ({ + repo, + score: scoreMap.get(String(repo.id)) || 0, + })) + .sort((a, b) => b.score - a.score) + .map(item => item.repo); + + if (scoredRepos.length > 0) { + // 4. AI 校验:用 LLM 对向量搜索结果进行二次排序 + let reranked = scoredRepos; + let rerankSucceeded = false; + const rerankConfig = aiConfigs.find(config => config.id === activeAIConfig); + if (rerankConfig) { + try { + setSearchPhase(t('AI 校验排序...', 'AI reranking...')); + const { AIService } = await import('../services/aiService'); + const rerankService = new AIService(rerankConfig, language); + reranked = await rerankService.searchRepositoriesWithReranking(scoredRepos, searchQuery); + rerankSucceeded = true; + console.log('🤖 AI reranked results:', reranked.length); + } catch (rerankError) { + console.warn('AI reranking failed, using vector order:', rerankError); + } + } + + // If AI reranking succeeded, preserve its order; otherwise sort by vector score + const finalFiltered = applyFilters([...reranked]); + if (!rerankSucceeded) { + finalFiltered.sort((a, b) => (scoreMap.get(String(b.id)) ?? 0) - (scoreMap.get(String(a.id)) ?? 0)); + } + console.log('🎯 Vector search results:', finalFiltered.length); + vectorScoreMapRef.current = { query: searchQuery, scores: scoreMap }; + skipNextTextSearchRef.current = true; + setSearchResults(finalFiltered); + setSearchFilters({ query: searchQuery }); + return; + } + } + } + // 向量搜索无结果 → 继续走关键词搜索 + console.log('⚠️ Vector search returned no results, falling back to keyword search'); + } catch (vectorError) { + console.warn('❌ Vector search failed, falling back to keyword search:', vectorError); + } + } + // ====== 向量搜索分支结束 ====== + const activeConfig = aiConfigs.find(config => config.id === activeAIConfig); console.log('🤖 AI Config found:', !!activeConfig, 'Active AI Config ID:', activeAIConfig); console.log('📋 Available AI Configs:', aiConfigs.length); console.log('🔧 AI Configs:', aiConfigs.map(c => ({ id: c.id, name: c.name, hasApiKey: !!c.apiKey }))); - + if (activeConfig) { try { console.log('🚀 Calling AI service...'); + setSearchPhase(t('AI 语义分析...', 'AI semantic analysis...')); const aiService = new AIService(activeConfig, language); - + // 先尝试AI搜索 const aiResults = await aiService.searchRepositoriesWithReranking(filtered, searchQuery); console.log('✅ AI search completed, results:', aiResults.length); @@ -613,6 +639,7 @@ export const SearchBar: React.FC = () => { console.error('💥 Search failed:', error); } finally { setIsSearching(false); + setSearchPhase(null); } }; @@ -852,6 +879,28 @@ export const SearchBar: React.FC = () => { } else { toast(t('同步完成!所有仓库都是最新的。', 'Sync completed! All repositories are up to date.'), 'info'); } + + // 向量搜索开启时,后台自动索引新仓库 + const vsCfg = useAppStore.getState().vectorSearchConfig; + const embCfgs = useAppStore.getState().embeddingConfigs; + const activeEmb = embCfgs.find(c => c.id === vsCfg?.embeddingConfigId); + if (vsCfg?.enabled && vsCfg?.workerUrl && activeEmb && newRepoCount > 0) { + const { VectorSearchService, EmbeddingClient, indexAllRepos } = await import('../services/vectorSearchService'); + const embClient = new EmbeddingClient(activeEmb); + const vecService = new VectorSearchService(vsCfg); + const readmeFetcher = githubToken + ? (owner: string, repo: string, signal?: AbortSignal) => new GitHubApiService(githubToken).getRepositoryReadme(owner, repo, signal) + : undefined; + // 只索引新增仓库,不重复索引已有仓库 + const newRepos = mergedRepositories.filter(repo => !existingRepoIds.has(repo.id)); + if (newRepos.length > 0) { + indexAllRepos(newRepos, embClient, vecService, { + readmeFetcher, + indexMode: vsCfg.indexMode, + readmeMaxChars: vsCfg.readmeMaxChars, + }).catch(() => {}); + } + } } catch (error) { console.error('Sync failed:', error); if (error instanceof Error && error.message.includes('token')) { @@ -984,13 +1033,18 @@ export const SearchBar: React.FC = () => { onClick={handleAISearch} disabled={isSearching} className="flex items-center space-x-1 px-2.5 sm:px-4 py-1.5 bg-brand-indigo text-white rounded-lg hover:bg-brand-hover transition-colors text-sm font-medium disabled:opacity-50" - title={activeAIConfig + title={activeAIConfig ? t('使用配置的AI服务进行语义搜索和重排序', 'Use configured AI service for semantic search and reranking') : t('使用本地智能排序算法进行搜索', 'Use local intelligent ranking algorithm for search')} > {isSearching ? t('AI搜索中...', 'AI Searching...') : t('AI搜索', 'AI Search')} + {isSearching && searchPhase && ( + + {searchPhase} + + )}
diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx index a55cadec..e73640b9 100644 --- a/src/components/SettingsPanel.tsx +++ b/src/components/SettingsPanel.tsx @@ -12,6 +12,7 @@ import { Wifi, ScrollText, Layout, + Search, } from 'lucide-react'; import { useAppStore } from '../store/useAppStore'; import { isElectron } from '../services/electronProxy'; @@ -27,9 +28,10 @@ import { NetworkPanel, DiagnosticLogsPanel, MenuManagementPanel, + VectorSearchSettings, } from './settings'; -type SettingsTab = 'general' | 'ai' | 'webdav' | 'backup' | 'backend' | 'category' | 'menu' | 'data' | 'logs' | 'network'; +type SettingsTab = 'general' | 'ai' | 'webdav' | 'backup' | 'backend' | 'category' | 'menu' | 'data' | 'logs' | 'network' | 'vectorSearch'; interface SettingsTabItem { id: SettingsTab; @@ -256,7 +258,7 @@ export const SettingsPanel: React.FC = ({ // Valid SettingsTab values for runtime validation const VALID_TABS: ReadonlySet = useMemo( - () => new Set(['general', 'ai', 'webdav', 'backup', 'backend', 'category', 'menu', 'data', 'logs', 'network']), + () => new Set(['general', 'ai', 'webdav', 'backup', 'backend', 'category', 'menu', 'data', 'logs', 'network', 'vectorSearch']), [] ); @@ -354,6 +356,11 @@ export const SettingsPanel: React.FC = ({ label: t('网络设置', 'Network'), icon: , }] : []), + { + id: 'vectorSearch' as SettingsTab, + label: t('向量搜索', 'Vector Search'), + icon: , + }, ]; const renderTabContent = () => { @@ -379,6 +386,8 @@ export const SettingsPanel: React.FC = ({ return ; case 'network': return ; + case 'vectorSearch': + return ; default: return null; } diff --git a/src/components/settings/VectorSearchSettings.tsx b/src/components/settings/VectorSearchSettings.tsx new file mode 100644 index 00000000..c2c7d4eb --- /dev/null +++ b/src/components/settings/VectorSearchSettings.tsx @@ -0,0 +1,923 @@ +import React, { useState, useCallback } from 'react'; +import { + Search, + Eye, + EyeOff, + Loader2, + CheckCircle, + XCircle, + RefreshCw, + Square, + ChevronDown, + ChevronRight, + Zap, +} from 'lucide-react'; +import { useAppStore } from '../../store/useAppStore'; +import { + EmbeddingClient, + VectorSearchService, + indexAllRepos, +} from '../../services/vectorSearchService'; +import { GitHubApiService } from '../../services/githubApi'; +import type { EmbeddingApiType, EmbeddingConfig } from '../../types'; + +interface VectorSearchSettingsProps { + t: (zh: string, en: string) => string; +} + +const EMBEDDING_API_TYPES: { value: EmbeddingApiType; label: string; labelEn: string }[] = [ + { value: 'openai', label: 'OpenAI', labelEn: 'OpenAI' }, + { value: 'openai-compatible', label: 'OpenAI 兼容端点', labelEn: 'OpenAI Compatible' }, + { value: 'siliconflow', label: '硅基流动', labelEn: 'SiliconFlow' }, + { value: 'gemini', label: 'Gemini', labelEn: 'Gemini' }, + { value: 'cohere', label: 'Cohere', labelEn: 'Cohere' }, + { value: 'ollama', label: 'Ollama (本地)', labelEn: 'Ollama (Local)' }, +]; + +const DEFAULT_DIMENSIONS: Record = { + openai: 1536, + 'openai-compatible': 1536, + siliconflow: 1024, + gemini: 768, + cohere: 1024, + ollama: 768, +}; + +export const VectorSearchSettings: React.FC = ({ t }) => { + const { + embeddingConfigs, + activeEmbeddingConfig, + vectorSearchConfig, + vectorSearchStatus, + vectorIndexingState, + addEmbeddingConfig, + updateEmbeddingConfig, + setActiveEmbeddingConfig, + setVectorSearchConfig, + setVectorSearchStatus, + setVectorIndexingState, + repositories, + githubToken, + } = useAppStore(); + + // Local form state for embedding config + const activeConfig = embeddingConfigs.find((c) => c.id === activeEmbeddingConfig); + const [formApiType, setFormApiType] = useState(activeConfig?.apiType || 'openai'); + const [formBaseUrl, setFormBaseUrl] = useState(activeConfig?.baseUrl || ''); + const [formApiKey, setFormApiKey] = useState(activeConfig?.apiKey || ''); + const [formModel, setFormModel] = useState(activeConfig?.model || ''); + const [formDimensions, setFormDimensions] = useState(activeConfig?.dimensions || 1536); + const [showApiKey, setShowApiKey] = useState(false); + + // Worker form state + const [formWorkerUrl, setFormWorkerUrl] = useState(vectorSearchConfig.workerUrl || ''); + const [formAuthToken, setFormAuthToken] = useState(vectorSearchConfig.authToken || ''); + const [showAuthToken, setShowAuthToken] = useState(false); + + // Index mode state + const [formIndexMode, setFormIndexMode] = useState<'description' | 'readme'>(vectorSearchConfig.indexMode || 'readme'); + const [formReadmeMaxChars, setFormReadmeMaxChars] = useState(vectorSearchConfig.readmeMaxChars || 6000); + + // Test state + const [testingEmbedding, setTestingEmbedding] = useState(false); + const [embeddingTestResult, setEmbeddingTestResult] = useState<{ success: boolean; dimensions: number; error?: string } | null>(null); + const [testingWorker, setTestingWorker] = useState(false); + const [workerTestResult, setWorkerTestResult] = useState<{ success: boolean; vectorCount: number; dimensions: number; error?: string } | null>(null); + + // Save feedback + const [embeddingSaved, setEmbeddingSaved] = useState(false); + const [workerSaved, setWorkerSaved] = useState(false); + + // Indexing state (from store, persists across navigation) + const { isIndexing, phase, phaseDone, phaseTotal, result: indexResult } = vectorIndexingState; + const [abortController, setAbortController] = useState(null); + + // Deploy guide + const [showDeployGuide, setShowDeployGuide] = useState(false); + + // Sync form state when active config changes + React.useEffect(() => { + if (activeConfig) { + setFormApiType(activeConfig.apiType); + setFormBaseUrl(activeConfig.baseUrl); + setFormApiKey(activeConfig.apiKey); + setFormModel(activeConfig.model); + setFormDimensions(activeConfig.dimensions); + } + }, [activeConfig]); + + // Sync worker form state + React.useEffect(() => { + setFormWorkerUrl(vectorSearchConfig.workerUrl); + setFormAuthToken(vectorSearchConfig.authToken); + }, [vectorSearchConfig.workerUrl, vectorSearchConfig.authToken]); + + const handleSaveEmbeddingConfig = useCallback(() => { + const configData: Omit = { + name: `${formApiType} Embedding`, + apiType: formApiType, + baseUrl: formBaseUrl, + apiKey: formApiKey, + model: formModel, + dimensions: formDimensions, + }; + + if (activeConfig) { + updateEmbeddingConfig(activeConfig.id, configData); + } else { + const id = `emb_${Date.now()}`; + addEmbeddingConfig({ + ...configData, + id, + isActive: true, + }); + setActiveEmbeddingConfig(id); + } + setEmbeddingSaved(true); + setTimeout(() => setEmbeddingSaved(false), 2000); + }, [activeConfig, formApiType, formBaseUrl, formApiKey, formModel, formDimensions, addEmbeddingConfig, updateEmbeddingConfig, setActiveEmbeddingConfig]); + + const handleSaveWorkerConfig = useCallback(() => { + setVectorSearchConfig({ + workerUrl: formWorkerUrl, + authToken: formAuthToken, + embeddingConfigId: activeEmbeddingConfig || '', + indexMode: formIndexMode, + readmeMaxChars: formReadmeMaxChars, + }); + setWorkerSaved(true); + setTimeout(() => setWorkerSaved(false), 2000); + }, [formWorkerUrl, formAuthToken, formIndexMode, formReadmeMaxChars, activeEmbeddingConfig, setVectorSearchConfig]); + + const handleTestEmbedding = useCallback(async () => { + setTestingEmbedding(true); + setEmbeddingTestResult(null); + try { + const client = new EmbeddingClient({ + id: 'test', + name: 'test', + apiType: formApiType, + baseUrl: formBaseUrl, + apiKey: formApiKey, + model: formModel, + dimensions: formDimensions, + isActive: true, + }); + const result = await client.testConnection(); + setEmbeddingTestResult(result); + } catch (err) { + setEmbeddingTestResult({ + success: false, + dimensions: 0, + error: err instanceof Error ? err.message : String(err), + }); + } finally { + setTestingEmbedding(false); + } + }, [formApiType, formBaseUrl, formApiKey, formModel, formDimensions]); + + const handleTestWorker = useCallback(async () => { + setTestingWorker(true); + setWorkerTestResult(null); + try { + const service = new VectorSearchService({ + enabled: true, + workerUrl: formWorkerUrl, + authToken: formAuthToken, + embeddingConfigId: '', + }); + const result = await service.testConnection(); + setWorkerTestResult(result); + // 同步更新 store 中的状态,让状态区域实时反映 + if (result.success) { + setVectorSearchStatus({ + connected: true, + vectorCount: result.vectorCount, + dimensions: result.dimensions, + }); + } + } catch (err) { + setWorkerTestResult({ + success: false, + vectorCount: 0, + dimensions: 0, + error: err instanceof Error ? err.message : String(err), + }); + } finally { + setTestingWorker(false); + } + }, [formWorkerUrl, formAuthToken, setVectorSearchStatus]); + + const runIndexAll = useCallback(async (withCleanup: boolean) => { + if (!activeConfig) return; + + const embeddingClient = new EmbeddingClient({ + ...activeConfig, + apiType: formApiType, + baseUrl: formBaseUrl, + apiKey: formApiKey, + model: formModel, + dimensions: formDimensions, + }); + const vectorService = new VectorSearchService({ + enabled: true, + workerUrl: formWorkerUrl, + authToken: formAuthToken, + embeddingConfigId: activeEmbeddingConfig || '', + }); + const controller = new AbortController(); + setAbortController(controller); + setVectorIndexingState({ isIndexing: true, phase: null, phaseDone: 0, phaseTotal: 0, result: null }); + + try { + if (withCleanup) { + const keepIds = repositories.map(r => String(r.id)); + try { + await vectorService.cleanup(keepIds, controller.signal); + } catch (cleanupErr) { + // Cleanup 失败不阻塞重建,记录警告继续 + console.warn('Vector cleanup failed, continuing with rebuild:', cleanupErr); + } + } + + const readmeFetcher = githubToken + ? (owner: string, repo: string, signal?: AbortSignal) => { + const api = new GitHubApiService(githubToken); + return api.getRepositoryReadme(owner, repo, signal); + } + : undefined; + + const result = await indexAllRepos(repositories, embeddingClient, vectorService, { + onProgress: (progress) => setVectorIndexingState({ + phase: progress.phase, + phaseDone: progress.done, + phaseTotal: progress.total, + }), + signal: controller.signal, + readmeFetcher, + indexMode: formIndexMode, + readmeMaxChars: formReadmeMaxChars, + }); + setVectorIndexingState({ result, isIndexing: false, phase: null }); + setVectorSearchStatus({ + connected: true, + vectorCount: result.indexed, + dimensions: formDimensions, + lastSyncAt: new Date().toISOString(), + }); + } catch (err) { + if (err instanceof Error && err.message === 'Aborted') { + setVectorIndexingState({ isIndexing: false, phase: null, result: null }); + } else { + setVectorIndexingState({ isIndexing: false, phase: null, result: { indexed: 0, skipped: 0, errors: repositories.length } }); + } + } finally { + setAbortController(null); + } + }, [activeConfig, formApiType, formBaseUrl, formApiKey, formModel, formDimensions, formWorkerUrl, formAuthToken, formIndexMode, formReadmeMaxChars, activeEmbeddingConfig, repositories, githubToken, setVectorSearchStatus, setVectorIndexingState]); + + const handleRebuildIndex = useCallback(() => runIndexAll(true), [runIndexAll]); + const handleIncrementalIndex = useCallback(() => runIndexAll(false), [runIndexAll]); + + const handleAbortIndexing = useCallback(() => { + abortController?.abort(); + }, [abortController]); + + const isConfigComplete = !!( + activeConfig && + formBaseUrl && + formModel && + (formApiType === 'ollama' || formApiKey) && + formWorkerUrl && + formAuthToken + ); + + return ( +
+ {/* Header */} +
+
+ +
+
+

+ {t('向量语义搜索', 'Vector Semantic Search')} +

+

+ {t( + '基于 Cloudflare Vectorize 的语义搜索,能理解自然语言意图,找到语义相关而非仅关键词匹配的仓库。', + 'Semantic search powered by Cloudflare Vectorize. Understands natural language intent to find semantically related repositories.' + )} +

+
+
+ + {/* Toggle */} +
+
+
+ {t('启用向量搜索', 'Enable Vector Search')} +
+
+ {t('启用后,AI 搜索将优先走向量检索,失败时自动回退', 'When enabled, AI search will use vector retrieval first, with automatic fallback on failure')} +
+
+ +
+ + {/* Section 1: Embedding Model Config */} +
+

+ + {t('Embedding 模型配置', 'Embedding Model Configuration')} +

+ + {/* API Type */} +
+ +
+ {EMBEDDING_API_TYPES.map((type) => ( + + ))} +
+
+ + {/* Base URL */} +
+ + setFormBaseUrl(e.target.value)} + placeholder={ + formApiType === 'openai' + ? 'https://api.openai.com' + : formApiType === 'siliconflow' + ? 'https://api.siliconflow.cn' + : formApiType === 'gemini' + ? 'https://generativelanguage.googleapis.com' + : formApiType === 'cohere' + ? 'https://api.cohere.com' + : formApiType === 'ollama' + ? 'http://localhost:11434' + : 'https://api.example.com/v1/embeddings' + } + className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-purple-500 focus:border-transparent" + /> +
+ + {/* API Key */} +
+ +
+ setFormApiKey(e.target.value)} + placeholder={formApiType === 'ollama' ? t('可留空', 'Optional') : 'sk-xxx'} + className="w-full px-3 py-2 pr-10 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-purple-500 focus:border-transparent" + /> + +
+ {formApiType === 'ollama' && ( +

+ {t('Ollama 本地模型可留空', 'Ollama local models can leave this empty')} +

+ )} +
+ + {/* Model Name */} +
+ + setFormModel(e.target.value)} + placeholder={ + formApiType === 'openai' + ? 'text-embedding-3-small' + : formApiType === 'siliconflow' + ? 'BAAI/bge-large-zh-v1.5' + : formApiType === 'ollama' + ? 'nomic-embed-text' + : 'model-name' + } + className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-purple-500 focus:border-transparent" + /> +
+ + {/* Dimensions */} +
+ +
+ setFormDimensions(parseInt(e.target.value) || 1536)} + className="flex-1 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-purple-500 focus:border-transparent" + /> + +
+

+ ⚠ {t('必须与 Vectorize 索引维度一致', 'Must match Vectorize index dimensions')} +

+
+ + {/* Test & Save */} +
+ + +
+ + {/* Test Result */} + {embeddingTestResult && ( +
+ {embeddingTestResult.success ? : } + {embeddingTestResult.success + ? `${t('连接成功', 'Connection successful')} — ${t('维度', 'Dimensions')}: ${embeddingTestResult.dimensions}` + : `${t('连接失败', 'Connection failed')}: ${embeddingTestResult.error}`} +
+ )} +
+ + {/* Section 2: Cloudflare Vectorize Connection */} +
+

+ + {t('Cloudflare Vectorize 连接', 'Cloudflare Vectorize Connection')} +

+ + {/* Worker URL */} +
+ + setFormWorkerUrl(e.target.value)} + placeholder="https://github-stars-vectorize.your-name.workers.dev" + className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-purple-500 focus:border-transparent" + /> +
+ + {/* Auth Token */} +
+ +
+ setFormAuthToken(e.target.value)} + placeholder={t('Worker 认证令牌', 'Worker authentication token')} + className="w-full px-3 py-2 pr-10 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-purple-500 focus:border-transparent" + /> + +
+
+ + {/* Test */} +
+ +
+ + {/* Test Result */} + {workerTestResult && ( +
+ {workerTestResult.success ? : } + {workerTestResult.success + ? `${t('连接成功', 'Connection successful')} — ${t('向量数', 'Vectors')}: ${workerTestResult.vectorCount}, ${t('维度', 'Dimensions')}: ${workerTestResult.dimensions}` + : `${t('连接失败', 'Connection failed')}: ${workerTestResult.error}`} +
+ )} +
+ + {/* Section 3: Status */} +
+

+ + {t('状态', 'Status')} +

+ +
+
+ {vectorSearchStatus?.connected ? ( + + ) : ( + + )} + + {vectorSearchStatus?.connected + ? t('Worker 已连接', 'Worker connected') + : t('Worker 未连接', 'Worker not connected')} + +
+ + {activeConfig && ( +
+ + + {t('Embedding 模型', 'Embedding model')}: {activeConfig.model} + +
+ )} + + {vectorSearchStatus?.vectorCount !== undefined && ( +
+ 📊 + + {t('索引向量数', 'Indexed vectors')}: {vectorSearchStatus.vectorCount.toLocaleString()} + +
+ )} + + {vectorSearchStatus?.dimensions !== undefined && ( +
+ 📐 + + {t('向量维度', 'Vector dimensions')}: {vectorSearchStatus.dimensions.toLocaleString()} + +
+ )} + + {vectorSearchStatus?.lastSyncAt && ( +
+ 🕐 + + {t('最后同步', 'Last sync')}: {new Date(vectorSearchStatus.lastSyncAt).toLocaleString()} + +
+ )} +
+
+ + {/* Section 4: Actions */} +
+

+ + {t('索引管理', 'Index Management')} +

+ + {/* 索引内容选择 */} +
+ +
+ + +
+
+ + {/* README 字符数设置 */} + {formIndexMode === 'readme' && ( +
+ + setFormReadmeMaxChars(Math.max(500, parseInt(e.target.value) || 6000))} + min={500} + max={20000} + step={1000} + className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-brand-indigo focus:border-transparent" + /> +

+ {t('建议 4000-8000,越长精度越高但索引越慢', 'Recommended 4000-8000. Longer = higher precision but slower indexing')} +

+
+ )} + + {/* 保存索引配置 */} + + +
+ + + {isIndexing && ( + + )} +
+ + {/* Progress */} + {isIndexing && phaseTotal > 0 && ( +
+
+ + {phase === 'readme' && `📖 ${t('获取 README', 'Fetching README')}`} + {phase === 'embedding' && `🧠 ${t('生成向量', 'Generating embeddings')}`} + {phase === 'uploading' && `☁️ ${t('上传向量', 'Uploading vectors')}`} + {!phase && `⏳ ${t('准备中', 'Preparing')}`} + + + {phaseDone}/{phaseTotal} ({Math.round((phaseDone / phaseTotal) * 100)}%) + +
+
+
+
+
+ )} + + {/* Result */} + {indexResult && ( +
0 && indexResult.indexed === 0 + ? 'bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300' + : 'bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300' + }`}> + {t('索引完成', 'Indexing complete')}: {indexResult.indexed} {t('已索引', 'indexed')}, {indexResult.skipped} {t('跳过', 'skipped')}, {indexResult.errors} {t('失败', 'errors')} + {indexResult.error && ( +
{indexResult.error}
+ )} +
+ )} +
+ + {/* Section 5: Delete Index */} +
+

+ + {t('删除索引', 'Delete Index')} +

+

+ {t( + '如果更换了 Embedding 模型(维度不同),需要删除旧索引后重新创建。', + 'If you changed the Embedding model (different dimensions), you need to delete the old index and recreate it.' + )} +

+
+ + +
+

+ {t('在 cloudflare-worker 目录下执行以上命令,然后点击上方「重建向量索引」', 'Run these commands in the cloudflare-worker directory, then click "Rebuild Vector Index" above')} +

+
+ + {/* Section 6: Deploy Guide */} +
+ + + {showDeployGuide && ( +
+ {/* 首次部署 */} +
+

+ {t('首次部署', 'Initial Deployment')} +

+
    +
  1. + npm install -g wrangler + {t(' 然后 ', ' then ')} + wrangler login +
  2. +
  3. + + npx wrangler vectorize create github-stars --dimensions={formDimensions} --metric=cosine + +
  4. +
  5. + cd cloudflare-worker && npm install +
  6. +
  7. + wrangler secret put AUTH_TOKEN +
  8. +
  9. + npm run deploy +
  10. +
+
+ + {/* 更新部署 */} +
+

+ {t('更新部署(代码变更后)', 'Redeploy (after code changes)')} +

+
    +
  1. + cd cloudflare-worker +
  2. +
  3. + npm run deploy + {t('(如果依赖有变更,先执行 ', ' (if dependencies changed, run ')} + npm install + {t(')', ')')} +
  4. +
+

+ {t('注意:更新部署不需要重新创建 Vectorize 索引,已有向量数据不受影响。', 'Note: Redeployment does not require recreating the Vectorize index. Existing vector data is preserved.')} +

+
+ + {/* 模型变更警告 */} +
+

+ ⚠️ {t('更换 Embedding 模型后必须重建索引', 'Must rebuild index after changing Embedding model')} +

+

+ {t( + '不同模型生成的向量维度不同,混用会导致查询失败。更换模型后需要:① 删除旧索引并创建新索引(维度需匹配) ② 点击下方「重建向量索引」', + 'Different models produce vectors with different dimensions. After changing model: ① Delete old index and create new one (dimensions must match) ② Click "Rebuild Vector Index" below' + )} +

+
+ +

+ {t('详细部署指南请参考', 'For detailed instructions, see')}{' '} + + cloudflare-worker/README.md + +

+
+ )} +
+
+ ); +}; diff --git a/src/components/settings/index.ts b/src/components/settings/index.ts index ef65e604..8a08ccc4 100644 --- a/src/components/settings/index.ts +++ b/src/components/settings/index.ts @@ -8,3 +8,4 @@ export { DataManagementPanel } from './DataManagementPanel'; export { NetworkPanel } from './NetworkPanel'; export { DiagnosticLogsPanel } from './DiagnosticLogsPanel'; export { MenuManagementPanel } from './MenuManagementPanel'; +export { VectorSearchSettings } from './VectorSearchSettings'; diff --git a/src/services/autoSync.ts b/src/services/autoSync.ts index b64a7550..01e51b25 100644 --- a/src/services/autoSync.ts +++ b/src/services/autoSync.ts @@ -33,6 +33,8 @@ const _lastHash = { releases: '', ai: '', webdav: '', + embedding: '', + vectorSearch: '', settings: '', }; @@ -65,15 +67,17 @@ export async function syncFromBackend(): Promise { const startTime = Date.now(); try { - const [reposResult, releasesResult, aiResult, webdavResult, settingsResult] = await Promise.allSettled([ + const [reposResult, releasesResult, aiResult, webdavResult, embeddingResult, vectorSearchResult, settingsResult] = await Promise.allSettled([ backend.fetchRepositories(), backend.fetchReleases(), backend.fetchAIConfigs(), backend.fetchWebDAVConfigs(), + backend.fetchEmbeddingConfigs(), + backend.fetchVectorSearchConfig(), backend.fetchSettings(), ]); - const changed = { repos: false, releases: false, ai: false, webdav: false, settings: false }; + const changed = { repos: false, releases: false, ai: false, webdav: false, embedding: false, vectorSearch: false, settings: false }; // Compute hashes for each slice — only mark changed if hash differs const hashes: Record = {}; @@ -109,6 +113,22 @@ export async function syncFromBackend(): Promise { } } + if (embeddingResult.status === 'fulfilled') { + const hash = quickHash(embeddingResult.value); + if (hash !== _lastHash.embedding) { + hashes.embedding = hash; + changed.embedding = true; + } + } + + if (vectorSearchResult.status === 'fulfilled') { + const hash = quickHash(vectorSearchResult.value); + if (hash !== _lastHash.vectorSearch) { + hashes.vectorSearch = hash; + changed.vectorSearch = true; + } + } + if (settingsResult.status === 'fulfilled') { const hash = quickHash(settingsResult.value); if (hash !== _lastHash.settings) { @@ -190,6 +210,35 @@ export async function syncFromBackend(): Promise { // Store raw backend hash for consistent change detection _lastHash.webdav = hashes.webdav; } + if (changed.embedding && embeddingResult.status === 'fulfilled') { + const backendConfigs = embeddingResult.value; + const localConfigs = state.embeddingConfigs; + const mergedConfigs = backendConfigs.map(bc => { + if (bc.apiKeyStatus === 'decrypt_failed' || !bc.apiKey) { + const local = localConfigs.find(lc => lc.id === bc.id); + if (local && local.apiKey) { + logger.warn('sync.decryptFailed', `Backend decrypt_failed for embedding config "${bc.name}", preserving local apiKey`); + return { ...bc, apiKey: local.apiKey, apiKeyStatus: 'ok' as const }; + } + } + return bc; + }); + state.setEmbeddingConfigs(mergedConfigs); + _lastHash.embedding = hashes.embedding; + } + if (changed.vectorSearch && vectorSearchResult.status === 'fulfilled') { + const backendConfig = vectorSearchResult.value; + // Preserve local authToken if backend returned empty or decrypt_failed + const localConfig = state.vectorSearchConfig; + if ((backendConfig as Record).authTokenStatus === 'decrypt_failed' || !backendConfig.authToken) { + if (localConfig.authToken) { + logger.warn('sync.decryptFailed', 'Backend decrypt_failed for vector search authToken, preserving local value'); + backendConfig.authToken = localConfig.authToken; + } + } + state.setVectorSearchConfig(backendConfig); + _lastHash.vectorSearch = hashes.vectorSearch; + } // Sync active selections from settings if (changed.settings && settingsResult.status === 'fulfilled') { const settings = settingsResult.value; @@ -199,6 +248,9 @@ export async function syncFromBackend(): Promise { if (typeof settings.activeWebDAVConfig === 'string' || settings.activeWebDAVConfig === null) { state.setActiveWebDAVConfig(settings.activeWebDAVConfig as string | null); } + if (typeof settings.activeEmbeddingConfig === 'string' || settings.activeEmbeddingConfig === null) { + state.setActiveEmbeddingConfig(settings.activeEmbeddingConfig as string | null); + } if (Array.isArray(settings.hiddenDefaultCategoryIds)) { const nextHiddenIds = settings.hiddenDefaultCategoryIds.filter((id): id is string => typeof id === 'string'); const currentHiddenIds = state.hiddenDefaultCategoryIds || []; @@ -272,9 +324,12 @@ export async function syncToBackend(): Promise { backend.syncReleases(state.releases), backend.syncAIConfigs(state.aiConfigs), backend.syncWebDAVConfigs(state.webdavConfigs), + backend.syncEmbeddingConfigs(state.embeddingConfigs), + backend.syncVectorSearchConfig(state.vectorSearchConfig), backend.syncSettings({ activeAIConfig: state.activeAIConfig, activeWebDAVConfig: state.activeWebDAVConfig, + activeEmbeddingConfig: state.activeEmbeddingConfig, hiddenDefaultCategoryIds: state.hiddenDefaultCategoryIds, categoryOrder: state.categoryOrder, customCategories: state.customCategories, @@ -283,7 +338,7 @@ export async function syncToBackend(): Promise { collapsedSidebarCategoryCount: state.collapsedSidebarCategoryCount, }), ]); - const [reposSync, releasesSync, aiSync, webdavSync, settingsSync] = results; + const [reposSync, releasesSync, aiSync, webdavSync, embeddingSync, vectorSearchSync, settingsSync] = results; const failures = results.filter(r => r.status === 'rejected'); if (failures.length > 0) { @@ -299,10 +354,13 @@ export async function syncToBackend(): Promise { if (releasesSync.status === 'fulfilled') _lastHash.releases = quickHash(state.releases); if (aiSync.status === 'fulfilled') _lastHash.ai = quickHash(state.aiConfigs); if (webdavSync.status === 'fulfilled') _lastHash.webdav = quickHash(state.webdavConfigs); + if (embeddingSync.status === 'fulfilled') _lastHash.embedding = quickHash(state.embeddingConfigs); + if (vectorSearchSync.status === 'fulfilled') _lastHash.vectorSearch = quickHash(state.vectorSearchConfig); if (settingsSync.status === 'fulfilled') { _lastHash.settings = quickHash({ activeAIConfig: state.activeAIConfig, activeWebDAVConfig: state.activeWebDAVConfig, + activeEmbeddingConfig: state.activeEmbeddingConfig, hiddenDefaultCategoryIds: state.hiddenDefaultCategoryIds, categoryOrder: state.categoryOrder, customCategories: state.customCategories, @@ -365,8 +423,11 @@ export function startAutoSync(): () => void { state.releases !== prevState.releases || state.aiConfigs !== prevState.aiConfigs || state.webdavConfigs !== prevState.webdavConfigs || + state.embeddingConfigs !== prevState.embeddingConfigs || + state.vectorSearchConfig !== prevState.vectorSearchConfig || state.activeAIConfig !== prevState.activeAIConfig || state.activeWebDAVConfig !== prevState.activeWebDAVConfig || + state.activeEmbeddingConfig !== prevState.activeEmbeddingConfig || state.hiddenDefaultCategoryIds !== prevState.hiddenDefaultCategoryIds || state.categoryOrder !== prevState.categoryOrder || state.customCategories !== prevState.customCategories || diff --git a/src/services/backendAdapter.ts b/src/services/backendAdapter.ts index 296f5960..a0f38c14 100644 --- a/src/services/backendAdapter.ts +++ b/src/services/backendAdapter.ts @@ -1,7 +1,7 @@ import { translateBackendError } from '../utils/backendErrors'; import { logger } from './logger'; -import { Repository, Release, AIConfig, WebDAVConfig } from '../types'; +import { Repository, Release, AIConfig, WebDAVConfig, EmbeddingConfig, VectorSearchConfig } from '../types'; import { useAppStore } from '../store/useAppStore'; import { isReadmeCandidateItem, type GitHubReadmeCandidateItem } from '../utils/readmeVariants'; @@ -593,6 +593,62 @@ class BackendAdapter { return res.json() as Promise; } + // === Embedding Configs === + + async syncEmbeddingConfigs(configs: EmbeddingConfig[]): Promise { + if (!this._backendUrl) return; + + const res = await this.fetchWithRetry(`${this._backendUrl}/configs/embedding/bulk`, { + method: 'PUT', + headers: this.getAuthHeaders(), + body: JSON.stringify({ configs }) + }, 30000, 3); + if (!res.ok) await this.throwTranslatedError(res, 'Sync embedding configs error'); + + try { + const data = await res.json() as { synced?: number; skipped?: number; errors?: Array<{ id: string; name: string; reason: string }> }; + if (data.skipped && data.skipped > 0) { + const reasons = data.errors?.map(e => `${e.name}: ${e.reason}`).join('; ') ?? ''; + throw new Error(`Sync embedding configs partial failure: ${data.skipped} skipped${reasons ? ` (${reasons})` : ''}`); + } + } catch (err) { + if (err instanceof Error && err.message.startsWith('Sync embedding configs partial failure')) throw err; + } + } + + async fetchEmbeddingConfigs(): Promise { + if (!this._backendUrl) throw new Error('Backend not available'); + + const res = await this.fetchWithTimeout(`${this._backendUrl}/configs/embedding?decrypt=true`, { + headers: this.getAuthHeaders() + }); + if (!res.ok) await this.throwTranslatedError(res, 'Fetch embedding configs error'); + return res.json() as Promise; + } + + // === Vector Search Config === + + async syncVectorSearchConfig(config: VectorSearchConfig): Promise { + if (!this._backendUrl) return; + + const res = await this.fetchWithRetry(`${this._backendUrl}/configs/vector-search`, { + method: 'PUT', + headers: this.getAuthHeaders(), + body: JSON.stringify(config) + }, 30000, 3); + if (!res.ok) await this.throwTranslatedError(res, 'Sync vector search config error'); + } + + async fetchVectorSearchConfig(): Promise { + if (!this._backendUrl) throw new Error('Backend not available'); + + const res = await this.fetchWithTimeout(`${this._backendUrl}/configs/vector-search?decrypt=true`, { + headers: this.getAuthHeaders() + }); + if (!res.ok) await this.throwTranslatedError(res, 'Fetch vector search config error'); + return res.json() as Promise; + } + // === Settings (active selections) === diff --git a/src/services/vectorSearchService.ts b/src/services/vectorSearchService.ts new file mode 100644 index 00000000..b545f2c0 --- /dev/null +++ b/src/services/vectorSearchService.ts @@ -0,0 +1,460 @@ +/** + * 向量语义搜索服务 + * + * 1. EmbeddingClient — 调用用户配置的 Embedding API 生成向量 + * 2. VectorSearchService — 与 Cloudflare Worker 通信(存/查/删向量) + */ + +import type { EmbeddingConfig, VectorSearchConfig, Repository } from '../types'; + +// ============================================================ +// EmbeddingClient +// ============================================================ + +export class EmbeddingClient { + constructor(private config: EmbeddingConfig) {} + + /** + * 批量生成 embedding 向量 + * @param purpose 'document' 用于索引, 'query' 用于搜索查询 + */ + async embed(texts: string[], purpose: 'document' | 'query' = 'document', signal?: AbortSignal): Promise { + switch (this.config.apiType) { + case 'openai': + case 'openai-compatible': + case 'siliconflow': + return this.embedOpenAICompatible(texts, signal); + case 'ollama': + return this.embedOllama(texts, signal); + case 'gemini': + return this.embedGemini(texts, purpose, signal); + case 'cohere': + return this.embedCohere(texts, purpose, signal); + default: + throw new Error(`Unsupported embedding API type: ${this.config.apiType}`); + } + } + + /** + * 测试连接:发送单条文本,验证返回向量维度 + */ + async testConnection(): Promise<{ success: boolean; dimensions: number; error?: string }> { + try { + const vectors = await this.embed(['hello']); + if (!vectors || vectors.length === 0 || !Array.isArray(vectors[0])) { + return { success: false, dimensions: 0, error: 'Invalid response format' }; + } + return { success: true, dimensions: vectors[0].length }; + } catch (error) { + return { + success: false, + dimensions: 0, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + // ---------------------------------------------------------- + // OpenAI / OpenAI-compatible + // POST /v1/embeddings or custom URL + // ---------------------------------------------------------- + private async embedOpenAICompatible(texts: string[], signal?: AbortSignal): Promise { + const url = + this.config.apiType === 'openai' || this.config.apiType === 'siliconflow' + ? `${this.config.baseUrl.replace(/\/+$/, '')}/v1/embeddings` + : this.config.baseUrl; + + const headers: Record = { 'Content-Type': 'application/json' }; + if (this.config.apiKey) { + headers['Authorization'] = `Bearer ${this.config.apiKey}`; + } + + const response = await fetch(url, { + method: 'POST', + headers, + body: JSON.stringify({ model: this.config.model, input: texts }), + signal, + }); + + if (!response.ok) { + const errText = await response.text().catch(() => ''); + throw new Error(`Embedding API error ${response.status}: ${errText}`); + } + + const data = await response.json(); + // OpenAI 格式: { data: [{ embedding: [...], index: 0 }] } + return data.data + .sort((a: { index: number }, b: { index: number }) => a.index - b.index) + .map((d: { embedding: number[] }) => d.embedding); + } + + // ---------------------------------------------------------- + // Ollama 本地模型 + // POST /api/embed + // ---------------------------------------------------------- + private async embedOllama(texts: string[], signal?: AbortSignal): Promise { + const url = `${this.config.baseUrl.replace(/\/+$/, '')}/api/embed`; + + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: this.config.model, input: texts }), + signal, + }); + + if (!response.ok) { + const errText = await response.text().catch(() => ''); + throw new Error(`Ollama API error ${response.status}: ${errText}`); + } + + const data = await response.json(); + // Ollama 格式: { embeddings: [[...], [...]] } + return data.embeddings; + } + + // ---------------------------------------------------------- + // Google Gemini + // POST /v1beta/models/{model}:batchEmbedContents + // ---------------------------------------------------------- + private async embedGemini(texts: string[], purpose: 'document' | 'query' = 'document', signal?: AbortSignal): Promise { + const baseUrl = this.config.baseUrl.replace(/\/+$/, ''); + const url = `${baseUrl}/v1beta/models/${this.config.model}:batchEmbedContents?key=${this.config.apiKey}`; + const taskType = purpose === 'query' ? 'RETRIEVAL_QUERY' : 'RETRIEVAL_DOCUMENT'; + + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + requests: texts.map((text) => ({ + model: `models/${this.config.model}`, + content: { parts: [{ text }] }, + taskType, + })), + }), + signal, + }); + + if (!response.ok) { + const errText = await response.text().catch(() => ''); + throw new Error(`Gemini API error ${response.status}: ${errText}`); + } + + const data = await response.json(); + return data.embeddings.map((e: { values: number[] }) => e.values); + } + + // ---------------------------------------------------------- + // Cohere + // POST /v1/embed + // ---------------------------------------------------------- + private async embedCohere(texts: string[], purpose: 'document' | 'query' = 'document', signal?: AbortSignal): Promise { + const url = `${this.config.baseUrl.replace(/\/+$/, '')}/v1/embed`; + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.config.apiKey}`, + }, + body: JSON.stringify({ + model: this.config.model, + texts, + input_type: purpose === 'query' ? 'search_query' : 'search_document', + }), + signal, + }); + + if (!response.ok) { + const errText = await response.text().catch(() => ''); + throw new Error(`Cohere API error ${response.status}: ${errText}`); + } + + const data = await response.json(); + return data.embeddings; + } +} + +// ============================================================ +// VectorSearchService — 与 Cloudflare Worker 通信 +// ============================================================ + +export interface VectorizeVector { + id: string; + values: number[]; + metadata: { + full_name: string; + description: string; + language: string; + stars: number; + tags: string[]; + }; +} + +export interface VectorQueryResult { + id: string; + score: number; + metadata: { + full_name: string; + description: string; + language: string; + stars: number; + tags: string[]; + }; +} + +export interface VectorizeStatus { + vectorCount: number; + dimensions: number; + indexName?: string; +} + +export class VectorSearchService { + private workerUrl: string; + private authToken: string; + + constructor(config: VectorSearchConfig) { + this.workerUrl = config.workerUrl.replace(/\/+$/, ''); + this.authToken = config.authToken; + } + + private async request(path: string, options: RequestInit = {}, signal?: AbortSignal): Promise { + const url = `${this.workerUrl}${path}`; + const headers: Record = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.authToken}`, + ...(options.headers as Record), + }; + + const response = await fetch(url, { ...options, headers, signal }); + + if (!response.ok) { + const errText = await response.text().catch(() => ''); + throw new Error(`Worker error ${response.status}: ${errText}`); + } + + const data = await response.json(); + if (data.success === false) { + throw new Error(data.error || 'Unknown worker error'); + } + return data as T; + } + + /** + * 批量 upsert 向量到 Vectorize + */ + async upsert(vectors: VectorizeVector[], signal?: AbortSignal): Promise<{ upserted: number }> { + return this.request<{ upserted: number }>('/upsert', { + method: 'POST', + body: JSON.stringify({ vectors }), + }, signal); + } + + /** + * 向量相似度查询 + */ + async query( + vector: number[], + options: { topK?: number; threshold?: number } = {}, + signal?: AbortSignal, + ): Promise { + const { topK = 20, threshold = 0.3 } = options; + const result = await this.request<{ matches: VectorQueryResult[] }>('/query', { + method: 'POST', + body: JSON.stringify({ vector, topK, threshold }), + }, signal); + return result.matches; + } + + /** + * 删除指定 ID 的向量 + */ + async delete(ids: string[], signal?: AbortSignal): Promise<{ deleted: number }> { + return this.request<{ deleted: number }>('/delete', { + method: 'POST', + body: JSON.stringify({ ids }), + }, signal); + } + + /** + * 清理不在 keepIds 列表中的向量(删除已 unstar 的仓库) + */ + async cleanup(keepIds: string[], signal?: AbortSignal): Promise<{ deleted: number }> { + return this.request<{ deleted: number }>('/cleanup', { + method: 'POST', + body: JSON.stringify({ keepIds }), + }, signal); + } + + /** + * 获取索引状态 + */ + async getStatus(): Promise { + return this.request('/status'); + } + + /** + * 测试 Worker 连通性 + */ + async testConnection(): Promise<{ success: boolean; vectorCount: number; dimensions: number; error?: string }> { + try { + const status = await this.getStatus(); + return { + success: true, + vectorCount: status.vectorCount, + dimensions: status.dimensions, + }; + } catch (error) { + return { + success: false, + vectorCount: 0, + dimensions: 0, + error: error instanceof Error ? error.message : String(error), + }; + } + } +} + +// ============================================================ +// 工具函数 +// ============================================================ + +/** + * 拼接仓库文本用于 embedding + * @param repo 仓库数据 + * @param readmeContent README 内容(可选) + * @param maxChars README 最大字符数,默认 6000 + */ +export function buildEmbeddingText(repo: Repository, readmeContent?: string, maxChars = 6000): string { + const parts = [ + repo.full_name, + repo.description || '', + repo.custom_description || '', + repo.ai_summary || '', + (repo.topics || []).join(', '), + (repo.ai_tags || []).join(', '), + (repo.custom_tags || []).join(', '), + repo.language || '', + ]; + // README 内容提供最丰富的语义信息 + // 跳过常见的装饰性徽章/图片头部 + if (readmeContent) { + const cleaned = readmeContent + .replace(/\[!\[.*?\]\(.*?\)\]\(.*?\)/g, '') // 移除链接徽章 [![...](...)](...) — 必须在图片之前 + .replace(/!\[.*?\]\(.*?\)/g, '') // 移除图片/徽章 ![...](...) + .replace(/<[^>]+>/g, ' ') // 移除 HTML 标签 + .replace(/\n{3,}/g, '\n\n') // 压缩多余空行 + .trim(); + const truncated = cleaned.slice(0, maxChars); + if (truncated) parts.push(truncated); + } + return parts.filter(Boolean).join('\n'); +} + +/** + * 全量重建向量索引 + * 遍历所有已分析仓库,分批生成 embedding 并 upsert 到 Worker + * @param readmeFetcher 可选:获取仓库 README 内容的函数 (owner, repo) => content + */ +export interface IndexProgress { + phase: 'readme' | 'embedding' | 'uploading'; + done: number; + total: number; +} + +export async function indexAllRepos( + repos: Repository[], + embeddingClient: EmbeddingClient, + vectorService: VectorSearchService, + options: { + batchSize?: number; + onProgress?: (progress: IndexProgress) => void; + signal?: AbortSignal; + readmeFetcher?: (owner: string, repo: string, signal?: AbortSignal) => Promise; + indexMode?: 'description' | 'readme'; + readmeMaxChars?: number; + } = {} +): Promise<{ indexed: number; skipped: number; errors: number; error?: string }> { + const { batchSize = 100, onProgress, signal, readmeFetcher, indexMode = 'readme', readmeMaxChars = 6000 } = options; + + if (!Number.isInteger(batchSize) || batchSize <= 0) { + throw new Error('batchSize must be a positive integer'); + } + + // 只索引已分析且未失败的仓库 + const indexable = repos.filter((r) => r.analyzed_at && !r.analysis_failed); + let indexed = 0; + let errors = 0; + let lastError = ''; + + // 仅在 readme 模式下获取 README 内容 + const shouldFetchReadme = indexMode === 'readme' && readmeFetcher; + const readmeCache = new Map(); + if (shouldFetchReadme) { + for (let i = 0; i < indexable.length; i++) { + const repo = indexable[i]; + if (signal?.aborted) throw new Error('Aborted'); + onProgress?.({ phase: 'readme', done: i, total: indexable.length }); + try { + const [owner, name] = repo.full_name.split('/'); + const readme = await readmeFetcher(owner, name, signal); + if (readme) readmeCache.set(repo.full_name, readme); + } catch { + // README 获取失败不影响索引 + } + } + onProgress?.({ phase: 'readme', done: indexable.length, total: indexable.length }); + } + + const totalBatches = Math.ceil(indexable.length / batchSize); + for (let i = 0; i < indexable.length; i += batchSize) { + if (signal?.aborted) { + throw new Error('Aborted'); + } + + const batch = indexable.slice(i, i + batchSize); + const texts = batch.map(repo => buildEmbeddingText(repo, readmeCache.get(repo.full_name), readmeMaxChars)); + + try { + // 1. 调用 Embedding API 生成向量 + const vectors = await embeddingClient.embed(texts, 'document', signal); + + // Validate that the embedding API returned the expected number of vectors + if (!Array.isArray(vectors) || vectors.length < batch.length) { + throw new Error( + `Embedding API returned ${vectors?.length ?? 0} vectors for ${batch.length} texts` + ); + } + + // 2. 组装 Vectorize 格式 + const vectorizeVectors: VectorizeVector[] = batch.map((repo, j) => ({ + id: String(repo.id), + values: vectors[j], + metadata: { + full_name: repo.full_name, + description: repo.description || '', + language: repo.language || '', + stars: repo.stargazers_count || 0, + tags: repo.ai_tags || [], + }, + })); + + // 3. upsert 到 Worker + const currentBatch = Math.floor(i / batchSize) + 1; + onProgress?.({ phase: 'uploading', done: currentBatch, total: totalBatches }); + await vectorService.upsert(vectorizeVectors, signal); + indexed += batch.length; + } catch (err) { + if (signal?.aborted || (err instanceof Error && err.message === 'Aborted')) { + throw new Error('Aborted'); + } + lastError = err instanceof Error ? err.message : String(err); + console.error(`Batch ${i}-${i + batch.length} failed:`, err); + errors += batch.length; + } + + const currentBatch = Math.floor(i / batchSize) + 1; + onProgress?.({ phase: 'embedding', done: currentBatch, total: totalBatches }); + } + + return { indexed, skipped: repos.length - indexable.length, errors, error: lastError || undefined }; +} diff --git a/src/store/useAppStore.ts b/src/store/useAppStore.ts index 961e3dda..a4084c07 100644 --- a/src/store/useAppStore.ts +++ b/src/store/useAppStore.ts @@ -10,6 +10,10 @@ import { ForkRepo, AIConfig, WebDAVConfig, + EmbeddingConfig, + VectorSearchConfig, + VectorSearchStatus, + VectorIndexingState, ProxyConfig, RpcDownloadConfig, SearchFilters, @@ -311,6 +315,18 @@ interface AppActions { setActiveWebDAVConfig: (id: string | null) => void; setWebDAVConfigs: (configs: WebDAVConfig[]) => void; setLastBackup: (timestamp: string) => void; + + // Embedding actions + addEmbeddingConfig: (config: EmbeddingConfig) => void; + updateEmbeddingConfig: (id: string, updates: Partial) => void; + deleteEmbeddingConfig: (id: string) => void; + setActiveEmbeddingConfig: (id: string | null) => void; + setEmbeddingConfigs: (configs: EmbeddingConfig[]) => void; + + // Vector Search actions + setVectorSearchConfig: (config: Partial) => void; + setVectorSearchStatus: (status: VectorSearchStatus | undefined) => void; + setVectorIndexingState: (state: Partial) => void; // Search actions setSearchFilters: (filters: Partial) => void; @@ -619,6 +635,25 @@ const normalizePersistedState = ( sortOrder: safePersisted.gistSearchFilters?.sortOrder || 'desc', }, webdavConfigs: Array.isArray(safePersisted.webdavConfigs) ? safePersisted.webdavConfigs : [], + embeddingConfigs: Array.isArray(safePersisted.embeddingConfigs) ? safePersisted.embeddingConfigs : [], + activeEmbeddingConfig: typeof safePersisted.activeEmbeddingConfig === 'string' ? safePersisted.activeEmbeddingConfig : null, + vectorSearchConfig: (() => { + const raw = safePersisted.vectorSearchConfig; + const embCfgs = Array.isArray(safePersisted.embeddingConfigs) ? safePersisted.embeddingConfigs : []; + const embIds = new Set(embCfgs.map((c: { id: string }) => c.id)); + if (raw && typeof raw === 'object') { + const configId = typeof raw.embeddingConfigId === 'string' ? raw.embeddingConfigId : ''; + return { + enabled: !!raw.enabled && embIds.has(configId), + workerUrl: typeof raw.workerUrl === 'string' ? raw.workerUrl : '', + authToken: typeof raw.authToken === 'string' ? raw.authToken : '', + embeddingConfigId: embIds.has(configId) ? configId : '', + indexMode: raw.indexMode === 'description' ? 'description' as const : 'readme' as const, + readmeMaxChars: typeof raw.readmeMaxChars === 'number' && raw.readmeMaxChars > 0 ? raw.readmeMaxChars : 6000, + }; + } + return { enabled: false, workerUrl: '', authToken: '', embeddingConfigId: '', indexMode: 'readme' as const, readmeMaxChars: 6000 }; + })(), customCategories: Array.isArray(safePersisted.customCategories) ? safePersisted.customCategories : [], hiddenDefaultCategoryIds: (() => { const persistedIds = (safePersisted as Record).hiddenDefaultCategoryIds; @@ -1012,6 +1047,10 @@ export const useAppStore = create()( analyzingRepositoryIds: new Set(), aiConfigs: [], activeAIConfig: null, + embeddingConfigs: [], + activeEmbeddingConfig: null, + vectorSearchConfig: { enabled: false, workerUrl: '', authToken: '', embeddingConfigId: '', indexMode: 'readme', readmeMaxChars: 6000 }, + vectorIndexingState: { isIndexing: false, phase: null, phaseDone: 0, phaseTotal: 0, result: null }, webdavConfigs: [], activeWebDAVConfig: null, lastBackup: null, @@ -1286,6 +1325,43 @@ export const useAppStore = create()( setWebDAVConfigs: (webdavConfigs) => set({ webdavConfigs }), setLastBackup: (lastBackup) => set({ lastBackup }), + // Embedding actions + addEmbeddingConfig: (config) => set((state) => ({ + embeddingConfigs: [...state.embeddingConfigs, config] + })), + updateEmbeddingConfig: (id, updates) => set((state) => ({ + embeddingConfigs: state.embeddingConfigs.map(config => + config.id === id ? { ...config, ...updates } : config + ) + })), + deleteEmbeddingConfig: (id) => set((state) => ({ + embeddingConfigs: state.embeddingConfigs.filter(config => config.id !== id), + activeEmbeddingConfig: state.activeEmbeddingConfig === id ? null : state.activeEmbeddingConfig, + vectorSearchConfig: state.vectorSearchConfig.embeddingConfigId === id + ? { ...state.vectorSearchConfig, embeddingConfigId: '', enabled: false } + : state.vectorSearchConfig, + })), + setActiveEmbeddingConfig: (activeEmbeddingConfig) => set({ activeEmbeddingConfig }), + setEmbeddingConfigs: (embeddingConfigs) => set((state) => { + const ids = new Set(embeddingConfigs.map(config => config.id)); + const activeEmbeddingConfig = state.activeEmbeddingConfig && ids.has(state.activeEmbeddingConfig) + ? state.activeEmbeddingConfig + : null; + const vectorSearchConfig = ids.has(state.vectorSearchConfig.embeddingConfigId) + ? state.vectorSearchConfig + : { ...state.vectorSearchConfig, embeddingConfigId: '', enabled: false }; + return { embeddingConfigs, activeEmbeddingConfig, vectorSearchConfig }; + }), + + // Vector Search actions + setVectorSearchConfig: (config) => set((state) => ({ + vectorSearchConfig: { ...state.vectorSearchConfig, ...config } + })), + setVectorSearchStatus: (status) => set({ vectorSearchStatus: status }), + setVectorIndexingState: (indexingState) => set((state) => ({ + vectorIndexingState: { ...state.vectorIndexingState, ...indexingState } + })), + // Search actions setSearchFilters: (filters) => set((state) => { const newFilters = { ...state.searchFilters, ...filters }; @@ -1935,6 +2011,13 @@ export const useAppStore = create()( aiConfigs: state.aiConfigs, activeAIConfig: state.activeAIConfig, + // 持久化Embedding配置 + embeddingConfigs: state.embeddingConfigs, + activeEmbeddingConfig: state.activeEmbeddingConfig, + + // 持久化向量搜索配置 + vectorSearchConfig: state.vectorSearchConfig, + // 持久化WebDAV配置 webdavConfigs: state.webdavConfigs, activeWebDAVConfig: state.activeWebDAVConfig, @@ -2173,6 +2256,27 @@ export const useAppStore = create()( (state as Record).headerMenuConfig = defaultHeaderMenuConfig; } + // 初始化 embeddingConfigs + if (state && !Array.isArray((state as Record).embeddingConfigs)) { + (state as Record).embeddingConfigs = []; + } + if (state && typeof (state as Record).activeEmbeddingConfig !== 'string') { + (state as Record).activeEmbeddingConfig = null; + } + + // 初始化 vectorSearchConfig + if (state && !(state as Record).vectorSearchConfig) { + (state as Record).vectorSearchConfig = { enabled: false, workerUrl: '', authToken: '', embeddingConfigId: '', indexMode: 'readme', readmeMaxChars: 6000 }; + } + // 迁移:为旧配置添加 indexMode 和 readmeMaxChars + if (state) { + const vsc = (state as Record).vectorSearchConfig as Record | undefined; + if (vsc && typeof vsc === 'object') { + if (vsc.indexMode !== 'description' && vsc.indexMode !== 'readme') vsc.indexMode = 'readme'; + if (typeof vsc.readmeMaxChars !== 'number' || vsc.readmeMaxChars <= 0) vsc.readmeMaxChars = 6000; + } + } + return state as PersistedAppState; }, merge: (persistedState, currentState) => { diff --git a/src/types/index.ts b/src/types/index.ts index badf2b58..39608d26 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -206,6 +206,51 @@ export interface GistSearchFilters { } export type AIApiType = 'openai' | 'openai-responses' | 'claude' | 'gemini' | 'deepseek' | 'mimo' | 'openai-compatible'; + +// Embedding 提供商类型 +export type EmbeddingApiType = 'openai' | 'openai-compatible' | 'gemini' | 'cohere' | 'ollama' | 'siliconflow'; + +// Embedding 配置(结构与 AIConfig/WebDAVConfig 平行) +export interface EmbeddingConfig { + id: string; + name: string; + apiType: EmbeddingApiType; + baseUrl: string; + apiKey: string; + model: string; + dimensions: number; + isActive: boolean; + apiKeyStatus?: SecretStatus; +} + +// 索引内容模式 +export type VectorIndexMode = 'description' | 'readme'; + +// 向量搜索整体配置(持久化 + 同步,不含运行时状态) +export interface VectorSearchConfig { + enabled: boolean; + workerUrl: string; + authToken: string; + embeddingConfigId: string; + indexMode: VectorIndexMode; + readmeMaxChars: number; // README 截取字符数,默认 6000 +} + +export interface VectorSearchStatus { + connected: boolean; + vectorCount: number; + dimensions: number; + lastSyncAt?: string; + error?: string; +} + +export interface VectorIndexingState { + isIndexing: boolean; + phase: 'readme' | 'embedding' | 'uploading' | null; + phaseDone: number; + phaseTotal: number; + result: { indexed: number; skipped: number; errors: number; error?: string } | null; +} export type AIReasoningEffort = 'none' | 'low' | 'medium' | 'high' | 'xhigh'; export type MiMoPlan = 'api' | 'token-plan'; @@ -331,7 +376,16 @@ export interface AppState { // AI aiConfigs: AIConfig[]; activeAIConfig: string | null; - + + // Embedding + embeddingConfigs: EmbeddingConfig[]; + activeEmbeddingConfig: string | null; + + // Vector Search + vectorSearchConfig: VectorSearchConfig; + vectorSearchStatus?: VectorSearchStatus; + vectorIndexingState: VectorIndexingState; + // WebDAV webdavConfigs: WebDAVConfig[]; activeWebDAVConfig: string | null; diff --git a/src/utils/repoUtils.ts b/src/utils/repoUtils.ts new file mode 100644 index 00000000..683b3344 --- /dev/null +++ b/src/utils/repoUtils.ts @@ -0,0 +1,37 @@ +import { Repository, Category } from '../types'; +import { getAICategory, getDefaultCategory } from './categoryUtils'; + +/** + * 判断仓库是否被用户自定义编辑过 + * 逻辑与编辑页面一致:描述、标签、分类任一被修改即视为已编辑 + * 注意:分类锁定不算编辑 + */ +export function isRepoCustomized(repo: Repository, allCategories: Category[]): boolean { + // 描述:有自定义描述标记(包括明确清空),且内容与AI/原始不同 + const hasCustomDesc = repo.custom_description !== undefined; + const repoDesc = (repo.description || '').trim(); + const aiDesc = (repo.ai_summary || '').trim(); + const customDesc = (repo.custom_description || '').trim(); + const isDescEdited = hasCustomDesc && + (customDesc === '' || (customDesc !== repoDesc && customDesc !== aiDesc)); + + // 标签:有自定义标签标记(包括明确清空),且内容与AI/Topics不同 + const hasCustomTags = repo.custom_tags !== undefined; + const aiTags = repo.ai_tags || []; + const topics = repo.topics || []; + const customTags = repo.custom_tags || []; + const isTagsEdited = hasCustomTags && + (customTags.length === 0 || ( + JSON.stringify([...customTags].sort()) !== JSON.stringify([...aiTags].sort()) && + JSON.stringify([...customTags].sort()) !== JSON.stringify([...topics].sort()) + )); + + // 分类:有自定义分类标记(包括明确清空),且与AI/默认不一致 + const aiCat = getAICategory(repo, allCategories); + const defaultCat = getDefaultCategory(repo, allCategories); + const customCat = repo.custom_category; + const isCategoryEdited = customCat !== undefined && + (customCat === '' || (customCat !== aiCat && customCat !== defaultCat)); + + return isDescEdited || isTagsEdited || isCategoryEdited; +}