From 8bcd079c147e0a6c27bf79af9b47c3144a4ee706 Mon Sep 17 00:00:00 2001 From: AmintaCCCP Date: Wed, 24 Jun 2026 19:06:18 +0800 Subject: [PATCH 01/16] feat: add vector semantic search with Cloudflare Vectorize - Add EmbeddingClient supporting 6 providers: OpenAI, OpenAI-compatible, SiliconFlow, Gemini, Cohere, Ollama - Add VectorSearchService for Cloudflare Worker proxy communication - Add VectorSearchSettings UI with embedding config, worker config, status display, index management, and deploy guide - Add Cloudflare Worker (TS + JS) as pure Vectorize proxy - Add embedding_configs and vector_search_configs DB tables - Add CRUD routes for embedding and vector search configs - Add autoSync integration for new config types - Integrate vector search into SearchBar with auto-fallback - Refactor config routes into shared factory (registerEncryptedConfigRoutes) - Extract isRepoCustomized helper to eliminate 3x duplication Co-Authored-By: Claude --- cloudflare-worker/README.md | 150 ++ cloudflare-worker/package-lock.json | 1642 +++++++++++++++++ cloudflare-worker/package.json | 14 + cloudflare-worker/src/index.ts | 107 ++ cloudflare-worker/tsconfig.json | 14 + cloudflare-worker/worker.js | 92 + cloudflare-worker/wrangler.toml | 10 + server/src/db/schema.ts | 25 + server/src/routes/configs.ts | 505 +++-- src/components/SearchBar.tsx | 144 +- src/components/SettingsPanel.tsx | 11 +- .../settings/VectorSearchSettings.tsx | 728 ++++++++ src/components/settings/index.ts | 1 + src/services/autoSync.ts | 58 +- src/services/backendAdapter.ts | 58 +- src/services/vectorSearchService.ts | 388 ++++ src/store/useAppStore.ts | 66 + src/types/index.ts | 42 +- src/utils/repoUtils.ts | 37 + 19 files changed, 3841 insertions(+), 251 deletions(-) create mode 100644 cloudflare-worker/README.md create mode 100644 cloudflare-worker/package-lock.json create mode 100644 cloudflare-worker/package.json create mode 100644 cloudflare-worker/src/index.ts create mode 100644 cloudflare-worker/tsconfig.json create mode 100644 cloudflare-worker/worker.js create mode 100644 cloudflare-worker/wrangler.toml create mode 100644 src/components/settings/VectorSearchSettings.tsx create mode 100644 src/services/vectorSearchService.ts create mode 100644 src/utils/repoUtils.ts diff --git a/cloudflare-worker/README.md b/cloudflare-worker/README.md new file mode 100644 index 00000000..1c4533dd --- /dev/null +++ b/cloudflare-worker/README.md @@ -0,0 +1,150 @@ +# GitHub Stars Vectorize Worker + +极简 Cloudflare Worker,作为 Cloudflare Vectorize 的代理。前端负责 Embedding 生成,Worker 只负责向量的存/查/删。 + +--- + +## 部署方式一:Cloudflare 网页控制台(推荐新手) + +无需安装任何工具,全程在浏览器中完成。 + +### 第 1 步:创建 Vectorize 索引 + +1. 登录 [Cloudflare Dashboard](https://dash.cloudflare.com/) +2. 左侧菜单点击 **Storage & Databases** → **Vectorize** +3. 点击 **Create index**,填写: + - **Index name**: `github-stars` + - **Dimensions**: 根据你选择的 Embedding 模型填写(见下方维度参考) + - **Distance metric**: `Cosine` +4. 点击 **Create** 完成创建 + +> ⚠️ **维度必须与 Embedding 模型一致**,创建后不可修改。 +> 常见选择:OpenAI `text-embedding-3-small` = **1536**,Ollama `nomic-embed-text` = **768** + +### 第 2 步:创建 Worker + +1. 左侧菜单点击 **Workers & Pages** +2. 点击 **Create** → **Create Worker** +3. 给 Worker 起个名字,如 `github-stars-vectorize` +4. 进入编辑器后,**删除右侧编辑区的所有默认代码** +5. 打开本项目的 [`worker.js`](./worker.js) 文件,**复制全部内容**粘贴到编辑器中 +6. 点击 **Save and deploy** + +### 第 3 步:绑定 Vectorize 索引 + +1. 进入刚创建的 Worker 页面 +2. 点击 **Settings** → **Bindings** → **Add** +3. 选择 **Vectorize**,填写: + - **Variable name**: `VECTORIZE`(必须大写) + - **Vectorize index**: 选择第 1 步创建的 `github-stars` +4. 点击 **Save** + +### 第 4 步:设置认证令牌 + +1. 在同一页面 **Settings** → **Variables and Secrets** → **Add** +2. 选择 **Secret** 类型(不是 Variable,Secret 更安全) +3. 填写: + - **Variable name**: `AUTH_TOKEN`(必须大写) + - **Value**: 输入一个安全的随机字符串,例如在终端运行 `openssl rand -hex 32` 生成 +4. 点击 **Save and deploy** + +### 第 5 步:获取 Worker URL + +部署成功后,页面顶部会显示 Worker 的 URL,格式类似: +``` +https://github-stars-vectorize..workers.dev +``` + +### 第 6 步:在 App 中配置 + +在 GitHub Stars Manager 的 **设置 → 向量搜索** 中: +- **Worker 地址**: 填入上一步的 URL +- **认证 Token**: 填入你设置的 AUTH_TOKEN 值 + +### 第 7 步:测试连接 + +在设置页点击 **测试 Worker 连接**,看到 "连接成功" 即可。 + +--- + +## 部署方式二:Wrangler CLI(推荐开发者) + +### 前置条件 + +- [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 +``` + +### 第 2 步:安装依赖 & 部署 + +```bash +npm install +wrangler secret put AUTH_TOKEN +# 输入一个安全的随机字符串,例如:openssl rand -hex 32 +npm run deploy +``` + +部署成功后,Wrangler 会输出 Worker 的 URL。 + +### 第 3 步:在 App 中配置 + +同方式一的第 6、7 步。 + +--- + +## 文件说明 + +| 文件 | 说明 | +|------|------| +| `src/index.ts` | Worker 源码(TypeScript,CLI 部署使用) | +| `worker.js` | Worker 代码(纯 JS,Web UI 粘贴使用) | +| `wrangler.toml` | Wrangler 部署配置 | +| `package.json` | 依赖声明 | + +> `src/index.ts` 和 `worker.js` 功能完全相同,只是语言不同。 +> Web UI 部署用 `worker.js`,CLI 部署用 `src/index.ts`。 + +## API 接口 + +| 方法 | 路径 | 说明 | +|------|------|------| +| POST | `/upsert` | 批量写入向量 | +| POST | `/query` | 向量相似度查询 | +| POST | `/delete` | 删除指定向量 | +| 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** | ✅ | 免费 | 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..d2d760f5 --- /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.0.0" + } +} diff --git a/cloudflare-worker/src/index.ts b/cloudflare-worker/src/index.ts new file mode 100644 index 00000000..fd4e779a --- /dev/null +++ b/cloudflare-worker/src/index.ts @@ -0,0 +1,107 @@ +/** + * 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 }); + } + + // 认证 + 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); + } + const matches = await env.VECTORIZE.query(vector, { + topK, + returnMetadata: true, + }); + // 过滤低分结果 + 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 }); + } + + // 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..2b9a5447 --- /dev/null +++ b/cloudflare-worker/worker.js @@ -0,0 +1,92 @@ +/** + * 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 }); + } + + // 认证 + 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); + } + const matches = await env.VECTORIZE.query(vector, { + topK, + returnMetadata: true, + }); + // 过滤低分结果 + 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 }); + } + + // 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..d744f12f --- /dev/null +++ b/cloudflare-worker/wrangler.toml @@ -0,0 +1,10 @@ +name = "github-stars-vectorize" +main = "src/index.ts" +compatibility_date = "2024-01-01" + +[vars] +AUTH_TOKEN = "" # 通过 wrangler secret put AUTH_TOKEN 设置 + +[[vectorize]] +binding = "VECTORIZE" +index_name = "github-stars" diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 7b7a01d1..9d462c5a 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -107,6 +107,31 @@ 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, + 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'); diff --git a/server/src/routes/configs.ts b/server/src/routes/configs.ts index 9f2c7c07..e660d830 100644 --- a/server/src/routes/configs.ts +++ b/server/src/routes/configs.ts @@ -32,7 +32,7 @@ function getMaskedSecretResult(params: { } } -// ── AI Configs ── +// ── Helpers ── function maskApiKey(key: string | null | undefined): string { if (!key || typeof key !== 'string') return ''; @@ -40,6 +40,165 @@ 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; +}): void { + const { router, basePath, table, secretColumn, label, logPrefix, insertSql, updateSql, insertParams, updateParams, shapeResponse } = 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 { + encryptedKey = existingKeys.get(String(c.id)) ?? ''; + } + + if (!encryptedKey) { + 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++; + } + + if (syncResult.skipped.length > 0) { + logger.warn(`${logPrefix}.bulk`, `Skipped ${label} configs with missing keys`, { skippedCount: syncResult.skipped.length, skipped: syncResult.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(`${logPrefix}.bulk`, `PUT ${basePath}/bulk error`, err); + if (errMsg === 'ALL_CONFIGS_SKIPPED') { + res.status(422).json({ + error: `All ${label} configs were skipped — check the errors field for per-config reasons`, + code: `SYNC_${logPrefix.toUpperCase().replace(/\./g, '_')}_ALL_SKIPPED`, + 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 { + 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 +258,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 +574,165 @@ 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 encryptedKey = apiKey && typeof apiKey === 'string' ? encrypt(apiKey, config.encryptionKey) : ''; + + const result = db.prepare( + 'INSERT INTO embedding_configs (name, api_type, base_url, api_key_encrypted, model, dimensions, is_active) VALUES (?, ?, ?, ?, ?, ?, ?)' + ).run(name ?? '', apiType ?? 'openai', baseUrl ?? '', encryptedKey, model ?? '', dimensions ?? 1536, isActive ? 1 : 0); + + res.status(201).json({ + id: result.lastInsertRowid, + 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, + }), +}); + +// ── 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: '' }); + 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 ?? '', + 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, status, lastSyncAt } = req.body as Record; + + let encryptedToken = ''; + if (authToken && typeof authToken === 'string' && !authToken.startsWith('***')) { + encryptedToken = encrypt(authToken, config.encryptionKey); + } else { + 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; + + db.prepare(` + INSERT OR REPLACE INTO vector_search_configs (id, enabled, worker_url, auth_token_encrypted, embedding_config_id, status_json, last_sync_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now')) + `).run('default', enabled ? 1 : 0, workerUrl ?? '', encryptedToken, embeddingConfigId ?? '', 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..64a57e85 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++; @@ -376,36 +348,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 +439,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 @@ -570,15 +496,61 @@ export const SearchBar: React.FC = () => { // Trigger AI search immediately setIsSearching(true); 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 生成查询向量 + const queryVectors = await embeddingClient.embed([searchQuery]); + if (queryVectors && queryVectors.length > 0) { + // 2. 前端将查询向量发送到 Worker + 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) { + const finalFiltered = applyFilters(scoredRepos); + console.log('🎯 Vector search results:', finalFiltered.length); + 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...'); diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx index a55cadec..ae66ee14 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; @@ -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..f990762a --- /dev/null +++ b/src/components/settings/VectorSearchSettings.tsx @@ -0,0 +1,728 @@ +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 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, + addEmbeddingConfig, + updateEmbeddingConfig, + setActiveEmbeddingConfig, + setVectorSearchConfig, + repositories, + } = 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); + + // 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); + + // Indexing state + const [isIndexing, setIsIndexing] = useState(false); + const [indexProgress, setIndexProgress] = useState({ done: 0, total: 0 }); + const [indexResult, setIndexResult] = useState<{ indexed: number; skipped: number; errors: number } | null>(null); + 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); + } + }, [activeConfig, formApiType, formBaseUrl, formApiKey, formModel, formDimensions, addEmbeddingConfig, updateEmbeddingConfig, setActiveEmbeddingConfig]); + + const handleSaveWorkerConfig = useCallback(() => { + setVectorSearchConfig({ + workerUrl: formWorkerUrl, + authToken: formAuthToken, + embeddingConfigId: activeEmbeddingConfig || '', + }); + }, [formWorkerUrl, formAuthToken, 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); + } catch (err) { + setWorkerTestResult({ + success: false, + vectorCount: 0, + dimensions: 0, + error: err instanceof Error ? err.message : String(err), + }); + } finally { + setTestingWorker(false); + } + }, [formWorkerUrl, formAuthToken]); + + const handleRebuildIndex = useCallback(async () => { + if (!activeConfig) return; + + const embeddingClient = new EmbeddingClient(activeConfig); + // Use form state (not store) so unsaved URL/token changes are respected + const vectorService = new VectorSearchService({ + enabled: true, + workerUrl: formWorkerUrl, + authToken: formAuthToken, + embeddingConfigId: activeEmbeddingConfig || '', + }); + const controller = new AbortController(); + setAbortController(controller); + setIsIndexing(true); + setIndexProgress({ done: 0, total: 0 }); + setIndexResult(null); + + try { + const result = await indexAllRepos(repositories, embeddingClient, vectorService, { + onProgress: (done, total) => setIndexProgress({ done, total }), + signal: controller.signal, + }); + setIndexResult(result); + setVectorSearchConfig({ + status: { + connected: true, + vectorCount: result.indexed, + dimensions: activeConfig.dimensions, + lastSyncAt: new Date().toISOString(), + }, + }); + } catch (err) { + if (err instanceof Error && err.message === 'Aborted') { + setIndexResult(null); + } else { + setIndexResult({ indexed: 0, skipped: 0, errors: repositories.length }); + } + } finally { + setIsIndexing(false); + setAbortController(null); + } + }, [activeConfig, formWorkerUrl, formAuthToken, activeEmbeddingConfig, repositories, setVectorSearchConfig]); + + const handleAbortIndexing = useCallback(() => { + abortController?.abort(); + }, [abortController]); + + const isConfigComplete = !!( + 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 === '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 & Save */} +
+ + +
+ + {/* 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')} +

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

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

+ +
+ + {isIndexing && ( + + )} +
+ + {/* Progress */} + {isIndexing && indexProgress.total > 0 && ( +
+
+ + {indexProgress.done}/{indexProgress.total} ({Math.round((indexProgress.done / indexProgress.total) * 100)}%) + +
+
+
+
+
+ )} + + {/* Result */} + {indexResult && ( +
+ {t('索引完成', 'Indexing complete')}: {indexResult.indexed} {t('已索引', 'indexed')}, {indexResult.skipped} {t('跳过', 'skipped')}, {indexResult.errors} {t('失败', 'errors')} +
+ )} +
+ + {/* Section 5: Deploy Guide */} +
+ + + {showDeployGuide && ( +
+ {/* 方式一:Web UI */} +
+

+ {t('方式一:网页控制台部署(推荐)', 'Method 1: Cloudflare Dashboard (Recommended)')} +

+
    +
  1. + {t('登录', 'Login to')}{' '} + + Cloudflare Dashboard + + {t(',进入 Storage & Databases → Vectorize → Create index', ', go to Storage & Databases → Vectorize → Create index')} +
  2. +
  3. + {t('索引名填', 'Index name:')} github-stars + {t(',维度填', ', Dimensions:')} {formDimensions} + {t(',距离度量选 Cosine', ', Distance metric: Cosine')} +
  4. +
  5. + {t('进入 Workers & Pages → Create → Create Worker,删除默认代码,粘贴', 'Go to Workers & Pages → Create → Create Worker, delete default code, paste')}{' '} + worker.js + {t('内容,Save and deploy', ' content, Save and deploy')} +
  6. +
  7. + {t('进入 Worker → Settings → Bindings → Add → Vectorize,变量名填', 'Go to Worker → Settings → Bindings → Add → Vectorize, Variable name:')}{' '} + VECTORIZE + {t(',选择刚创建的索引', ', select the index you just created')} +
  8. +
  9. + {t('Settings → Variables and Secrets → Add → Secret,变量名填', 'Settings → Variables and Secrets → Add → Secret, Variable name:')}{' '} + AUTH_TOKEN + {t(',值填一个随机字符串', ', value: a random string')} +
  10. +
  11. {t('复制页面顶部的 Worker URL,填入上方 Worker 地址', 'Copy the Worker URL from the top of the page and paste it above')}
  12. +
+
+ + {/* 方式二:CLI */} +
+

+ {t('方式二:Wrangler CLI 部署', 'Method 2: Wrangler CLI')} +

+
    +
  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( + '详细部署指南请参考 cloudflare-worker/README.md', + '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..61bd252d 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,26 @@ 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') { + state.setVectorSearchConfig(vectorSearchResult.value); + _lastHash.vectorSearch = hashes.vectorSearch; + } // Sync active selections from settings if (changed.settings && settingsResult.status === 'fulfilled') { const settings = settingsResult.value; @@ -199,6 +239,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 +315,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 +329,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 +345,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 +414,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..a950a82d --- /dev/null +++ b/src/services/vectorSearchService.ts @@ -0,0 +1,388 @@ +/** + * 向量语义搜索服务 + * + * 1. EmbeddingClient — 调用用户配置的 Embedding API 生成向量 + * 2. VectorSearchService — 与 Cloudflare Worker 通信(存/查/删向量) + */ + +import type { EmbeddingConfig, VectorSearchConfig, Repository } from '../types'; + +// ============================================================ +// EmbeddingClient +// ============================================================ + +export class EmbeddingClient { + constructor(private config: EmbeddingConfig) {} + + /** + * 批量生成 embedding 向量 + */ + async embed(texts: string[]): Promise { + switch (this.config.apiType) { + case 'openai': + case 'openai-compatible': + case 'siliconflow': + return this.embedOpenAICompatible(texts); + case 'ollama': + return this.embedOllama(texts); + case 'gemini': + return this.embedGemini(texts); + case 'cohere': + return this.embedCohere(texts); + 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[]): 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 }), + }); + + 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[]): 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 }), + }); + + 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[]): Promise { + const baseUrl = this.config.baseUrl.replace(/\/+$/, ''); + const url = `${baseUrl}/v1beta/models/${this.config.model}:batchEmbedContents?key=${this.config.apiKey}`; + + 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 }] }, + })), + }), + }); + + 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[]): 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: 'search_document', + }), + }); + + 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 = {}): 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 }); + + 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[]): Promise<{ upserted: number }> { + return this.request<{ upserted: number }>('/upsert', { + method: 'POST', + body: JSON.stringify({ vectors }), + }); + } + + /** + * 向量相似度查询 + */ + async query( + vector: number[], + options: { topK?: number; threshold?: number } = {} + ): Promise { + const { topK = 20, threshold = 0.3 } = options; + const result = await this.request<{ matches: VectorQueryResult[] }>('/query', { + method: 'POST', + body: JSON.stringify({ vector, topK, threshold }), + }); + return result.matches; + } + + /** + * 删除指定 ID 的向量 + */ + async delete(ids: string[]): Promise<{ deleted: number }> { + return this.request<{ deleted: number }>('/delete', { + method: 'POST', + body: JSON.stringify({ ids }), + }); + } + + /** + * 获取索引状态 + */ + 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 + */ +export function buildEmbeddingText(repo: Repository): 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 || '', + ]; + return parts.filter(Boolean).join('\n'); +} + +/** + * 全量重建向量索引 + * 遍历所有已分析仓库,分批生成 embedding 并 upsert 到 Worker + */ +export async function indexAllRepos( + repos: Repository[], + embeddingClient: EmbeddingClient, + vectorService: VectorSearchService, + options: { + batchSize?: number; + onProgress?: (done: number, total: number) => void; + signal?: AbortSignal; + } = {} +): Promise<{ indexed: number; skipped: number; errors: number }> { + const { batchSize = 100, onProgress, signal } = options; + + // 只索引已分析且未失败的仓库 + const indexable = repos.filter((r) => r.analyzed_at && !r.analysis_failed); + let indexed = 0; + let errors = 0; + + 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(buildEmbeddingText); + + try { + // 1. 调用 Embedding API 生成向量 + const vectors = await embeddingClient.embed(texts); + + // 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 + await vectorService.upsert(vectorizeVectors); + indexed += batch.length; + } catch (err) { + if (signal?.aborted || (err instanceof Error && err.message === 'Aborted')) { + throw new Error('Aborted'); + } + console.error(`Batch ${i}-${i + batch.length} failed:`, err); + errors += batch.length; + } + + onProgress?.(Math.min(i + batchSize, indexable.length), indexable.length); + } + + return { indexed, skipped: repos.length - indexable.length, errors }; +} diff --git a/src/store/useAppStore.ts b/src/store/useAppStore.ts index 961e3dda..c32e90cf 100644 --- a/src/store/useAppStore.ts +++ b/src/store/useAppStore.ts @@ -10,6 +10,8 @@ import { ForkRepo, AIConfig, WebDAVConfig, + EmbeddingConfig, + VectorSearchConfig, ProxyConfig, RpcDownloadConfig, SearchFilters, @@ -311,6 +313,16 @@ 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; // Search actions setSearchFilters: (filters: Partial) => void; @@ -619,6 +631,16 @@ 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: safePersisted.vectorSearchConfig && typeof safePersisted.vectorSearchConfig === 'object' + ? { + enabled: !!safePersisted.vectorSearchConfig.enabled, + workerUrl: typeof safePersisted.vectorSearchConfig.workerUrl === 'string' ? safePersisted.vectorSearchConfig.workerUrl : '', + authToken: typeof safePersisted.vectorSearchConfig.authToken === 'string' ? safePersisted.vectorSearchConfig.authToken : '', + embeddingConfigId: typeof safePersisted.vectorSearchConfig.embeddingConfigId === 'string' ? safePersisted.vectorSearchConfig.embeddingConfigId : '', + } + : { enabled: false, workerUrl: '', authToken: '', embeddingConfigId: '' }, customCategories: Array.isArray(safePersisted.customCategories) ? safePersisted.customCategories : [], hiddenDefaultCategoryIds: (() => { const persistedIds = (safePersisted as Record).hiddenDefaultCategoryIds; @@ -1012,6 +1034,9 @@ export const useAppStore = create()( analyzingRepositoryIds: new Set(), aiConfigs: [], activeAIConfig: null, + embeddingConfigs: [], + activeEmbeddingConfig: null, + vectorSearchConfig: { enabled: false, workerUrl: '', authToken: '', embeddingConfigId: '' }, webdavConfigs: [], activeWebDAVConfig: null, lastBackup: null, @@ -1286,6 +1311,27 @@ 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 + })), + setActiveEmbeddingConfig: (activeEmbeddingConfig) => set({ activeEmbeddingConfig }), + setEmbeddingConfigs: (embeddingConfigs) => set({ embeddingConfigs }), + + // Vector Search actions + setVectorSearchConfig: (config) => set((state) => ({ + vectorSearchConfig: { ...state.vectorSearchConfig, ...config } + })), + // Search actions setSearchFilters: (filters) => set((state) => { const newFilters = { ...state.searchFilters, ...filters }; @@ -1935,6 +1981,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 +2226,19 @@ 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: '' }; + } + return state as PersistedAppState; }, merge: (persistedState, currentState) => { diff --git a/src/types/index.ts b/src/types/index.ts index badf2b58..cc8ae409 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -206,6 +206,39 @@ 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 interface VectorSearchConfig { + enabled: boolean; + workerUrl: string; + authToken: string; + embeddingConfigId: string; + status?: VectorSearchStatus; +} + +export interface VectorSearchStatus { + connected: boolean; + vectorCount: number; + dimensions: number; + lastSyncAt?: string; + error?: string; +} export type AIReasoningEffort = 'none' | 'low' | 'medium' | 'high' | 'xhigh'; export type MiMoPlan = 'api' | 'token-plan'; @@ -331,7 +364,14 @@ export interface AppState { // AI aiConfigs: AIConfig[]; activeAIConfig: string | null; - + + // Embedding + embeddingConfigs: EmbeddingConfig[]; + activeEmbeddingConfig: string | null; + + // Vector Search + vectorSearchConfig: VectorSearchConfig; + // 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; +} From 8425b3c45a620cd03cb990fbfabdc22b65a12931 Mon Sep 17 00:00:00 2001 From: AmintaCCCP Date: Wed, 24 Jun 2026 19:23:21 +0800 Subject: [PATCH 02/16] fix: address CodeRabbitAI and Gemini Code Assist review findings Security: - Worker: reject requests when AUTH_TOKEN is empty/unset - Remove plaintext AUTH_TOKEN from wrangler.toml [vars] Functional: - Add purpose parameter for Cohere/Gemini (search_document vs search_query) - Pass 'query' purpose in SearchBar vector search - Preserve vector similarity ranking after applyFilters - Generate proper UUID for embedding config POST (TEXT PK) - Allow keyless embedding configs in factory (Ollama) - Clear vectorSearchConfig when embedding config is deleted - Preserve authToken on decrypt failure in autoSync - Use form state for EmbeddingClient in handleRebuildIndex - Add vectorSearch to VALID_TABS runtime allowlist - Add Gemini/Cohere URL placeholders in settings UI - Add language tag to README code fence Co-Authored-By: Claude --- cloudflare-worker/README.md | 2 +- cloudflare-worker/src/index.ts | 5 ++++- cloudflare-worker/worker.js | 5 ++++- cloudflare-worker/wrangler.toml | 3 +-- server/src/routes/configs.ts | 17 +++++++++++------ src/components/SearchBar.tsx | 7 +++++-- src/components/SettingsPanel.tsx | 2 +- .../settings/VectorSearchSettings.tsx | 15 +++++++++++++-- src/services/autoSync.ts | 11 ++++++++++- src/services/vectorSearchService.ts | 15 +++++++++------ src/store/useAppStore.ts | 16 ++++++++++++++-- 11 files changed, 73 insertions(+), 25 deletions(-) diff --git a/cloudflare-worker/README.md b/cloudflare-worker/README.md index 1c4533dd..cd96cf6a 100644 --- a/cloudflare-worker/README.md +++ b/cloudflare-worker/README.md @@ -51,7 +51,7 @@ ### 第 5 步:获取 Worker URL 部署成功后,页面顶部会显示 Worker 的 URL,格式类似: -``` +```text https://github-stars-vectorize..workers.dev ``` diff --git a/cloudflare-worker/src/index.ts b/cloudflare-worker/src/index.ts index fd4e779a..256e2a29 100644 --- a/cloudflare-worker/src/index.ts +++ b/cloudflare-worker/src/index.ts @@ -45,7 +45,10 @@ export default { } // 认证 - const token = request.headers.get('Authorization')?.replace('Bearer ', ''); + 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); } diff --git a/cloudflare-worker/worker.js b/cloudflare-worker/worker.js index 2b9a5447..b3508d40 100644 --- a/cloudflare-worker/worker.js +++ b/cloudflare-worker/worker.js @@ -30,7 +30,10 @@ export default { } // 认证 - const token = request.headers.get('Authorization')?.replace('Bearer ', ''); + 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); } diff --git a/cloudflare-worker/wrangler.toml b/cloudflare-worker/wrangler.toml index d744f12f..79a78cf0 100644 --- a/cloudflare-worker/wrangler.toml +++ b/cloudflare-worker/wrangler.toml @@ -2,8 +2,7 @@ name = "github-stars-vectorize" main = "src/index.ts" compatibility_date = "2024-01-01" -[vars] -AUTH_TOKEN = "" # 通过 wrangler secret put AUTH_TOKEN 设置 +# AUTH_TOKEN 通过 wrangler secret put AUTH_TOKEN 设置,不要写在 [vars] 中 [[vectorize]] binding = "VECTORIZE" diff --git a/server/src/routes/configs.ts b/server/src/routes/configs.ts index e660d830..632c23b5 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'; @@ -59,8 +60,10 @@ function registerEncryptedConfigRoutes(opts: { 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 } = opts; + 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) => { @@ -103,7 +106,7 @@ function registerEncryptedConfigRoutes(opts: { encryptedKey = existingKeys.get(String(c.id)) ?? ''; } - if (!encryptedKey) { + if (!encryptedKey && requiresSecret) { syncResult.skipped.push({ id: String(c.id), name: String(c.name ?? ''), @@ -615,14 +618,15 @@ router.post('/api/configs/embedding', (req, res) => { 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) : ''; - const result = db.prepare( - 'INSERT INTO embedding_configs (name, api_type, base_url, api_key_encrypted, model, dimensions, is_active) VALUES (?, ?, ?, ?, ?, ?, ?)' - ).run(name ?? '', apiType ?? 'openai', baseUrl ?? '', encryptedKey, model ?? '', dimensions ?? 1536, isActive ? 1 : 0); + 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: result.lastInsertRowid, + id, name, apiType, baseUrl, @@ -659,6 +663,7 @@ registerEncryptedConfigRoutes({ 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 ── diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx index 64a57e85..aed560da 100644 --- a/src/components/SearchBar.tsx +++ b/src/components/SearchBar.tsx @@ -512,7 +512,7 @@ export const SearchBar: React.FC = () => { const vectorService = new VectorSearchService(vsConfig); // 1. 前端调用 Embedding API 生成查询向量 - const queryVectors = await embeddingClient.embed([searchQuery]); + const queryVectors = await embeddingClient.embed([searchQuery], 'query'); if (queryVectors && queryVectors.length > 0) { // 2. 前端将查询向量发送到 Worker const vectorResults = await vectorService.query(queryVectors[0], { topK: 30, threshold: 0.3 }); @@ -530,7 +530,10 @@ export const SearchBar: React.FC = () => { .map(item => item.repo); if (scoredRepos.length > 0) { - const finalFiltered = applyFilters(scoredRepos); + // Re-sort by similarity score after filtering, since applyFilters may reorder + const finalFiltered = applyFilters([...scoredRepos]).sort( + (a, b) => (scoreMap.get(String(b.id)) ?? 0) - (scoreMap.get(String(a.id)) ?? 0) + ); console.log('🎯 Vector search results:', finalFiltered.length); setSearchResults(finalFiltered); setSearchFilters({ query: searchQuery }); diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx index ae66ee14..e73640b9 100644 --- a/src/components/SettingsPanel.tsx +++ b/src/components/SettingsPanel.tsx @@ -258,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']), [] ); diff --git a/src/components/settings/VectorSearchSettings.tsx b/src/components/settings/VectorSearchSettings.tsx index f990762a..f8409340 100644 --- a/src/components/settings/VectorSearchSettings.tsx +++ b/src/components/settings/VectorSearchSettings.tsx @@ -185,8 +185,15 @@ export const VectorSearchSettings: React.FC = ({ t }) const handleRebuildIndex = useCallback(async () => { if (!activeConfig) return; - const embeddingClient = new EmbeddingClient(activeConfig); - // Use form state (not store) so unsaved URL/token changes are respected + // Use form state (not store) so unsaved URL/token/model changes are respected + const embeddingClient = new EmbeddingClient({ + ...activeConfig, + apiType: formApiType, + baseUrl: formBaseUrl, + apiKey: formApiKey, + model: formModel, + dimensions: formDimensions, + }); const vectorService = new VectorSearchService({ enabled: true, workerUrl: formWorkerUrl, @@ -327,6 +334,10 @@ export const VectorSearchSettings: React.FC = ({ t }) ? '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' diff --git a/src/services/autoSync.ts b/src/services/autoSync.ts index 61bd252d..01e51b25 100644 --- a/src/services/autoSync.ts +++ b/src/services/autoSync.ts @@ -227,7 +227,16 @@ export async function syncFromBackend(): Promise { _lastHash.embedding = hashes.embedding; } if (changed.vectorSearch && vectorSearchResult.status === 'fulfilled') { - state.setVectorSearchConfig(vectorSearchResult.value); + 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 diff --git a/src/services/vectorSearchService.ts b/src/services/vectorSearchService.ts index a950a82d..a430549c 100644 --- a/src/services/vectorSearchService.ts +++ b/src/services/vectorSearchService.ts @@ -16,8 +16,9 @@ export class EmbeddingClient { /** * 批量生成 embedding 向量 + * @param purpose 'document' 用于索引, 'query' 用于搜索查询 */ - async embed(texts: string[]): Promise { + async embed(texts: string[], purpose: 'document' | 'query' = 'document'): Promise { switch (this.config.apiType) { case 'openai': case 'openai-compatible': @@ -26,9 +27,9 @@ export class EmbeddingClient { case 'ollama': return this.embedOllama(texts); case 'gemini': - return this.embedGemini(texts); + return this.embedGemini(texts, purpose); case 'cohere': - return this.embedCohere(texts); + return this.embedCohere(texts, purpose); default: throw new Error(`Unsupported embedding API type: ${this.config.apiType}`); } @@ -113,9 +114,10 @@ export class EmbeddingClient { // Google Gemini // POST /v1beta/models/{model}:batchEmbedContents // ---------------------------------------------------------- - private async embedGemini(texts: string[]): Promise { + private async embedGemini(texts: string[], purpose: 'document' | 'query' = 'document'): 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', @@ -124,6 +126,7 @@ export class EmbeddingClient { requests: texts.map((text) => ({ model: `models/${this.config.model}`, content: { parts: [{ text }] }, + taskType, })), }), }); @@ -141,7 +144,7 @@ export class EmbeddingClient { // Cohere // POST /v1/embed // ---------------------------------------------------------- - private async embedCohere(texts: string[]): Promise { + private async embedCohere(texts: string[], purpose: 'document' | 'query' = 'document'): Promise { const url = `${this.config.baseUrl.replace(/\/+$/, '')}/v1/embed`; const response = await fetch(url, { @@ -153,7 +156,7 @@ export class EmbeddingClient { body: JSON.stringify({ model: this.config.model, texts, - input_type: 'search_document', + input_type: purpose === 'query' ? 'search_query' : 'search_document', }), }); diff --git a/src/store/useAppStore.ts b/src/store/useAppStore.ts index c32e90cf..b940f419 100644 --- a/src/store/useAppStore.ts +++ b/src/store/useAppStore.ts @@ -1322,10 +1322,22 @@ export const useAppStore = create()( })), deleteEmbeddingConfig: (id) => set((state) => ({ embeddingConfigs: state.embeddingConfigs.filter(config => config.id !== id), - activeEmbeddingConfig: state.activeEmbeddingConfig === id ? null : state.activeEmbeddingConfig + 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({ embeddingConfigs }), + 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) => ({ From 6d95b6c3dbaa10d8415f680295e1401353cfbfe7 Mon Sep 17 00:00:00 2001 From: AmintaCCCP Date: Wed, 24 Jun 2026 19:39:23 +0800 Subject: [PATCH 03/16] fix: address remaining CodeRabbit review findings - Fix filter effect overwriting vector results (skipNextTextSearchRef) - Add /cleanup endpoint to Worker for stale vector removal - Thread AbortSignal through all fetch calls (embed + worker requests) - Split VectorSearchStatus out of VectorSearchConfig (runtime-only) - Cleanup stale vectors before rebuild in handleRebuildIndex Co-Authored-By: Claude --- cloudflare-worker/src/index.ts | 27 ++++++++++ cloudflare-worker/worker.js | 26 +++++++++ src/components/SearchBar.tsx | 7 +++ .../settings/VectorSearchSettings.tsx | 39 ++++++++------ src/services/vectorSearchService.ts | 53 ++++++++++++------- src/store/useAppStore.ts | 3 ++ src/types/index.ts | 4 +- 7 files changed, 123 insertions(+), 36 deletions(-) diff --git a/cloudflare-worker/src/index.ts b/cloudflare-worker/src/index.ts index 256e2a29..91f99633 100644 --- a/cloudflare-worker/src/index.ts +++ b/cloudflare-worker/src/index.ts @@ -91,6 +91,33 @@ export default { 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); + // Query with a zero vector to get a broad sample of existing vectors + const zeroVector = new Array(1536).fill(0); + const info = await env.VECTORIZE.describe(); + const sampleSize = Math.min(info.vectorCount ?? 0, 10000); + if (sampleSize === 0) { + return jsonResponse({ success: true, deleted: 0 }); + } + const existing = await env.VECTORIZE.query(zeroVector, { + topK: sampleSize, + returnMetadata: false, + }); + const staleIds = existing.matches + .filter((m) => !keepSet.has(m.id)) + .map((m) => m.id); + if (staleIds.length > 0) { + await env.VECTORIZE.deleteByIds(staleIds); + } + return jsonResponse({ success: true, deleted: staleIds.length }); + } + // GET /status — 返回索引信息 if (request.method === 'GET' && url.pathname === '/status') { const info = await env.VECTORIZE.describe(); diff --git a/cloudflare-worker/worker.js b/cloudflare-worker/worker.js index b3508d40..89504283 100644 --- a/cloudflare-worker/worker.js +++ b/cloudflare-worker/worker.js @@ -76,6 +76,32 @@ export default { 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 zeroVector = new Array(1536).fill(0); + const info = await env.VECTORIZE.describe(); + const sampleSize = Math.min(info.vectorCount ?? 0, 10000); + if (sampleSize === 0) { + return jsonResponse({ success: true, deleted: 0 }); + } + const existing = await env.VECTORIZE.query(zeroVector, { + topK: sampleSize, + returnMetadata: false, + }); + const staleIds = existing.matches + .filter((m) => !keepSet.has(m.id)) + .map((m) => m.id); + if (staleIds.length > 0) { + await env.VECTORIZE.deleteByIds(staleIds); + } + return jsonResponse({ success: true, deleted: staleIds.length }); + } + // GET /status — 返回索引信息 if (request.method === 'GET' && url.pathname === '/status') { const info = await env.VECTORIZE.describe(); diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx index aed560da..d8940301 100644 --- a/src/components/SearchBar.tsx +++ b/src/components/SearchBar.tsx @@ -166,6 +166,7 @@ export const SearchBar: React.FC = () => { const [searchSuggestions, setSearchSuggestions] = useState([]); const [showSuggestions, setShowSuggestions] = useState(false); const searchInputRef = useRef(null); + const skipNextTextSearchRef = useRef(false); 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'; @@ -208,6 +209,11 @@ export const SearchBar: React.FC = () => { useEffect(() => { const performSearch = async () => { + // Skip if vector search just set results + if (skipNextTextSearchRef.current) { + skipNextTextSearchRef.current = false; + return; + } if (!searchFilters.query) { performBasicFilter(); } else { @@ -535,6 +541,7 @@ export const SearchBar: React.FC = () => { (a, b) => (scoreMap.get(String(b.id)) ?? 0) - (scoreMap.get(String(a.id)) ?? 0) ); console.log('🎯 Vector search results:', finalFiltered.length); + skipNextTextSearchRef.current = true; setSearchResults(finalFiltered); setSearchFilters({ query: searchQuery }); return; diff --git a/src/components/settings/VectorSearchSettings.tsx b/src/components/settings/VectorSearchSettings.tsx index f8409340..39072c77 100644 --- a/src/components/settings/VectorSearchSettings.tsx +++ b/src/components/settings/VectorSearchSettings.tsx @@ -47,10 +47,12 @@ export const VectorSearchSettings: React.FC = ({ t }) embeddingConfigs, activeEmbeddingConfig, vectorSearchConfig, + vectorSearchStatus, addEmbeddingConfig, updateEmbeddingConfig, setActiveEmbeddingConfig, setVectorSearchConfig, + setVectorSearchStatus, repositories, } = useAppStore(); @@ -207,18 +209,25 @@ export const VectorSearchSettings: React.FC = ({ t }) setIndexResult(null); try { + // 清理已 unstar 的仓库向量 + const keepIds = repositories.map(r => String(r.id)); + try { + await vectorService.cleanup(keepIds); + } catch { + // cleanup 失败不阻塞重建 + console.warn('Vector cleanup failed, continuing with rebuild'); + } + const result = await indexAllRepos(repositories, embeddingClient, vectorService, { onProgress: (done, total) => setIndexProgress({ done, total }), signal: controller.signal, }); setIndexResult(result); - setVectorSearchConfig({ - status: { - connected: true, - vectorCount: result.indexed, - dimensions: activeConfig.dimensions, - lastSyncAt: new Date().toISOString(), - }, + setVectorSearchStatus({ + connected: true, + vectorCount: result.indexed, + dimensions: activeConfig.dimensions, + lastSyncAt: new Date().toISOString(), }); } catch (err) { if (err instanceof Error && err.message === 'Aborted') { @@ -543,13 +552,13 @@ export const VectorSearchSettings: React.FC = ({ t })
- {vectorSearchConfig.status?.connected ? ( + {vectorSearchStatus?.connected ? ( ) : ( )} - {vectorSearchConfig.status?.connected + {vectorSearchStatus?.connected ? t('Worker 已连接', 'Worker connected') : t('Worker 未连接', 'Worker not connected')} @@ -564,29 +573,29 @@ export const VectorSearchSettings: React.FC = ({ t })
)} - {vectorSearchConfig.status?.vectorCount !== undefined && ( + {vectorSearchStatus?.vectorCount !== undefined && (
📊 - {t('索引向量数', 'Indexed vectors')}: {vectorSearchConfig.status.vectorCount.toLocaleString()} + {t('索引向量数', 'Indexed vectors')}: {vectorSearchStatus.vectorCount.toLocaleString()}
)} - {vectorSearchConfig.status?.dimensions !== undefined && ( + {vectorSearchStatus?.dimensions !== undefined && (
📐 - {t('向量维度', 'Vector dimensions')}: {vectorSearchConfig.status.dimensions.toLocaleString()} + {t('向量维度', 'Vector dimensions')}: {vectorSearchStatus.dimensions.toLocaleString()}
)} - {vectorSearchConfig.status?.lastSyncAt && ( + {vectorSearchStatus?.lastSyncAt && (
🕐 - {t('最后同步', 'Last sync')}: {new Date(vectorSearchConfig.status.lastSyncAt).toLocaleString()} + {t('最后同步', 'Last sync')}: {new Date(vectorSearchStatus.lastSyncAt).toLocaleString()}
)} diff --git a/src/services/vectorSearchService.ts b/src/services/vectorSearchService.ts index a430549c..ac4e8e75 100644 --- a/src/services/vectorSearchService.ts +++ b/src/services/vectorSearchService.ts @@ -18,18 +18,18 @@ export class EmbeddingClient { * 批量生成 embedding 向量 * @param purpose 'document' 用于索引, 'query' 用于搜索查询 */ - async embed(texts: string[], purpose: 'document' | 'query' = 'document'): Promise { + 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); + return this.embedOpenAICompatible(texts, signal); case 'ollama': - return this.embedOllama(texts); + return this.embedOllama(texts, signal); case 'gemini': - return this.embedGemini(texts, purpose); + return this.embedGemini(texts, purpose, signal); case 'cohere': - return this.embedCohere(texts, purpose); + return this.embedCohere(texts, purpose, signal); default: throw new Error(`Unsupported embedding API type: ${this.config.apiType}`); } @@ -58,7 +58,7 @@ export class EmbeddingClient { // OpenAI / OpenAI-compatible // POST /v1/embeddings or custom URL // ---------------------------------------------------------- - private async embedOpenAICompatible(texts: string[]): Promise { + private async embedOpenAICompatible(texts: string[], signal?: AbortSignal): Promise { const url = this.config.apiType === 'openai' || this.config.apiType === 'siliconflow' ? `${this.config.baseUrl.replace(/\/+$/, '')}/v1/embeddings` @@ -73,6 +73,7 @@ export class EmbeddingClient { method: 'POST', headers, body: JSON.stringify({ model: this.config.model, input: texts }), + signal, }); if (!response.ok) { @@ -91,13 +92,14 @@ export class EmbeddingClient { // Ollama 本地模型 // POST /api/embed // ---------------------------------------------------------- - private async embedOllama(texts: string[]): Promise { + 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) { @@ -114,7 +116,7 @@ export class EmbeddingClient { // Google Gemini // POST /v1beta/models/{model}:batchEmbedContents // ---------------------------------------------------------- - private async embedGemini(texts: string[], purpose: 'document' | 'query' = 'document'): Promise { + 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'; @@ -129,6 +131,7 @@ export class EmbeddingClient { taskType, })), }), + signal, }); if (!response.ok) { @@ -144,7 +147,7 @@ export class EmbeddingClient { // Cohere // POST /v1/embed // ---------------------------------------------------------- - private async embedCohere(texts: string[], purpose: 'document' | 'query' = 'document'): Promise { + 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, { @@ -158,6 +161,7 @@ export class EmbeddingClient { texts, input_type: purpose === 'query' ? 'search_query' : 'search_document', }), + signal, }); if (!response.ok) { @@ -213,7 +217,7 @@ export class VectorSearchService { this.authToken = config.authToken; } - private async request(path: string, options: RequestInit = {}): Promise { + private async request(path: string, options: RequestInit = {}, signal?: AbortSignal): Promise { const url = `${this.workerUrl}${path}`; const headers: Record = { 'Content-Type': 'application/json', @@ -221,7 +225,7 @@ export class VectorSearchService { ...(options.headers as Record), }; - const response = await fetch(url, { ...options, headers }); + const response = await fetch(url, { ...options, headers, signal }); if (!response.ok) { const errText = await response.text().catch(() => ''); @@ -238,11 +242,11 @@ export class VectorSearchService { /** * 批量 upsert 向量到 Vectorize */ - async upsert(vectors: VectorizeVector[]): Promise<{ upserted: number }> { + async upsert(vectors: VectorizeVector[], signal?: AbortSignal): Promise<{ upserted: number }> { return this.request<{ upserted: number }>('/upsert', { method: 'POST', body: JSON.stringify({ vectors }), - }); + }, signal); } /** @@ -250,24 +254,35 @@ export class VectorSearchService { */ async query( vector: number[], - options: { topK?: number; threshold?: 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[]): Promise<{ deleted: number }> { + 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); } /** @@ -351,7 +366,7 @@ export async function indexAllRepos( try { // 1. 调用 Embedding API 生成向量 - const vectors = await embeddingClient.embed(texts); + 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) { @@ -374,7 +389,7 @@ export async function indexAllRepos( })); // 3. upsert 到 Worker - await vectorService.upsert(vectorizeVectors); + await vectorService.upsert(vectorizeVectors, signal); indexed += batch.length; } catch (err) { if (signal?.aborted || (err instanceof Error && err.message === 'Aborted')) { diff --git a/src/store/useAppStore.ts b/src/store/useAppStore.ts index b940f419..6566e2ed 100644 --- a/src/store/useAppStore.ts +++ b/src/store/useAppStore.ts @@ -12,6 +12,7 @@ import { WebDAVConfig, EmbeddingConfig, VectorSearchConfig, + VectorSearchStatus, ProxyConfig, RpcDownloadConfig, SearchFilters, @@ -323,6 +324,7 @@ interface AppActions { // Vector Search actions setVectorSearchConfig: (config: Partial) => void; + setVectorSearchStatus: (status: VectorSearchStatus | undefined) => void; // Search actions setSearchFilters: (filters: Partial) => void; @@ -1343,6 +1345,7 @@ export const useAppStore = create()( setVectorSearchConfig: (config) => set((state) => ({ vectorSearchConfig: { ...state.vectorSearchConfig, ...config } })), + setVectorSearchStatus: (status) => set({ vectorSearchStatus: status }), // Search actions setSearchFilters: (filters) => set((state) => { diff --git a/src/types/index.ts b/src/types/index.ts index cc8ae409..96566687 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -223,13 +223,12 @@ export interface EmbeddingConfig { apiKeyStatus?: SecretStatus; } -// 向量搜索整体配置 +// 向量搜索整体配置(持久化 + 同步,不含运行时状态) export interface VectorSearchConfig { enabled: boolean; workerUrl: string; authToken: string; embeddingConfigId: string; - status?: VectorSearchStatus; } export interface VectorSearchStatus { @@ -371,6 +370,7 @@ export interface AppState { // Vector Search vectorSearchConfig: VectorSearchConfig; + vectorSearchStatus?: VectorSearchStatus; // WebDAV webdavConfigs: WebDAVConfig[]; From 877eab5f44d278ce7248f3ea1dda4b6f12763e03 Mon Sep 17 00:00:00 2001 From: AmintaCCCP Date: Wed, 24 Jun 2026 20:20:59 +0800 Subject: [PATCH 04/16] fix: improve vector search quality and UX Search quality: - Fetch README content during indexing (first 2000 chars) - buildEmbeddingText now includes README for richer semantic context - indexAllRepos accepts optional readmeFetcher callback Settings UI: - Save buttons show green 'Saved' feedback for 2 seconds - Auto-detect dimensions focuses the input to show the value Deploy guide: - Remove Cloudflare Dashboard deployment method (no Vectorize UI) - Add update/redeploy instructions - Add prominent warning about model change requiring index rebuild Co-Authored-By: Claude --- cloudflare-worker/README.md | 133 +++++++++--------- .../settings/VectorSearchSettings.tsx | 111 +++++++++------ src/services/vectorSearchService.ts | 30 +++- 3 files changed, 160 insertions(+), 114 deletions(-) diff --git a/cloudflare-worker/README.md b/cloudflare-worker/README.md index cd96cf6a..8f684d95 100644 --- a/cloudflare-worker/README.md +++ b/cloudflare-worker/README.md @@ -2,110 +2,104 @@ 极简 Cloudflare Worker,作为 Cloudflare Vectorize 的代理。前端负责 Embedding 生成,Worker 只负责向量的存/查/删。 ---- +## 前置条件 -## 部署方式一:Cloudflare 网页控制台(推荐新手) +- [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 索引 +### 1. 创建 Vectorize 索引 -1. 登录 [Cloudflare Dashboard](https://dash.cloudflare.com/) -2. 左侧菜单点击 **Storage & Databases** → **Vectorize** -3. 点击 **Create index**,填写: - - **Index name**: `github-stars` - - **Dimensions**: 根据你选择的 Embedding 模型填写(见下方维度参考) - - **Distance metric**: `Cosine` -4. 点击 **Create** 完成创建 +索引维度必须与你选择的 Embedding 模型一致: -> ⚠️ **维度必须与 Embedding 模型一致**,创建后不可修改。 -> 常见选择:OpenAI `text-embedding-3-small` = **1536**,Ollama `nomic-embed-text` = **768** +```bash +# OpenAI text-embedding-3-small (1536维) +npx wrangler vectorize create github-stars --dimensions=1536 --metric=cosine -### 第 2 步:创建 Worker +# Ollama nomic-embed-text (768维) +npx wrangler vectorize create github-stars --dimensions=768 --metric=cosine -1. 左侧菜单点击 **Workers & Pages** -2. 点击 **Create** → **Create Worker** -3. 给 Worker 起个名字,如 `github-stars-vectorize` -4. 进入编辑器后,**删除右侧编辑区的所有默认代码** -5. 打开本项目的 [`worker.js`](./worker.js) 文件,**复制全部内容**粘贴到编辑器中 -6. 点击 **Save and deploy** +# Cohere embed-multilingual-v3.0 (1024维) +npx wrangler vectorize create github-stars --dimensions=1024 --metric=cosine -### 第 3 步:绑定 Vectorize 索引 +# Gemini text-embedding-004 (768维) +npx wrangler vectorize create github-stars --dimensions=768 --metric=cosine -1. 进入刚创建的 Worker 页面 -2. 点击 **Settings** → **Bindings** → **Add** -3. 选择 **Vectorize**,填写: - - **Variable name**: `VECTORIZE`(必须大写) - - **Vectorize index**: 选择第 1 步创建的 `github-stars` -4. 点击 **Save** +# 硅基流动 BAAI/bge-large-zh-v1.5 (1024维) +npx wrangler vectorize create github-stars --dimensions=1024 --metric=cosine +``` -### 第 4 步:设置认证令牌 +### 2. 安装依赖 -1. 在同一页面 **Settings** → **Variables and Secrets** → **Add** -2. 选择 **Secret** 类型(不是 Variable,Secret 更安全) -3. 填写: - - **Variable name**: `AUTH_TOKEN`(必须大写) - - **Value**: 输入一个安全的随机字符串,例如在终端运行 `openssl rand -hex 32` 生成 -4. 点击 **Save and deploy** +```bash +npm install +``` -### 第 5 步:获取 Worker URL +### 3. 设置认证令牌 + +```bash +wrangler secret put AUTH_TOKEN +# 输入一个安全的随机字符串,例如:openssl rand -hex 32 +``` + +### 4. 部署 + +```bash +npm run deploy +``` -部署成功后,页面顶部会显示 Worker 的 URL,格式类似: +部署成功后,Wrangler 会输出 Worker 的 URL,格式类似: ```text https://github-stars-vectorize..workers.dev ``` -### 第 6 步:在 App 中配置 +### 5. 在 App 中配置 在 GitHub Stars Manager 的 **设置 → 向量搜索** 中: - **Worker 地址**: 填入上一步的 URL - **认证 Token**: 填入你设置的 AUTH_TOKEN 值 -### 第 7 步:测试连接 +### 6. 测试连接 在设置页点击 **测试 Worker 连接**,看到 "连接成功" 即可。 --- -## 部署方式二:Wrangler CLI(推荐开发者) - -### 前置条件 +## 更新部署(代码变更后) -- [Wrangler CLI](https://developers.cloudflare.com/workers/wrangler/install-and-update/) (`npm install -g wrangler`) -- 已登录 Wrangler (`wrangler login`) - -### 第 1 步:创建 Vectorize 索引 - -索引维度必须与你选择的 Embedding 模型一致: +当你更新了 Worker 代码(例如从 GitHub 拉取了新版本),需要重新部署: ```bash -# OpenAI text-embedding-3-small (1536维) -npx wrangler vectorize create github-stars --dimensions=1536 --metric=cosine +cd cloudflare-worker -# 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 +# 如果依赖有变更(package.json 更新了) +npm install -# Gemini text-embedding-004 (768维) -npx wrangler vectorize create github-stars --dimensions=768 --metric=cosine +# 重新部署 +npm run deploy ``` -### 第 2 步:安装依赖 & 部署 +> **注意**:更新部署**不需要**重新创建 Vectorize 索引,已有向量数据不受影响。 -```bash -npm install -wrangler secret put AUTH_TOKEN -# 输入一个安全的随机字符串,例如:openssl rand -hex 32 -npm run deploy -``` +--- + +## 更换 Embedding 模型 -部署成功后,Wrangler 会输出 Worker 的 URL。 +> ⚠️ **更换模型后必须重建索引!** 不同模型生成的向量维度不同,混用会导致查询失败。 -### 第 3 步:在 App 中配置 +步骤: +1. 在 App 设置中更换 Embedding 模型 +2. 如果新模型的维度与旧模型不同,需要**删除旧索引并创建新索引**: + ```bash + # 删除旧索引 + npx wrangler vectorize delete github-stars -同方式一的第 6、7 步。 + # 创建新索引(维度与新模型一致) + npx wrangler vectorize create github-stars --dimensions=1024 --metric=cosine + ``` +3. 在 App 中点击 **重建向量索引** --- @@ -114,13 +108,10 @@ npm run deploy | 文件 | 说明 | |------|------| | `src/index.ts` | Worker 源码(TypeScript,CLI 部署使用) | -| `worker.js` | Worker 代码(纯 JS,Web UI 粘贴使用) | +| `worker.js` | Worker 代码(纯 JS,备用) | | `wrangler.toml` | Wrangler 部署配置 | | `package.json` | 依赖声明 | -> `src/index.ts` 和 `worker.js` 功能完全相同,只是语言不同。 -> Web UI 部署用 `worker.js`,CLI 部署用 `src/index.ts`。 - ## API 接口 | 方法 | 路径 | 说明 | @@ -128,6 +119,7 @@ npm run deploy | POST | `/upsert` | 批量写入向量 | | POST | `/query` | 向量相似度查询 | | POST | `/delete` | 删除指定向量 | +| POST | `/cleanup` | 清理不在 keepIds 列表中的向量 | | GET | `/status` | 获取索引状态 | 所有请求需要 `Authorization: Bearer ` 头。 @@ -148,3 +140,4 @@ npm run dev | 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/src/components/settings/VectorSearchSettings.tsx b/src/components/settings/VectorSearchSettings.tsx index 39072c77..d8008f25 100644 --- a/src/components/settings/VectorSearchSettings.tsx +++ b/src/components/settings/VectorSearchSettings.tsx @@ -18,6 +18,7 @@ import { VectorSearchService, indexAllRepos, } from '../../services/vectorSearchService'; +import { GitHubApiService } from '../../services/githubApi'; import type { EmbeddingApiType, EmbeddingConfig } from '../../types'; interface VectorSearchSettingsProps { @@ -54,6 +55,7 @@ export const VectorSearchSettings: React.FC = ({ t }) setVectorSearchConfig, setVectorSearchStatus, repositories, + githubToken, } = useAppStore(); // Local form state for embedding config @@ -76,6 +78,10 @@ export const VectorSearchSettings: React.FC = ({ t }) 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 const [isIndexing, setIsIndexing] = useState(false); const [indexProgress, setIndexProgress] = useState({ done: 0, total: 0 }); @@ -123,6 +129,8 @@ export const VectorSearchSettings: React.FC = ({ t }) }); setActiveEmbeddingConfig(id); } + setEmbeddingSaved(true); + setTimeout(() => setEmbeddingSaved(false), 2000); }, [activeConfig, formApiType, formBaseUrl, formApiKey, formModel, formDimensions, addEmbeddingConfig, updateEmbeddingConfig, setActiveEmbeddingConfig]); const handleSaveWorkerConfig = useCallback(() => { @@ -131,6 +139,8 @@ export const VectorSearchSettings: React.FC = ({ t }) authToken: formAuthToken, embeddingConfigId: activeEmbeddingConfig || '', }); + setWorkerSaved(true); + setTimeout(() => setWorkerSaved(false), 2000); }, [formWorkerUrl, formAuthToken, activeEmbeddingConfig, setVectorSearchConfig]); const handleTestEmbedding = useCallback(async () => { @@ -218,9 +228,17 @@ export const VectorSearchSettings: React.FC = ({ t }) console.warn('Vector cleanup failed, continuing with rebuild'); } + 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: (done, total) => setIndexProgress({ done, total }), signal: controller.signal, + readmeFetcher, }); setIndexResult(result); setVectorSearchStatus({ @@ -418,7 +436,13 @@ export const VectorSearchSettings: React.FC = ({ t }) 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" />
@@ -520,9 +548,13 @@ export const VectorSearchSettings: React.FC = ({ t })
@@ -669,69 +701,66 @@ export const VectorSearchSettings: React.FC = ({ t }) {showDeployGuide && (
- {/* 方式一:Web UI */} + {/* 首次部署 */}

- {t('方式一:网页控制台部署(推荐)', 'Method 1: Cloudflare Dashboard (Recommended)')} + {t('首次部署', 'Initial Deployment')}

  1. - {t('登录', 'Login to')}{' '} - - Cloudflare Dashboard - - {t(',进入 Storage & Databases → Vectorize → Create index', ', go to Storage & Databases → Vectorize → Create index')} + npm install -g wrangler + {t(' 然后 ', ' then ')} + wrangler login
  2. - {t('索引名填', 'Index name:')} github-stars - {t(',维度填', ', Dimensions:')} {formDimensions} - {t(',距离度量选 Cosine', ', Distance metric: Cosine')} + + npx wrangler vectorize create github-stars --dimensions={formDimensions} --metric=cosine +
  3. - {t('进入 Workers & Pages → Create → Create Worker,删除默认代码,粘贴', 'Go to Workers & Pages → Create → Create Worker, delete default code, paste')}{' '} - worker.js - {t('内容,Save and deploy', ' content, Save and deploy')} + cd cloudflare-worker && npm install
  4. - {t('进入 Worker → Settings → Bindings → Add → Vectorize,变量名填', 'Go to Worker → Settings → Bindings → Add → Vectorize, Variable name:')}{' '} - VECTORIZE - {t(',选择刚创建的索引', ', select the index you just created')} + wrangler secret put AUTH_TOKEN
  5. - {t('Settings → Variables and Secrets → Add → Secret,变量名填', 'Settings → Variables and Secrets → Add → Secret, Variable name:')}{' '} - AUTH_TOKEN - {t(',值填一个随机字符串', ', value: a random string')} + npm run deploy
  6. -
  7. {t('复制页面顶部的 Worker URL,填入上方 Worker 地址', 'Copy the Worker URL from the top of the page and paste it above')}
- {/* 方式二:CLI */} + {/* 更新部署 */}

- {t('方式二:Wrangler CLI 部署', 'Method 2: Wrangler CLI')} + {t('更新部署(代码变更后)', 'Redeploy (after code changes)')}

  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 + cd cloudflare-worker
  8. npm run deploy + {t('(如果依赖有变更,先执行 ', ' (if dependencies changed, run ')} + npm install + {t(')', ')')}
+

+ {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' + )} +

diff --git a/src/services/vectorSearchService.ts b/src/services/vectorSearchService.ts index ac4e8e75..e4ec5cf4 100644 --- a/src/services/vectorSearchService.ts +++ b/src/services/vectorSearchService.ts @@ -320,8 +320,10 @@ export class VectorSearchService { /** * 拼接仓库文本用于 embedding + * @param repo 仓库数据 + * @param readmeContent README 内容(可选,截取前 2000 字符) */ -export function buildEmbeddingText(repo: Repository): string { +export function buildEmbeddingText(repo: Repository, readmeContent?: string): string { const parts = [ repo.full_name, repo.description || '', @@ -332,12 +334,18 @@ export function buildEmbeddingText(repo: Repository): string { (repo.custom_tags || []).join(', '), repo.language || '', ]; + // README 内容提供最丰富的语义信息,截取前 2000 字符避免超出 embedding 模型上下文 + if (readmeContent) { + const truncated = readmeContent.slice(0, 2000).trim(); + if (truncated) parts.push(truncated); + } return parts.filter(Boolean).join('\n'); } /** * 全量重建向量索引 * 遍历所有已分析仓库,分批生成 embedding 并 upsert 到 Worker + * @param readmeFetcher 可选:获取仓库 README 内容的函数 (owner, repo) => content */ export async function indexAllRepos( repos: Repository[], @@ -347,22 +355,38 @@ export async function indexAllRepos( batchSize?: number; onProgress?: (done: number, total: number) => void; signal?: AbortSignal; + readmeFetcher?: (owner: string, repo: string, signal?: AbortSignal) => Promise; } = {} ): Promise<{ indexed: number; skipped: number; errors: number }> { - const { batchSize = 100, onProgress, signal } = options; + const { batchSize = 100, onProgress, signal, readmeFetcher } = options; // 只索引已分析且未失败的仓库 const indexable = repos.filter((r) => r.analyzed_at && !r.analysis_failed); let indexed = 0; let errors = 0; + // 预先批量获取 README 内容(如果提供了 fetcher) + const readmeCache = new Map(); + if (readmeFetcher) { + for (const repo of indexable) { + if (signal?.aborted) throw new Error('Aborted'); + 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 获取失败不影响索引 + } + } + } + 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(buildEmbeddingText); + const texts = batch.map(repo => buildEmbeddingText(repo, readmeCache.get(repo.full_name))); try { // 1. 调用 Embedding API 生成向量 From f6677f2e26f877b8a087a8042f53cea739a41742 Mon Sep 17 00:00:00 2001 From: AmintaCCCP Date: Wed, 24 Jun 2026 20:28:12 +0800 Subject: [PATCH 05/16] fix: improve vector search quality and address review findings Search quality: - Increase README content from 2000 to 6000 chars - Strip decorative badges/images/HTML from README before embedding - Add AI reranking step after vector search (uses existing AI config) Worker /cleanup fix: - Cap topK to 100 (Vectorize limit) - Use info.dimensions for zero vector instead of hardcoded 1536 Validation: - Add batchSize validation in indexAllRepos (must be positive integer) Co-Authored-By: Claude --- cloudflare-worker/src/index.ts | 9 ++++----- cloudflare-worker/worker.js | 8 ++++---- src/components/SearchBar.tsx | 16 +++++++++++++++- src/services/vectorSearchService.ts | 17 ++++++++++++++--- 4 files changed, 37 insertions(+), 13 deletions(-) diff --git a/cloudflare-worker/src/index.ts b/cloudflare-worker/src/index.ts index 91f99633..55aae5de 100644 --- a/cloudflare-worker/src/index.ts +++ b/cloudflare-worker/src/index.ts @@ -98,15 +98,14 @@ export default { return jsonResponse({ success: false, error: 'keepIds array required' }, 400); } const keepSet = new Set(keepIds); - // Query with a zero vector to get a broad sample of existing vectors - const zeroVector = new Array(1536).fill(0); const info = await env.VECTORIZE.describe(); - const sampleSize = Math.min(info.vectorCount ?? 0, 10000); - if (sampleSize === 0) { + if ((info.vectorCount ?? 0) === 0) { return jsonResponse({ success: true, deleted: 0 }); } + // Vectorize topK 上限为 100,零向量维度必须与索引维度一致 + const zeroVector = new Array(info.dimensions ?? 1536).fill(0); const existing = await env.VECTORIZE.query(zeroVector, { - topK: sampleSize, + topK: 100, returnMetadata: false, }); const staleIds = existing.matches diff --git a/cloudflare-worker/worker.js b/cloudflare-worker/worker.js index 89504283..07cc0493 100644 --- a/cloudflare-worker/worker.js +++ b/cloudflare-worker/worker.js @@ -83,14 +83,14 @@ export default { return jsonResponse({ success: false, error: 'keepIds array required' }, 400); } const keepSet = new Set(keepIds); - const zeroVector = new Array(1536).fill(0); const info = await env.VECTORIZE.describe(); - const sampleSize = Math.min(info.vectorCount ?? 0, 10000); - if (sampleSize === 0) { + if ((info.vectorCount ?? 0) === 0) { return jsonResponse({ success: true, deleted: 0 }); } + // Vectorize topK 上限为 100,零向量维度必须与索引维度一致 + const zeroVector = new Array(info.dimensions ?? 1536).fill(0); const existing = await env.VECTORIZE.query(zeroVector, { - topK: sampleSize, + topK: 100, returnMetadata: false, }); const staleIds = existing.matches diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx index d8940301..ffe4a813 100644 --- a/src/components/SearchBar.tsx +++ b/src/components/SearchBar.tsx @@ -536,8 +536,22 @@ export const SearchBar: React.FC = () => { .map(item => item.repo); if (scoredRepos.length > 0) { + // 4. AI 校验:用 LLM 对向量搜索结果进行二次排序 + let reranked = scoredRepos; + const rerankConfig = aiConfigs.find(config => config.id === activeAIConfig); + if (rerankConfig) { + try { + const { AIService } = await import('../services/aiService'); + const rerankService = new AIService(rerankConfig, language); + reranked = await rerankService.searchRepositoriesWithReranking(scoredRepos, searchQuery); + console.log('🤖 AI reranked results:', reranked.length); + } catch (rerankError) { + console.warn('AI reranking failed, using vector order:', rerankError); + } + } + // Re-sort by similarity score after filtering, since applyFilters may reorder - const finalFiltered = applyFilters([...scoredRepos]).sort( + const finalFiltered = applyFilters([...reranked]).sort( (a, b) => (scoreMap.get(String(b.id)) ?? 0) - (scoreMap.get(String(a.id)) ?? 0) ); console.log('🎯 Vector search results:', finalFiltered.length); diff --git a/src/services/vectorSearchService.ts b/src/services/vectorSearchService.ts index e4ec5cf4..15ce4a34 100644 --- a/src/services/vectorSearchService.ts +++ b/src/services/vectorSearchService.ts @@ -321,7 +321,7 @@ export class VectorSearchService { /** * 拼接仓库文本用于 embedding * @param repo 仓库数据 - * @param readmeContent README 内容(可选,截取前 2000 字符) + * @param readmeContent README 内容(可选,截取前 6000 字符,跳过装饰性头部) */ export function buildEmbeddingText(repo: Repository, readmeContent?: string): string { const parts = [ @@ -334,9 +334,16 @@ export function buildEmbeddingText(repo: Repository, readmeContent?: string): st (repo.custom_tags || []).join(', '), repo.language || '', ]; - // README 内容提供最丰富的语义信息,截取前 2000 字符避免超出 embedding 模型上下文 + // README 内容提供最丰富的语义信息 + // 截取前 6000 字符,跳过常见的装饰性徽章/图片头部 if (readmeContent) { - const truncated = readmeContent.slice(0, 2000).trim(); + const cleaned = readmeContent + .replace(/!\[.*?\]\(.*?\)/g, '') // 移除图片/徽章 ![...](...) + .replace(/\[!\[.*?\]\(.*?\)\]\(.*?\)/g, '') // 移除链接徽章 [![...](...)](...) + .replace(/<[^>]+>/g, ' ') // 移除 HTML 标签 + .replace(/\n{3,}/g, '\n\n') // 压缩多余空行 + .trim(); + const truncated = cleaned.slice(0, 6000); if (truncated) parts.push(truncated); } return parts.filter(Boolean).join('\n'); @@ -360,6 +367,10 @@ export async function indexAllRepos( ): Promise<{ indexed: number; skipped: number; errors: number }> { const { batchSize = 100, onProgress, signal, readmeFetcher } = 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; From 44d119c0ad13c1cdfbef7277a7478365c0c8fbaf Mon Sep 17 00:00:00 2001 From: AmintaCCCP Date: Wed, 24 Jun 2026 20:37:56 +0800 Subject: [PATCH 06/16] fix: use listVectors pagination for /cleanup instead of query The previous /cleanup used query(topK: 100) which only returns the 100 nearest matches, missing stale vectors beyond that window. Now uses listVectors with cursor-based pagination to enumerate ALL vector IDs and delete those not in the keepIds list. Co-Authored-By: Claude --- cloudflare-worker/src/index.ts | 19 ++++++++++--------- cloudflare-worker/worker.js | 19 ++++++++++--------- 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/cloudflare-worker/src/index.ts b/cloudflare-worker/src/index.ts index 55aae5de..9e521f81 100644 --- a/cloudflare-worker/src/index.ts +++ b/cloudflare-worker/src/index.ts @@ -102,15 +102,16 @@ export default { if ((info.vectorCount ?? 0) === 0) { return jsonResponse({ success: true, deleted: 0 }); } - // Vectorize topK 上限为 100,零向量维度必须与索引维度一致 - const zeroVector = new Array(info.dimensions ?? 1536).fill(0); - const existing = await env.VECTORIZE.query(zeroVector, { - topK: 100, - returnMetadata: false, - }); - const staleIds = existing.matches - .filter((m) => !keepSet.has(m.id)) - .map((m) => m.id); + // 使用 listVectors 分页枚举所有向量 ID,找出不在 keepSet 中的 + let staleIds: string[] = []; + let cursor: string | undefined; + do { + const page = await env.VECTORIZE.listVectors({ limit: 1000, cursor }); + for (const v of page.vectors) { + if (!keepSet.has(v.id)) staleIds.push(v.id); + } + cursor = page.nextCursor ?? undefined; + } while (cursor); if (staleIds.length > 0) { await env.VECTORIZE.deleteByIds(staleIds); } diff --git a/cloudflare-worker/worker.js b/cloudflare-worker/worker.js index 07cc0493..e62cdc38 100644 --- a/cloudflare-worker/worker.js +++ b/cloudflare-worker/worker.js @@ -87,15 +87,16 @@ export default { if ((info.vectorCount ?? 0) === 0) { return jsonResponse({ success: true, deleted: 0 }); } - // Vectorize topK 上限为 100,零向量维度必须与索引维度一致 - const zeroVector = new Array(info.dimensions ?? 1536).fill(0); - const existing = await env.VECTORIZE.query(zeroVector, { - topK: 100, - returnMetadata: false, - }); - const staleIds = existing.matches - .filter((m) => !keepSet.has(m.id)) - .map((m) => m.id); + // 使用 listVectors 分页枚举所有向量 ID,找出不在 keepSet 中的 + let staleIds = []; + let cursor; + do { + const page = await env.VECTORIZE.listVectors({ limit: 1000, cursor }); + for (const v of page.vectors) { + if (!keepSet.has(v.id)) staleIds.push(v.id); + } + cursor = page.nextCursor ?? undefined; + } while (cursor); if (staleIds.length > 0) { await env.VECTORIZE.deleteByIds(staleIds); } From 201bdca8ccd63a6fe20f03746eff2b158ab063f3 Mon Sep 17 00:00:00 2001 From: AmintaCCCP Date: Wed, 24 Jun 2026 20:55:56 +0800 Subject: [PATCH 07/16] fix: worker test status sync, delete index UI, README hyperlink MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Worker test success now updates store vectorSearchStatus - Add delete index section with copy-to-clipboard CLI commands - README.md reference is now a clickable GitHub link - Renumber deploy guide section to ⑥ Co-Authored-By: Claude --- .../settings/VectorSearchSettings.tsx | 66 ++++++++++++++++--- 1 file changed, 58 insertions(+), 8 deletions(-) diff --git a/src/components/settings/VectorSearchSettings.tsx b/src/components/settings/VectorSearchSettings.tsx index d8008f25..2d8bd97a 100644 --- a/src/components/settings/VectorSearchSettings.tsx +++ b/src/components/settings/VectorSearchSettings.tsx @@ -182,6 +182,14 @@ export const VectorSearchSettings: React.FC = ({ t }) }); 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, @@ -192,7 +200,7 @@ export const VectorSearchSettings: React.FC = ({ t }) } finally { setTestingWorker(false); } - }, [formWorkerUrl, formAuthToken]); + }, [formWorkerUrl, formAuthToken, setVectorSearchStatus]); const handleRebuildIndex = useCallback(async () => { if (!activeConfig) return; @@ -641,7 +649,7 @@ export const VectorSearchSettings: React.FC = ({ t }) {t('索引管理', 'Index Management')} -

+
- {/* Section 5: Deploy Guide */} + {/* 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 */}

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

)} From aa8dc17ef6ab2d5fce3a11d635173d3d04bf6928 Mon Sep 17 00:00:00 2001 From: AmintaCCCP Date: Wed, 24 Jun 2026 21:13:37 +0800 Subject: [PATCH 08/16] feat: add phase progress display and persist indexing state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - indexAllRepos now reports phase (readme/embedding/uploading) + progress - Progress bar shows phase emoji and label (📖/🧠/☁️) - Indexing state moved from component local state to Zustand store - State persists across page navigation (切页不丢失) Co-Authored-By: Claude --- .../settings/VectorSearchSettings.tsx | 37 +++++++++++-------- src/services/vectorSearchService.ts | 19 ++++++++-- src/store/useAppStore.ts | 6 +++ src/types/index.ts | 9 +++++ 4 files changed, 53 insertions(+), 18 deletions(-) diff --git a/src/components/settings/VectorSearchSettings.tsx b/src/components/settings/VectorSearchSettings.tsx index 2d8bd97a..43902500 100644 --- a/src/components/settings/VectorSearchSettings.tsx +++ b/src/components/settings/VectorSearchSettings.tsx @@ -49,11 +49,13 @@ export const VectorSearchSettings: React.FC = ({ t }) activeEmbeddingConfig, vectorSearchConfig, vectorSearchStatus, + vectorIndexingState, addEmbeddingConfig, updateEmbeddingConfig, setActiveEmbeddingConfig, setVectorSearchConfig, setVectorSearchStatus, + setVectorIndexingState, repositories, githubToken, } = useAppStore(); @@ -82,10 +84,8 @@ export const VectorSearchSettings: React.FC = ({ t }) const [embeddingSaved, setEmbeddingSaved] = useState(false); const [workerSaved, setWorkerSaved] = useState(false); - // Indexing state - const [isIndexing, setIsIndexing] = useState(false); - const [indexProgress, setIndexProgress] = useState({ done: 0, total: 0 }); - const [indexResult, setIndexResult] = useState<{ indexed: number; skipped: number; errors: number } | null>(null); + // Indexing state (from store, persists across navigation) + const { isIndexing, phase, phaseDone, phaseTotal, result: indexResult } = vectorIndexingState; const [abortController, setAbortController] = useState(null); // Deploy guide @@ -222,9 +222,7 @@ export const VectorSearchSettings: React.FC = ({ t }) }); const controller = new AbortController(); setAbortController(controller); - setIsIndexing(true); - setIndexProgress({ done: 0, total: 0 }); - setIndexResult(null); + setVectorIndexingState({ isIndexing: true, phase: null, phaseDone: 0, phaseTotal: 0, result: null }); try { // 清理已 unstar 的仓库向量 @@ -244,11 +242,15 @@ export const VectorSearchSettings: React.FC = ({ t }) : undefined; const result = await indexAllRepos(repositories, embeddingClient, vectorService, { - onProgress: (done, total) => setIndexProgress({ done, total }), + onProgress: (progress) => setVectorIndexingState({ + phase: progress.phase, + phaseDone: progress.done, + phaseTotal: progress.total, + }), signal: controller.signal, readmeFetcher, }); - setIndexResult(result); + setVectorIndexingState({ result, isIndexing: false, phase: null }); setVectorSearchStatus({ connected: true, vectorCount: result.indexed, @@ -257,12 +259,11 @@ export const VectorSearchSettings: React.FC = ({ t }) }); } catch (err) { if (err instanceof Error && err.message === 'Aborted') { - setIndexResult(null); + setVectorIndexingState({ isIndexing: false, phase: null, result: null }); } else { - setIndexResult({ indexed: 0, skipped: 0, errors: repositories.length }); + setVectorIndexingState({ isIndexing: false, phase: null, result: { indexed: 0, skipped: 0, errors: repositories.length } }); } } finally { - setIsIndexing(false); setAbortController(null); } }, [activeConfig, formWorkerUrl, formAuthToken, activeEmbeddingConfig, repositories, setVectorSearchConfig]); @@ -670,17 +671,23 @@ export const VectorSearchSettings: React.FC = ({ t })
{/* Progress */} - {isIndexing && indexProgress.total > 0 && ( + {isIndexing && phaseTotal > 0 && (
- {indexProgress.done}/{indexProgress.total} ({Math.round((indexProgress.done / indexProgress.total) * 100)}%) + {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)}%)
diff --git a/src/services/vectorSearchService.ts b/src/services/vectorSearchService.ts index 15ce4a34..8978f8da 100644 --- a/src/services/vectorSearchService.ts +++ b/src/services/vectorSearchService.ts @@ -354,13 +354,19 @@ export function buildEmbeddingText(repo: Repository, readmeContent?: string): st * 遍历所有已分析仓库,分批生成 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?: (done: number, total: number) => void; + onProgress?: (progress: IndexProgress) => void; signal?: AbortSignal; readmeFetcher?: (owner: string, repo: string, signal?: AbortSignal) => Promise; } = {} @@ -379,8 +385,10 @@ export async function indexAllRepos( // 预先批量获取 README 内容(如果提供了 fetcher) const readmeCache = new Map(); if (readmeFetcher) { - for (const repo of indexable) { + 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); @@ -389,8 +397,10 @@ export async function indexAllRepos( // 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'); @@ -424,6 +434,8 @@ export async function indexAllRepos( })); // 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) { @@ -434,7 +446,8 @@ export async function indexAllRepos( errors += batch.length; } - onProgress?.(Math.min(i + batchSize, indexable.length), indexable.length); + const currentBatch = Math.floor(i / batchSize) + 1; + onProgress?.({ phase: 'embedding', done: currentBatch, total: totalBatches }); } return { indexed, skipped: repos.length - indexable.length, errors }; diff --git a/src/store/useAppStore.ts b/src/store/useAppStore.ts index 6566e2ed..e87b312b 100644 --- a/src/store/useAppStore.ts +++ b/src/store/useAppStore.ts @@ -13,6 +13,7 @@ import { EmbeddingConfig, VectorSearchConfig, VectorSearchStatus, + VectorIndexingState, ProxyConfig, RpcDownloadConfig, SearchFilters, @@ -325,6 +326,7 @@ interface AppActions { // Vector Search actions setVectorSearchConfig: (config: Partial) => void; setVectorSearchStatus: (status: VectorSearchStatus | undefined) => void; + setVectorIndexingState: (state: Partial) => void; // Search actions setSearchFilters: (filters: Partial) => void; @@ -1039,6 +1041,7 @@ export const useAppStore = create()( embeddingConfigs: [], activeEmbeddingConfig: null, vectorSearchConfig: { enabled: false, workerUrl: '', authToken: '', embeddingConfigId: '' }, + vectorIndexingState: { isIndexing: false, phase: null, phaseDone: 0, phaseTotal: 0, result: null }, webdavConfigs: [], activeWebDAVConfig: null, lastBackup: null, @@ -1346,6 +1349,9 @@ export const useAppStore = create()( vectorSearchConfig: { ...state.vectorSearchConfig, ...config } })), setVectorSearchStatus: (status) => set({ vectorSearchStatus: status }), + setVectorIndexingState: (indexingState) => set((state) => ({ + vectorIndexingState: { ...state.vectorIndexingState, ...indexingState } + })), // Search actions setSearchFilters: (filters) => set((state) => { diff --git a/src/types/index.ts b/src/types/index.ts index 96566687..ebff199b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -238,6 +238,14 @@ export interface VectorSearchStatus { 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 } | null; +} export type AIReasoningEffort = 'none' | 'low' | 'medium' | 'high' | 'xhigh'; export type MiMoPlan = 'api' | 'token-plan'; @@ -371,6 +379,7 @@ export interface AppState { // Vector Search vectorSearchConfig: VectorSearchConfig; vectorSearchStatus?: VectorSearchStatus; + vectorIndexingState: VectorIndexingState; // WebDAV webdavConfigs: WebDAVConfig[]; From 03be83ce638b87a9ea52f2668291876c97a8e90e Mon Sep 17 00:00:00 2001 From: AmintaCCCP Date: Wed, 24 Jun 2026 21:28:25 +0800 Subject: [PATCH 09/16] feat: optimize AI search, add progress, fix sort, auto-index on sync 1. Remove redundant AI reranking for vector search results (speed up) 2. Add search phase display below AI search button 3. Fix sort changing loses vector results (use vectorScoreMapRef) 4. Clear vector state when disabling vector search 5. Auto-index new repos after star sync when vector search enabled 6. Add 'Incremental Index' button (upsert only, no cleanup) Co-Authored-By: Claude --- src/components/SearchBar.tsx | 58 +++++++++++++------ .../settings/VectorSearchSettings.tsx | 30 ++++++---- 2 files changed, 61 insertions(+), 27 deletions(-) diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx index ffe4a813..089b98a4 100644 --- a/src/components/SearchBar.tsx +++ b/src/components/SearchBar.tsx @@ -167,6 +167,8 @@ export const SearchBar: React.FC = () => { const [showSuggestions, setShowSuggestions] = useState(false); const searchInputRef = useRef(null); const skipNextTextSearchRef = useRef(false); + const vectorScoreMapRef = useRef | 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'; @@ -215,7 +217,16 @@ export const SearchBar: React.FC = () => { return; } if (!searchFilters.query) { + vectorScoreMapRef.current = null; performBasicFilter(); + } else if (vectorScoreMapRef.current) { + // Vector results exist — just re-apply filters and re-sort by score + const scoreMap = vectorScoreMapRef.current; + const reFiltered = applyFilters(repositories.filter(r => scoreMap.has(String(r.id)))); + const reSorted = reFiltered.sort( + (a, b) => (scoreMap.get(String(b.id)) ?? 0) - (scoreMap.get(String(a.id)) ?? 0) + ); + setSearchResults(reSorted); } else { const textResults = performBasicTextSearch(repositories, searchFilters.query); const finalFiltered = applyFilters(textResults); @@ -501,6 +512,8 @@ 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 { @@ -518,9 +531,11 @@ export const SearchBar: React.FC = () => { 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) { @@ -536,25 +551,12 @@ export const SearchBar: React.FC = () => { .map(item => item.repo); if (scoredRepos.length > 0) { - // 4. AI 校验:用 LLM 对向量搜索结果进行二次排序 - let reranked = scoredRepos; - const rerankConfig = aiConfigs.find(config => config.id === activeAIConfig); - if (rerankConfig) { - try { - const { AIService } = await import('../services/aiService'); - const rerankService = new AIService(rerankConfig, language); - reranked = await rerankService.searchRepositoriesWithReranking(scoredRepos, searchQuery); - console.log('🤖 AI reranked results:', reranked.length); - } catch (rerankError) { - console.warn('AI reranking failed, using vector order:', rerankError); - } - } - // Re-sort by similarity score after filtering, since applyFilters may reorder - const finalFiltered = applyFilters([...reranked]).sort( + const finalFiltered = applyFilters([...scoredRepos]).sort( (a, b) => (scoreMap.get(String(b.id)) ?? 0) - (scoreMap.get(String(a.id)) ?? 0) ); console.log('🎯 Vector search results:', finalFiltered.length); + vectorScoreMapRef.current = scoreMap; skipNextTextSearchRef.current = true; setSearchResults(finalFiltered); setSearchFilters({ query: searchQuery }); @@ -578,8 +580,9 @@ export const SearchBar: React.FC = () => { 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); @@ -609,6 +612,7 @@ export const SearchBar: React.FC = () => { console.error('💥 Search failed:', error); } finally { setIsSearching(false); + setSearchPhase(null); } }; @@ -848,6 +852,21 @@ 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; + // 后台执行,不阻塞 UI + indexAllRepos(mergedRepositories, embClient, vecService, { readmeFetcher }).catch(() => {}); + } } catch (error) { console.error('Sync failed:', error); if (error instanceof Error && error.message.includes('token')) { @@ -980,13 +999,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/settings/VectorSearchSettings.tsx b/src/components/settings/VectorSearchSettings.tsx index 43902500..78b2e875 100644 --- a/src/components/settings/VectorSearchSettings.tsx +++ b/src/components/settings/VectorSearchSettings.tsx @@ -202,10 +202,9 @@ export const VectorSearchSettings: React.FC = ({ t }) } }, [formWorkerUrl, formAuthToken, setVectorSearchStatus]); - const handleRebuildIndex = useCallback(async () => { + const runIndexAll = useCallback(async (withCleanup: boolean) => { if (!activeConfig) return; - // Use form state (not store) so unsaved URL/token/model changes are respected const embeddingClient = new EmbeddingClient({ ...activeConfig, apiType: formApiType, @@ -225,13 +224,13 @@ export const VectorSearchSettings: React.FC = ({ t }) setVectorIndexingState({ isIndexing: true, phase: null, phaseDone: 0, phaseTotal: 0, result: null }); try { - // 清理已 unstar 的仓库向量 - const keepIds = repositories.map(r => String(r.id)); - try { - await vectorService.cleanup(keepIds); - } catch { - // cleanup 失败不阻塞重建 - console.warn('Vector cleanup failed, continuing with rebuild'); + if (withCleanup) { + const keepIds = repositories.map(r => String(r.id)); + try { + await vectorService.cleanup(keepIds); + } catch { + console.warn('Vector cleanup failed, continuing with rebuild'); + } } const readmeFetcher = githubToken @@ -266,7 +265,10 @@ export const VectorSearchSettings: React.FC = ({ t }) } finally { setAbortController(null); } - }, [activeConfig, formWorkerUrl, formAuthToken, activeEmbeddingConfig, repositories, setVectorSearchConfig]); + }, [activeConfig, formApiType, formBaseUrl, formApiKey, formModel, formDimensions, formWorkerUrl, formAuthToken, activeEmbeddingConfig, repositories, githubToken, setVectorSearchStatus, setVectorIndexingState]); + + const handleRebuildIndex = useCallback(() => runIndexAll(true), [runIndexAll]); + const handleIncrementalIndex = useCallback(() => runIndexAll(false), [runIndexAll]); const handleAbortIndexing = useCallback(() => { abortController?.abort(); @@ -659,6 +661,14 @@ export const VectorSearchSettings: React.FC = ({ t }) {isIndexing ? : } {t('重建向量索引', 'Rebuild Vector Index')} + {isIndexing && (
- {/* Test & Save */} + {/* Test */}
-
{/* Test Result */} @@ -652,6 +650,78 @@ export const VectorSearchSettings: React.FC = ({ t }) {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')} +

+
+ )} + + {/* 保存索引配置 */} + +