diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 55baf81a98..dd624fc21f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -69,6 +69,9 @@ catalogs: '@intlify/core': specifier: ^11.3.2 version: 11.3.2 + '@libsql/client': + specifier: ^0.15.15 + version: 0.15.15 '@modelcontextprotocol/sdk': specifier: ^1.29.0 version: 1.29.0 @@ -213,6 +216,9 @@ catalogs: get-port-please: specifier: ^3.2.0 version: 3.2.0 + gpt-tokenizer: + specifier: ^3.4.0 + version: 3.4.0 histoire: specifier: 1.0.0-beta.1 version: 1.0.0-beta.1 @@ -270,6 +276,12 @@ catalogs: splitpanes: specifier: ^4.0.4 version: 4.0.4 + sql.js: + specifier: ^1.13.0 + version: 1.14.1 + sqlite-vec: + specifier: ^0.1.7-alpha.2 + version: 0.1.9 std-env: specifier: ^4.0.0 version: 4.0.0 @@ -565,10 +577,10 @@ importers: dependencies: '@better-auth/drizzle-adapter': specifier: ^1.6.3 - version: 1.6.3(@better-auth/core@1.6.3(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.4.0)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9)) + version: 1.6.3(@better-auth/core@1.6.3(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.4.0)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.4)(@libsql/client@0.15.15(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9)(sql.js@1.14.1)) '@better-auth/oauth-provider': specifier: 'catalog:' - version: 1.5.6(@better-auth/core@1.6.3(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-auth@1.6.3(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(better-sqlite3@12.5.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9))(pg@8.20.0)(react@19.2.3)(vitest@4.1.4)(vue@3.5.32(typescript@5.9.3)))(better-call@1.3.5(zod@4.3.6)) + version: 1.5.6(@better-auth/core@1.6.3(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-auth@1.6.3(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(better-sqlite3@12.5.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.4)(@libsql/client@0.15.15(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9)(sql.js@1.14.1))(pg@8.20.0)(react@19.2.3)(vitest@4.1.4)(vue@3.5.32(typescript@5.9.3)))(better-call@1.3.5(zod@4.3.6)) '@dotenvx/dotenvx': specifier: ^1.61.0 version: 1.61.0 @@ -640,7 +652,7 @@ importers: version: 1.40.0 '@proj-airi/drizzle-orm-browser-migrator': specifier: ^0.1.6 - version: 0.1.6(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9)) + version: 0.1.6(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.4)(@libsql/client@0.15.15(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9)(sql.js@1.14.1)) '@proj-airi/server-schema': specifier: workspace:* version: link:../../packages/server-schema @@ -649,16 +661,16 @@ importers: version: link:../../packages/server-sdk-shared better-auth: specifier: 'catalog:' - version: 1.6.3(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(better-sqlite3@12.5.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9))(pg@8.20.0)(react@19.2.3)(vitest@4.1.4)(vue@3.5.32(typescript@5.9.3)) + version: 1.6.3(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(better-sqlite3@12.5.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.4)(@libsql/client@0.15.15(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9)(sql.js@1.14.1))(pg@8.20.0)(react@19.2.3)(vitest@4.1.4)(vue@3.5.32(typescript@5.9.3)) cac: specifier: 'catalog:' version: 7.0.0 drizzle-orm: specifier: 'catalog:' - version: 0.45.2(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9) + version: 0.45.2(@electric-sql/pglite@0.4.4)(@libsql/client@0.15.15(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9)(sql.js@1.14.1) drizzle-valibot: specifier: 'catalog:' - version: 0.4.2(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9))(valibot@1.2.0(typescript@5.9.3)) + version: 0.4.2(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.4)(@libsql/client@0.15.15(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9)(sql.js@1.14.1))(valibot@1.2.0(typescript@5.9.3)) hono: specifier: 'catalog:' version: 4.11.3 @@ -689,7 +701,7 @@ importers: devDependencies: '@better-auth/cli': specifier: ^1.4.21 - version: 1.4.21(@better-fetch/fetch@1.1.21)(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(drizzle-kit@0.31.10)(jose@6.2.2)(kysely@0.28.14)(magicast@0.5.2)(nanostores@1.1.1)(postgres@3.4.9)(react@19.2.3)(vitest@4.1.4)(vue@3.5.32(typescript@5.9.3)) + version: 1.4.21(@better-fetch/fetch@1.1.21)(@electric-sql/pglite@0.4.4)(@libsql/client@0.15.15(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(drizzle-kit@0.31.10)(jose@6.2.2)(kysely@0.28.14)(magicast@0.5.2)(nanostores@1.1.1)(postgres@3.4.9)(react@19.2.3)(sql.js@1.14.1)(vitest@4.1.4)(vue@3.5.32(typescript@5.9.3)) '@types/pg': specifier: ^8.20.0 version: 8.20.0 @@ -740,7 +752,7 @@ importers: version: link:../../packages/ccc '@proj-airi/drizzle-duckdb-wasm': specifier: 'catalog:' - version: 0.4.29(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9)) + version: 0.4.29(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.4)(@libsql/client@0.15.15(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9)(sql.js@1.14.1)) '@proj-airi/font-chillroundm': specifier: workspace:^ version: link:../../packages/font-chillroundm @@ -851,7 +863,7 @@ importers: version: 1.4.0 drizzle-orm: specifier: 'catalog:' - version: 0.45.2(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9) + version: 0.45.2(@electric-sql/pglite@0.4.4)(@libsql/client@0.15.15(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9)(sql.js@1.14.1) embla-carousel-vue: specifier: 'catalog:' version: 9.0.0-rc02(vue@3.5.32(typescript@5.9.3)) @@ -1143,7 +1155,7 @@ importers: version: link:../../packages/ccc '@proj-airi/drizzle-duckdb-wasm': specifier: 'catalog:' - version: 0.4.29(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9)) + version: 0.4.29(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.4)(@libsql/client@0.15.15(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9)(sql.js@1.14.1)) '@proj-airi/font-chillroundm': specifier: workspace:^ version: link:../../packages/font-chillroundm @@ -1375,7 +1387,7 @@ importers: version: 3.0.2(electron@40.8.5) '@electron-toolkit/tsconfig': specifier: ^2.0.0 - version: 2.0.0(@types/node@25.6.0) + version: 2.0.0(@types/node@24.12.2) '@electron-toolkit/utils': specifier: ^4.0.0 version: 4.0.0(electron@40.8.5) @@ -1414,7 +1426,7 @@ importers: version: 3.1.0 '@intlify/unplugin-vue-i18n': specifier: ^11.0.7 - version: 11.0.7(@vue/compiler-dom@3.5.32)(eslint@10.2.0(jiti@2.6.1))(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-i18n@11.3.2(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)) + version: 11.0.7(@vue/compiler-dom@3.5.32)(eslint@10.2.0(jiti@2.6.1))(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-i18n@11.3.2(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)) '@modelcontextprotocol/sdk': specifier: 'catalog:' version: 1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6) @@ -1450,10 +1462,10 @@ importers: version: link:../../packages/ui-transitions '@proj-airi/unplugin-fetch': specifier: 'catalog:' - version: 0.2.2(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 0.2.2(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) '@proj-airi/unplugin-live2d-sdk': specifier: ^0.1.6 - version: 0.1.6(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + version: 0.1.6(@types/node@24.12.2)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) '@types/audioworklet': specifier: 'catalog:' version: 0.0.97 @@ -1477,7 +1489,7 @@ importers: version: 2.10.3 '@vitejs/plugin-vue': specifier: ^6.0.6 - version: 6.0.6(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3)) + version: 6.0.6(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3)) '@vue-macros/volar': specifier: ^3.1.2 version: 3.1.2(typescript@5.9.3)(vue-tsc@3.2.6(typescript@5.9.3))(vue@3.5.32(typescript@5.9.3)) @@ -1495,7 +1507,7 @@ importers: version: 3.2.3 drizzle-orm: specifier: 'catalog:' - version: 0.45.2(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9) + version: 0.45.2(@electric-sql/pglite@0.4.4)(@libsql/client@0.15.15(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9)(sql.js@1.14.1) electron: specifier: 'catalog:' version: 40.8.5 @@ -1507,7 +1519,7 @@ importers: version: 6.8.3 electron-vite: specifier: ^5.0.0 - version: 5.0.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 5.0.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) get-port-please: specifier: 'catalog:' version: 3.2.0 @@ -1528,31 +1540,31 @@ importers: version: 2.2.6 unocss-preset-scrollbar: specifier: ^4.0.0 - version: 4.0.0(unocss@66.6.8(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))) + version: 4.0.0(unocss@66.6.8(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))) unplugin-info: specifier: ^1.3.2 - version: 1.3.2(esbuild@0.27.2)(rollup@4.60.1)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 1.3.2(esbuild@0.27.2)(rollup@4.60.1)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) unplugin-yaml: specifier: ^4.1.0 - version: 4.1.0(@nuxt/kit@3.20.2(magicast@0.5.2))(esbuild@0.27.2)(rolldown@1.0.0-rc.15)(rollup@4.60.1)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.0(esbuild@0.27.2)(rolldown@1.0.0-rc.15)(rollup@4.60.1)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) vite: specifier: 'catalog:' - version: 8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + version: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) vite-bundle-visualizer: specifier: ^1.2.1 version: 1.2.1(rolldown@1.0.0-rc.15)(rollup@4.60.1) vite-plugin-mkcert: specifier: 'catalog:' - version: 2.0.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 2.0.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) vite-plugin-vue-devtools: specifier: ^8.1.1 - version: 8.1.1(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3)) + version: 8.1.1(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3)) vite-plugin-vue-layouts: specifier: ^0.11.0 - version: 0.11.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-router@5.0.4(@vue/compiler-sfc@3.5.32)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)) + version: 0.11.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-router@5.0.4(@vue/compiler-sfc@3.5.32)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)) vue-macros: specifier: ^3.1.2 - version: 3.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@vueuse/core@14.2.1(vue@3.5.32(typescript@5.9.3)))(esbuild@0.27.2)(rolldown@1.0.0-rc.15)(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-tsc@3.2.6(typescript@5.9.3))(vue@3.5.32(typescript@5.9.3)) + version: 3.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@vueuse/core@14.2.1(vue@3.5.32(typescript@5.9.3)))(esbuild@0.27.2)(rolldown@1.0.0-rc.15)(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-tsc@3.2.6(typescript@5.9.3))(vue@3.5.32(typescript@5.9.3)) vue-tsc: specifier: ^3.2.6 version: 3.2.6(typescript@5.9.3) @@ -1591,7 +1603,7 @@ importers: version: link:../../packages/ccc '@proj-airi/drizzle-duckdb-wasm': specifier: 'catalog:' - version: 0.4.29(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9)) + version: 0.4.29(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.4)(@libsql/client@0.15.15(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9)(sql.js@1.14.1)) '@proj-airi/font-chillroundm': specifier: workspace:^ version: link:../../packages/font-chillroundm @@ -1687,7 +1699,7 @@ importers: version: 4.3.6 better-auth: specifier: ^1.6.3 - version: 1.6.3(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(better-sqlite3@12.5.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9))(pg@8.20.0)(react@19.2.3)(vitest@4.1.4)(vue@3.5.32(typescript@5.9.3)) + version: 1.6.3(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(better-sqlite3@12.5.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.4)(@libsql/client@0.15.15(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9)(sql.js@1.14.1))(pg@8.20.0)(react@19.2.3)(vitest@4.1.4)(vue@3.5.32(typescript@5.9.3)) colorjs.io: specifier: ^0.6.1 version: 0.6.1 @@ -1708,7 +1720,7 @@ importers: version: 1.4.0 drizzle-orm: specifier: 'catalog:' - version: 0.45.2(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9) + version: 0.45.2(@electric-sql/pglite@0.4.4)(@libsql/client@0.15.15(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9)(sql.js@1.14.1) embla-carousel-vue: specifier: 'catalog:' version: 9.0.0-rc02(vue@3.5.32(typescript@5.9.3)) @@ -2492,7 +2504,7 @@ importers: version: link:../server-sdk drizzle-orm: specifier: 'catalog:' - version: 0.45.2(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9) + version: 0.45.2(@electric-sql/pglite@0.4.4)(@libsql/client@0.15.15(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9)(sql.js@1.14.1) postgres: specifier: ^3.4.9 version: 3.4.9 @@ -3002,7 +3014,7 @@ importers: version: link:../core-character '@proj-airi/drizzle-duckdb-wasm': specifier: 'catalog:' - version: 0.4.29(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9)) + version: 0.4.29(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.4)(@libsql/client@0.15.15(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9)(sql.js@1.14.1)) '@proj-airi/font-chillroundm': specifier: workspace:^ version: link:../font-chillroundm @@ -3104,7 +3116,7 @@ importers: version: 0.5.0 better-auth: specifier: 'catalog:' - version: 1.6.3(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(better-sqlite3@12.5.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9))(pg@8.20.0)(react@19.2.3)(vitest@4.1.4)(vue@3.5.32(typescript@5.9.3)) + version: 1.6.3(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(better-sqlite3@12.5.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.4)(@libsql/client@0.15.15(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9)(sql.js@1.14.1))(pg@8.20.0)(react@19.2.3)(vitest@4.1.4)(vue@3.5.32(typescript@5.9.3)) culori: specifier: ^4.0.2 version: 4.0.2 @@ -3980,6 +3992,55 @@ importers: specifier: 'catalog:' version: 8.18.1 + services/qq-bot: + dependencies: + '@guiiai/logg': + specifier: 'catalog:' + version: 1.2.11 + '@libsql/client': + specifier: 'catalog:' + version: 0.15.15(bufferutil@4.1.0)(utf-8-validate@5.0.10) + '@naplink/naplink': + specifier: ~0.0.10 + version: 0.0.10(bufferutil@4.1.0)(utf-8-validate@5.0.10) + '@proj-airi/server-sdk': + specifier: workspace:^ + version: link:../../packages/server-sdk + '@proj-airi/server-shared': + specifier: workspace:^ + version: link:../../packages/server-shared + '@xsai/generate-text': + specifier: 'catalog:' + version: 0.5.0-beta.2(patch_hash=306bfb723913596b334140f0d6fa48063f336e3b44024efc1d72bf60d54b15e6) + '@xsai/shared-chat': + specifier: 'catalog:' + version: 0.5.0-beta.2(patch_hash=26f2819b987245ec85f216b821ddf73aeb28fd7e611238a2d37250e42f838a01) + es-toolkit: + specifier: ^1.45.1 + version: 1.45.1 + gpt-tokenizer: + specifier: 'catalog:' + version: 3.4.0 + sql.js: + specifier: 'catalog:' + version: 1.14.1 + sqlite-vec: + specifier: 'catalog:' + version: 0.1.9 + valibot: + specifier: 'catalog:' + version: 1.2.0(typescript@5.9.3) + yaml: + specifier: ^2.7.1 + version: 2.8.3 + devDependencies: + '@types/node': + specifier: ^24.12.0 + version: 24.12.2 + tsx: + specifier: ^4.21.0 + version: 4.21.0 + services/satori-bot: dependencies: '@electric-sql/pglite': @@ -4008,7 +4069,7 @@ importers: version: 1.4.0 drizzle-orm: specifier: 'catalog:' - version: 0.45.2(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9) + version: 0.45.2(@electric-sql/pglite@0.4.4)(@libsql/client@0.15.15(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9)(sql.js@1.14.1) es-toolkit: specifier: ^1.45.1 version: 1.45.1 @@ -4102,7 +4163,7 @@ importers: version: 1.4.0 drizzle-orm: specifier: 'catalog:' - version: 0.45.2(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9) + version: 0.45.2(@electric-sql/pglite@0.4.4)(@libsql/client@0.15.15(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9)(sql.js@1.14.1) es-toolkit: specifier: ^1.45.1 version: 1.45.1 @@ -6433,6 +6494,67 @@ packages: '@lezer/lr@1.4.5': resolution: {integrity: sha512-/YTRKP5yPPSo1xImYQk7AZZMAgap0kegzqCSYHjAL9x1AZ0ZQW+IpcEzMKagCsbTsLnVeWkxYrCNeXG8xEPrjg==} + '@libsql/client@0.15.15': + resolution: {integrity: sha512-twC0hQxPNHPKfeOv3sNT6u2pturQjLcI+CnpTM0SjRpocEGgfiZ7DWKXLNnsothjyJmDqEsBQJ5ztq9Wlu470w==} + + '@libsql/core@0.15.15': + resolution: {integrity: sha512-C88Z6UKl+OyuKKPwz224riz02ih/zHYI3Ho/LAcVOgjsunIRZoBw7fjRfaH9oPMmSNeQfhGklSG2il1URoOIsA==} + + '@libsql/darwin-arm64@0.5.29': + resolution: {integrity: sha512-K+2RIB1OGFPYQbfay48GakLhqf3ArcbHqPFu7EZiaUcRgFcdw8RoltsMyvbj5ix2fY0HV3Q3Ioa/ByvQdaSM0A==} + cpu: [arm64] + os: [darwin] + + '@libsql/darwin-x64@0.5.29': + resolution: {integrity: sha512-OtT+KFHsKFy1R5FVadr8FJ2Bb1mghtXTyJkxv0trocq7NuHntSki1eUbxpO5ezJesDvBlqFjnWaYYY516QNLhQ==} + cpu: [x64] + os: [darwin] + + '@libsql/hrana-client@0.7.0': + resolution: {integrity: sha512-OF8fFQSkbL7vJY9rfuegK1R7sPgQ6kFMkDamiEccNUvieQ+3urzfDFI616oPl8V7T9zRmnTkSjMOImYCAVRVuw==} + + '@libsql/isomorphic-fetch@0.3.1': + resolution: {integrity: sha512-6kK3SUK5Uu56zPq/Las620n5aS9xJq+jMBcNSOmjhNf/MUvdyji4vrMTqD7ptY7/4/CAVEAYDeotUz60LNQHtw==} + engines: {node: '>=18.0.0'} + + '@libsql/isomorphic-ws@0.1.5': + resolution: {integrity: sha512-DtLWIH29onUYR00i0GlQ3UdcTRC6EP4u9w/h9LxpUZJWRMARk6dQwZ6Jkd+QdwVpuAOrdxt18v0K2uIYR3fwFg==} + + '@libsql/linux-arm-gnueabihf@0.5.29': + resolution: {integrity: sha512-CD4n4zj7SJTHso4nf5cuMoWoMSS7asn5hHygsDuhRl8jjjCTT3yE+xdUvI4J7zsyb53VO5ISh4cwwOtf6k2UhQ==} + cpu: [arm] + os: [linux] + + '@libsql/linux-arm-musleabihf@0.5.29': + resolution: {integrity: sha512-2Z9qBVpEJV7OeflzIR3+l5yAd4uTOLxklScYTwpZnkm2vDSGlC1PRlueLaufc4EFITkLKXK2MWBpexuNJfMVcg==} + cpu: [arm] + os: [linux] + + '@libsql/linux-arm64-gnu@0.5.29': + resolution: {integrity: sha512-gURBqaiXIGGwFNEaUj8Ldk7Hps4STtG+31aEidCk5evMMdtsdfL3HPCpvys+ZF/tkOs2MWlRWoSq7SOuCE9k3w==} + cpu: [arm64] + os: [linux] + + '@libsql/linux-arm64-musl@0.5.29': + resolution: {integrity: sha512-fwgYZ0H8mUkyVqXZHF3mT/92iIh1N94Owi/f66cPVNsk9BdGKq5gVpoKO+7UxaNzuEH1roJp2QEwsCZMvBLpqg==} + cpu: [arm64] + os: [linux] + + '@libsql/linux-x64-gnu@0.5.29': + resolution: {integrity: sha512-y14V0vY0nmMC6G0pHeJcEarcnGU2H6cm21ZceRkacWHvQAEhAG0latQkCtoS2njFOXiYIg+JYPfAoWKbi82rkg==} + cpu: [x64] + os: [linux] + + '@libsql/linux-x64-musl@0.5.29': + resolution: {integrity: sha512-gquqwA/39tH4pFl+J9n3SOMSymjX+6kZ3kWgY3b94nXFTwac9bnFNMffIomgvlFaC4ArVqMnOZD3nuJ3H3VO1w==} + cpu: [x64] + os: [linux] + + '@libsql/win32-x64-msvc@0.5.29': + resolution: {integrity: sha512-4/0CvEdhi6+KjMxMaVbFM2n2Z44escBRoEYpR+gZg64DdetzGnYm8mcNLcoySaDJZNaBd6wz5DNdgRmcI4hXcg==} + cpu: [x64] + os: [win32] + '@loaderkit/resolve@1.0.4': resolution: {integrity: sha512-rJzYKVcV4dxJv+vW6jlvagF8zvGxHJ2+HTr1e2qOejfmGhAApgJHl8Aog4mMszxceTRiKTTbnpgmTO1bEZHV/A==} @@ -6622,10 +6744,17 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 + '@naplink/naplink@0.0.10': + resolution: {integrity: sha512-+ygiV1NblKxfBOc/8rDv7UH28WVT1PiBZ1KEJZeKlt9kpb2v/ooGosdf9MKLHDBmZcoKAykD6ydRd/A5tJ40dQ==} + engines: {node: '>=18.0.0'} + '@nekopaw/tempora@0.4.0-alpha.1': resolution: {integrity: sha512-DlTVHe8HG2d925Jdr8qNikzFISr/ej3lq2/b4UMYfRx+IzMvkPasag1mzedWfrkU2keHn3NgClbWP/MbAxFyKw==} engines: {node: '>=22.10.0'} + '@neon-rs/load@0.0.4': + resolution: {integrity: sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==} + '@noble/ciphers@1.3.0': resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} engines: {node: ^14.21.3 || >=16} @@ -11571,6 +11700,10 @@ packages: engines: {node: '>=0.10'} hasBin: true + detect-libc@2.0.2: + resolution: {integrity: sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==} + engines: {node: '>=8'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -12953,6 +13086,9 @@ packages: resolution: {integrity: sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==} engines: {node: '>=10.19.0'} + gpt-tokenizer@3.4.0: + resolution: {integrity: sha512-wxFLnhIXTDjYebd9A9pGl3e31ZpSypbpIJSOswbgop5jLte/AsZVDvjlbEuVFlsqZixVKqbcoNmRlFDf6pz/UQ==} + gpuu@1.0.6: resolution: {integrity: sha512-g9pC4Hx/2rNulvTDft61gsL+4nkDwxdFOd2Hj6Miv444kxRc/y4Q5mnPVmAxNksWoojTt87U3JgHNHFr0CVkxw==} @@ -13508,6 +13644,11 @@ packages: isomorphic-fetch@3.0.0: resolution: {integrity: sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA==} + isomorphic-ws@5.0.0: + resolution: {integrity: sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==} + peerDependencies: + ws: '*' + isstream@0.1.2: resolution: {integrity: sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==} @@ -13545,6 +13686,9 @@ packages: jpeg-js@0.4.4: resolution: {integrity: sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==} + js-base64@3.7.8: + resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==} + js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} @@ -13746,6 +13890,11 @@ packages: libsodium@0.8.2: resolution: {integrity: sha512-TsnGYMoZtpweT+kR+lOv5TVsnJ/9U0FZOsLFzFOMWmxqOAYXjX3fsrPAW+i1LthgDKXJnI9A8dWEanT1tnJKIw==} + libsql@0.5.29: + resolution: {integrity: sha512-8lMP8iMgiBzzoNbAPQ59qdVcj6UaE/Vnm+fiwX4doX4Narook0a4GPKWBEv+CR8a1OwbfkgL18uBfBjWdF0Fzg==} + cpu: [x64, arm64, wasm32, arm] + os: [darwin, linux, win32] + lie@3.1.1: resolution: {integrity: sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==} @@ -15262,6 +15411,9 @@ packages: resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} engines: {node: '>=0.4.0'} + promise-limit@2.7.0: + resolution: {integrity: sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==} + promise-retry@2.0.1: resolution: {integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==} engines: {node: '>=10'} @@ -16000,6 +16152,37 @@ packages: sprintf-js@1.1.3: resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} + sql.js@1.14.1: + resolution: {integrity: sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==} + + sqlite-vec-darwin-arm64@0.1.9: + resolution: {integrity: sha512-jSsZpE42OfBkGL/ItyJTVCUwl6o6Ka3U5rc4j+UBDIQzC1ulSSKMEhQLthsOnF/MdAf1MuAkYhkdKmmcjaIZQg==} + cpu: [arm64] + os: [darwin] + + sqlite-vec-darwin-x64@0.1.9: + resolution: {integrity: sha512-KDlVyqQT7pnOhU1ymB9gs7dMbSoVmKHitT+k1/xkjarcX8bBqPxWrGlK/R+C5WmWkfvWwyq5FfXfiBYCBs6PlA==} + cpu: [x64] + os: [darwin] + + sqlite-vec-linux-arm64@0.1.9: + resolution: {integrity: sha512-5wXVJ9c9kR4CHm/wVqXb/R+XUHTdpZ4nWbPHlS+gc9qQFVHs92Km4bPnCKX4rtcPMzvNis+SIzMJR1SCEwpuUw==} + cpu: [arm64] + os: [linux] + + sqlite-vec-linux-x64@0.1.9: + resolution: {integrity: sha512-w3tCH8xK2finW8fQJ/m8uqKodXUZ9KAuAar2UIhz4BHILfpE0WM/MTGCRfa7RjYbrYim5Luk3guvMOGI7T7JQA==} + cpu: [x64] + os: [linux] + + sqlite-vec-windows-x64@0.1.9: + resolution: {integrity: sha512-y3gEIyy/17bq2QFPQOWLE68TYWcRZkBQVA2XLrTPHNTOp55xJi/BBBmOm40tVMDMjtP+Elpk6UBUXdaq+46b0Q==} + cpu: [x64] + os: [win32] + + sqlite-vec@0.1.9: + resolution: {integrity: sha512-L7XJWRIBNvR9O5+vh1FQ+IGkh/3D2AzVksW5gdtk28m78Hy8skFD0pqReKH1Yp0/BUKRGcffgKvyO/EON5JXpA==} + srvx@0.11.15: resolution: {integrity: sha512-iXsux0UcOjdvs0LCMa2Ws3WwcDUozA3JN3BquNXkaFPP7TpRqgunKdEgoZ/uwb1J6xaYHfxtz9Twlh6yzwM6Tg==} engines: {node: '>=20.16.0'} @@ -18716,7 +18899,7 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} - '@better-auth/cli@1.4.21(@better-fetch/fetch@1.1.21)(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(drizzle-kit@0.31.10)(jose@6.2.2)(kysely@0.28.14)(magicast@0.5.2)(nanostores@1.1.1)(postgres@3.4.9)(react@19.2.3)(vitest@4.1.4)(vue@3.5.32(typescript@5.9.3))': + '@better-auth/cli@1.4.21(@better-fetch/fetch@1.1.21)(@electric-sql/pglite@0.4.4)(@libsql/client@0.15.15(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(drizzle-kit@0.31.10)(jose@6.2.2)(kysely@0.28.14)(magicast@0.5.2)(nanostores@1.1.1)(postgres@3.4.9)(react@19.2.3)(sql.js@1.14.1)(vitest@4.1.4)(vue@3.5.32(typescript@5.9.3))': dependencies: '@babel/core': 7.29.0 '@babel/preset-react': 7.28.5(@babel/core@7.29.0) @@ -18728,13 +18911,13 @@ snapshots: '@mrleebo/prisma-ast': 0.13.1 '@prisma/client': 5.22.0 '@types/pg': 8.20.0 - better-auth: 1.4.21(@prisma/client@5.22.0)(better-sqlite3@12.5.0)(drizzle-kit@0.31.10)(drizzle-orm@0.41.0(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9))(pg@8.20.0)(react@19.2.3)(vitest@4.1.4)(vue@3.5.32(typescript@5.9.3)) + better-auth: 1.4.21(@prisma/client@5.22.0)(better-sqlite3@12.5.0)(drizzle-kit@0.31.10)(drizzle-orm@0.41.0(@electric-sql/pglite@0.4.4)(@libsql/client@0.15.15(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9)(sql.js@1.14.1))(pg@8.20.0)(react@19.2.3)(vitest@4.1.4)(vue@3.5.32(typescript@5.9.3)) better-sqlite3: 12.5.0 c12: 3.3.3(magicast@0.5.2) chalk: 5.6.2 commander: 12.1.0 dotenv: 17.4.2 - drizzle-orm: 0.41.0(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9) + drizzle-orm: 0.41.0(@electric-sql/pglite@0.4.4)(@libsql/client@0.15.15(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9)(sql.js@1.14.1) open: 10.2.0 pg: 8.20.0 prettier: 3.7.4 @@ -18823,12 +19006,12 @@ snapshots: nanostores: 1.1.1 zod: 4.3.6 - '@better-auth/drizzle-adapter@1.6.3(@better-auth/core@1.6.3(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.4.0)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9))': + '@better-auth/drizzle-adapter@1.6.3(@better-auth/core@1.6.3(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.4.0)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.4)(@libsql/client@0.15.15(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9)(sql.js@1.14.1))': dependencies: '@better-auth/core': 1.6.3(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.1.1) '@better-auth/utils': 0.4.0 optionalDependencies: - drizzle-orm: 0.45.2(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9) + drizzle-orm: 0.45.2(@electric-sql/pglite@0.4.4)(@libsql/client@0.15.15(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9)(sql.js@1.14.1) '@better-auth/kysely-adapter@1.6.3(@better-auth/core@1.6.3(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.4.0)(kysely@0.28.14)': dependencies: @@ -18847,12 +19030,12 @@ snapshots: '@better-auth/core': 1.6.3(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.1.1) '@better-auth/utils': 0.4.0 - '@better-auth/oauth-provider@1.5.6(@better-auth/core@1.6.3(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-auth@1.6.3(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(better-sqlite3@12.5.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9))(pg@8.20.0)(react@19.2.3)(vitest@4.1.4)(vue@3.5.32(typescript@5.9.3)))(better-call@1.3.5(zod@4.3.6))': + '@better-auth/oauth-provider@1.5.6(@better-auth/core@1.6.3(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-auth@1.6.3(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(better-sqlite3@12.5.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.4)(@libsql/client@0.15.15(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9)(sql.js@1.14.1))(pg@8.20.0)(react@19.2.3)(vitest@4.1.4)(vue@3.5.32(typescript@5.9.3)))(better-call@1.3.5(zod@4.3.6))': dependencies: '@better-auth/core': 1.6.3(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.1.1) '@better-auth/utils': 0.4.0 '@better-fetch/fetch': 1.1.21 - better-auth: 1.6.3(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(better-sqlite3@12.5.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9))(pg@8.20.0)(react@19.2.3)(vitest@4.1.4)(vue@3.5.32(typescript@5.9.3)) + better-auth: 1.6.3(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(better-sqlite3@12.5.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.4)(@libsql/client@0.15.15(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9)(sql.js@1.14.1))(pg@8.20.0)(react@19.2.3)(vitest@4.1.4)(vue@3.5.32(typescript@5.9.3)) better-call: 1.3.5(zod@4.3.6) jose: 6.2.2 zod: 4.3.6 @@ -19196,9 +19379,9 @@ snapshots: dependencies: electron: 41.0.3 - '@electron-toolkit/tsconfig@2.0.0(@types/node@25.6.0)': + '@electron-toolkit/tsconfig@2.0.0(@types/node@24.12.2)': dependencies: - '@types/node': 25.6.0 + '@types/node': 24.12.2 '@electron-toolkit/utils@4.0.0(electron@40.8.5)': dependencies: @@ -20084,6 +20267,31 @@ snapshots: - supports-color - typescript + '@intlify/unplugin-vue-i18n@11.0.7(@vue/compiler-dom@3.5.32)(eslint@10.2.0(jiti@2.6.1))(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-i18n@11.3.2(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.0(jiti@2.6.1)) + '@intlify/bundle-utils': 11.0.7(vue-i18n@11.3.2(vue@3.5.32(typescript@5.9.3))) + '@intlify/shared': 11.3.2 + '@intlify/vue-i18n-extensions': 8.0.0(@intlify/shared@11.3.2)(@vue/compiler-dom@3.5.32)(vue-i18n@11.3.2(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)) + '@rollup/pluginutils': 5.3.0(rollup@4.60.1) + '@typescript-eslint/scope-manager': 8.58.1 + '@typescript-eslint/typescript-estree': 8.58.1(typescript@5.9.3) + debug: 4.4.3(supports-color@10.2.2) + fast-glob: 3.3.3 + pathe: 2.0.3 + picocolors: 1.1.1 + unplugin: 2.3.11 + vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vue: 3.5.32(typescript@5.9.3) + optionalDependencies: + vue-i18n: 11.3.2(vue@3.5.32(typescript@5.9.3)) + transitivePeerDependencies: + - '@vue/compiler-dom' + - eslint + - rollup + - supports-color + - typescript + '@intlify/unplugin-vue-i18n@11.0.7(@vue/compiler-dom@3.5.32)(eslint@10.2.0(jiti@2.6.1))(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-i18n@11.3.2(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.0(jiti@2.6.1)) @@ -20347,6 +20555,68 @@ snapshots: dependencies: '@lezer/common': 1.5.0 + '@libsql/client@0.15.15(bufferutil@4.1.0)(utf-8-validate@5.0.10)': + dependencies: + '@libsql/core': 0.15.15 + '@libsql/hrana-client': 0.7.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) + js-base64: 3.7.8 + libsql: 0.5.29 + promise-limit: 2.7.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@libsql/core@0.15.15': + dependencies: + js-base64: 3.7.8 + + '@libsql/darwin-arm64@0.5.29': + optional: true + + '@libsql/darwin-x64@0.5.29': + optional: true + + '@libsql/hrana-client@0.7.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)': + dependencies: + '@libsql/isomorphic-fetch': 0.3.1 + '@libsql/isomorphic-ws': 0.1.5(bufferutil@4.1.0)(utf-8-validate@5.0.10) + js-base64: 3.7.8 + node-fetch: 3.3.2 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@libsql/isomorphic-fetch@0.3.1': {} + + '@libsql/isomorphic-ws@0.1.5(bufferutil@4.1.0)(utf-8-validate@5.0.10)': + dependencies: + '@types/ws': 8.18.1 + ws: 8.20.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@libsql/linux-arm-gnueabihf@0.5.29': + optional: true + + '@libsql/linux-arm-musleabihf@0.5.29': + optional: true + + '@libsql/linux-arm64-gnu@0.5.29': + optional: true + + '@libsql/linux-arm64-musl@0.5.29': + optional: true + + '@libsql/linux-x64-gnu@0.5.29': + optional: true + + '@libsql/linux-x64-musl@0.5.29': + optional: true + + '@libsql/win32-x64-msvc@0.5.29': + optional: true + '@loaderkit/resolve@1.0.4': dependencies: '@braidai/lang': 1.1.2 @@ -20536,8 +20806,18 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true + '@naplink/naplink@0.0.10(bufferutil@4.1.0)(utf-8-validate@5.0.10)': + dependencies: + isomorphic-ws: 5.0.0(ws@8.20.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)) + ws: 8.20.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) + transitivePeerDependencies: + - bufferutil + - utf-8-validate + '@nekopaw/tempora@0.4.0-alpha.1': {} + '@neon-rs/load@0.0.4': {} + '@noble/ciphers@1.3.0': {} '@noble/ciphers@2.1.1': {} @@ -22008,25 +22288,25 @@ snapshots: dependencies: culori: 4.0.2 - '@proj-airi/drizzle-duckdb-wasm@0.4.29(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9))': + '@proj-airi/drizzle-duckdb-wasm@0.4.29(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.4)(@libsql/client@0.15.15(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9)(sql.js@1.14.1))': dependencies: '@date-fns/tz': 1.4.1 '@duckdb/duckdb-wasm': 1.29.1-dev68.0 '@moeru/std': 0.1.0-beta.1 - '@proj-airi/duckdb-wasm': 0.4.29(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9)) + '@proj-airi/duckdb-wasm': 0.4.29(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.4)(@libsql/client@0.15.15(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9)(sql.js@1.14.1)) apache-arrow: 21.1.0 date-fns: 4.1.0 - drizzle-orm: 0.45.2(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9) + drizzle-orm: 0.45.2(@electric-sql/pglite@0.4.4)(@libsql/client@0.15.15(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9)(sql.js@1.14.1) es-toolkit: 1.45.1 transitivePeerDependencies: - '@75lb/nature' - '@proj-airi/drizzle-orm-browser-migrator@0.1.6(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9))': + '@proj-airi/drizzle-orm-browser-migrator@0.1.6(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.4)(@libsql/client@0.15.15(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9)(sql.js@1.14.1))': dependencies: '@guiiai/logg': 1.2.11 - drizzle-orm: 0.45.2(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9) + drizzle-orm: 0.45.2(@electric-sql/pglite@0.4.4)(@libsql/client@0.15.15(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9)(sql.js@1.14.1) - '@proj-airi/duckdb-wasm@0.4.29(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9))': + '@proj-airi/duckdb-wasm@0.4.29(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.4)(@libsql/client@0.15.15(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9)(sql.js@1.14.1))': dependencies: '@date-fns/tz': 1.4.1 '@duckdb/duckdb-wasm': 1.29.1-dev68.0 @@ -22034,7 +22314,7 @@ snapshots: apache-arrow: 21.1.0 date-fns: 4.1.0 defu: 6.1.7 - drizzle-orm: 0.45.2(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9) + drizzle-orm: 0.45.2(@electric-sql/pglite@0.4.4)(@libsql/client@0.15.15(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9)(sql.js@1.14.1) es-toolkit: 1.45.1 transitivePeerDependencies: - '@75lb/nature' @@ -22059,11 +22339,34 @@ snapshots: transitivePeerDependencies: - magicast + '@proj-airi/unplugin-fetch@0.2.2(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': + dependencies: + ofetch: 1.5.1 + vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + '@proj-airi/unplugin-fetch@0.2.2(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': dependencies: ofetch: 1.5.1 vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + '@proj-airi/unplugin-live2d-sdk@0.1.6(@types/node@24.12.2)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)': + dependencies: + ofetch: 1.5.1 + vite: 7.3.2(@types/node@24.12.2)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + yauzl: 3.3.0 + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + '@proj-airi/unplugin-live2d-sdk@0.1.6(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)': dependencies: ofetch: 1.5.1 @@ -22959,6 +23262,7 @@ snapshots: '@types/node@25.6.0': dependencies: undici-types: 7.19.2 + optional: true '@types/nprogress@0.2.3': {} @@ -23486,19 +23790,6 @@ snapshots: unplugin-utils: 0.3.1 vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) - '@unocss/vite@66.6.8(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': - dependencies: - '@jridgewell/remapping': 2.3.5 - '@unocss/config': 66.6.8 - '@unocss/core': 66.6.8 - '@unocss/inspector': 66.6.8 - chokidar: 5.0.0 - magic-string: 0.30.21 - pathe: 2.0.3 - tinyglobby: 0.2.16 - unplugin-utils: 0.3.1 - vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) - '@unrteljs/eval@0.2.1': dependencies: builtin-modules: 5.0.0 @@ -23622,6 +23913,12 @@ snapshots: vite: 6.4.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) vue: 3.5.32(typescript@5.9.3) + '@vitejs/plugin-vue@6.0.6(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3))': + dependencies: + '@rolldown/pluginutils': 1.0.0-rc.13 + vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vue: 3.5.32(typescript@5.9.3) + '@vitejs/plugin-vue@6.0.6(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.13 @@ -23924,6 +24221,15 @@ snapshots: transitivePeerDependencies: - vue + '@vue-macros/devtools@3.1.2(typescript@5.9.3)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': + dependencies: + sirv: 3.0.2 + vue: 3.5.32(typescript@5.9.3) + optionalDependencies: + vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + transitivePeerDependencies: + - typescript + '@vue-macros/devtools@3.1.2(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': dependencies: sirv: 3.0.2 @@ -24853,7 +25159,7 @@ snapshots: best-effort-json-parser@1.4.0: {} - better-auth@1.4.21(@prisma/client@5.22.0)(better-sqlite3@12.5.0)(drizzle-kit@0.31.10)(drizzle-orm@0.41.0(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9))(pg@8.20.0)(react@19.2.3)(vitest@4.1.4)(vue@3.5.32(typescript@5.9.3)): + better-auth@1.4.21(@prisma/client@5.22.0)(better-sqlite3@12.5.0)(drizzle-kit@0.31.10)(drizzle-orm@0.41.0(@electric-sql/pglite@0.4.4)(@libsql/client@0.15.15(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9)(sql.js@1.14.1))(pg@8.20.0)(react@19.2.3)(vitest@4.1.4)(vue@3.5.32(typescript@5.9.3)): dependencies: '@better-auth/core': 1.4.21(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.1.1) '@better-auth/telemetry': 1.4.21(@better-auth/core@1.4.21(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.1.1)) @@ -24871,16 +25177,16 @@ snapshots: '@prisma/client': 5.22.0 better-sqlite3: 12.5.0 drizzle-kit: 0.31.10 - drizzle-orm: 0.41.0(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9) + drizzle-orm: 0.41.0(@electric-sql/pglite@0.4.4)(@libsql/client@0.15.15(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9)(sql.js@1.14.1) pg: 8.20.0 react: 19.2.3 vitest: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@4.1.4)(jsdom@27.4.0(bufferutil@4.1.0)(canvas@3.2.3)(utf-8-validate@5.0.10))(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) vue: 3.5.32(typescript@5.9.3) - better-auth@1.6.3(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(better-sqlite3@12.5.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9))(pg@8.20.0)(react@19.2.3)(vitest@4.1.4)(vue@3.5.32(typescript@5.9.3)): + better-auth@1.6.3(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(better-sqlite3@12.5.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.4)(@libsql/client@0.15.15(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9)(sql.js@1.14.1))(pg@8.20.0)(react@19.2.3)(vitest@4.1.4)(vue@3.5.32(typescript@5.9.3)): dependencies: '@better-auth/core': 1.6.3(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.1.1) - '@better-auth/drizzle-adapter': 1.6.3(@better-auth/core@1.6.3(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.4.0)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9)) + '@better-auth/drizzle-adapter': 1.6.3(@better-auth/core@1.6.3(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.4.0)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.4)(@libsql/client@0.15.15(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9)(sql.js@1.14.1)) '@better-auth/kysely-adapter': 1.6.3(@better-auth/core@1.6.3(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.4.0)(kysely@0.28.14) '@better-auth/memory-adapter': 1.6.3(@better-auth/core@1.6.3(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.4.0) '@better-auth/mongo-adapter': 1.6.3(@better-auth/core@1.6.3(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.4.0) @@ -24900,7 +25206,7 @@ snapshots: '@prisma/client': 5.22.0 better-sqlite3: 12.5.0 drizzle-kit: 0.31.10 - drizzle-orm: 0.45.2(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9) + drizzle-orm: 0.45.2(@electric-sql/pglite@0.4.4)(@libsql/client@0.15.15(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9)(sql.js@1.14.1) pg: 8.20.0 react: 19.2.3 vitest: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@4.1.4)(jsdom@27.4.0(bufferutil@4.1.0)(canvas@3.2.3)(utf-8-validate@5.0.10))(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) @@ -25798,6 +26104,8 @@ snapshots: detect-libc@1.0.3: {} + detect-libc@2.0.2: {} + detect-libc@2.1.2: {} detect-node@2.1.0: {} @@ -25930,9 +26238,10 @@ snapshots: esbuild: 0.25.12 tsx: 4.21.0 - drizzle-orm@0.41.0(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9): + drizzle-orm@0.41.0(@electric-sql/pglite@0.4.4)(@libsql/client@0.15.15(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9)(sql.js@1.14.1): optionalDependencies: '@electric-sql/pglite': 0.4.4 + '@libsql/client': 0.15.15(bufferutil@4.1.0)(utf-8-validate@5.0.10) '@opentelemetry/api': 1.9.1 '@prisma/client': 5.22.0 '@types/pg': 8.20.0 @@ -25940,10 +26249,12 @@ snapshots: kysely: 0.28.14 pg: 8.20.0 postgres: 3.4.9 + sql.js: 1.14.1 - drizzle-orm@0.45.2(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9): + drizzle-orm@0.45.2(@electric-sql/pglite@0.4.4)(@libsql/client@0.15.15(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9)(sql.js@1.14.1): optionalDependencies: '@electric-sql/pglite': 0.4.4 + '@libsql/client': 0.15.15(bufferutil@4.1.0)(utf-8-validate@5.0.10) '@opentelemetry/api': 1.9.1 '@prisma/client': 5.22.0 '@types/pg': 8.20.0 @@ -25951,10 +26262,11 @@ snapshots: kysely: 0.28.14 pg: 8.20.0 postgres: 3.4.9 + sql.js: 1.14.1 - drizzle-valibot@0.4.2(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9))(valibot@1.2.0(typescript@5.9.3)): + drizzle-valibot@0.4.2(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.4)(@libsql/client@0.15.15(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9)(sql.js@1.14.1))(valibot@1.2.0(typescript@5.9.3)): dependencies: - drizzle-orm: 0.45.2(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9) + drizzle-orm: 0.45.2(@electric-sql/pglite@0.4.4)(@libsql/client@0.15.15(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9)(sql.js@1.14.1) valibot: 1.2.0(typescript@5.9.3) dts-resolver@2.1.3(oxc-resolver@11.19.1(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)): @@ -26050,7 +26362,7 @@ snapshots: transitivePeerDependencies: - supports-color - electron-vite@5.0.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): + electron-vite@5.0.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.0) @@ -26058,7 +26370,7 @@ snapshots: esbuild: 0.25.12 magic-string: 0.30.21 picocolors: 1.1.1 - vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) transitivePeerDependencies: - supports-color @@ -27374,6 +27686,8 @@ snapshots: p-cancelable: 2.1.1 responselike: 2.0.1 + gpt-tokenizer@3.4.0: {} + gpuu@1.0.6: {} graceful-fs@4.2.10: {} @@ -28023,6 +28337,10 @@ snapshots: transitivePeerDependencies: - encoding + isomorphic-ws@5.0.0(ws@8.20.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)): + dependencies: + ws: 8.20.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) + isstream@0.1.2: {} istanbul-lib-coverage@3.2.2: {} @@ -28060,6 +28378,8 @@ snapshots: jpeg-js@0.4.4: {} + js-base64@3.7.8: {} + js-tokens@10.0.0: {} js-tokens@4.0.0: {} @@ -28301,6 +28621,21 @@ snapshots: libsodium@0.8.2: {} + libsql@0.5.29: + dependencies: + '@neon-rs/load': 0.0.4 + detect-libc: 2.0.2 + optionalDependencies: + '@libsql/darwin-arm64': 0.5.29 + '@libsql/darwin-x64': 0.5.29 + '@libsql/linux-arm-gnueabihf': 0.5.29 + '@libsql/linux-arm-musleabihf': 0.5.29 + '@libsql/linux-arm64-gnu': 0.5.29 + '@libsql/linux-arm64-musl': 0.5.29 + '@libsql/linux-x64-gnu': 0.5.29 + '@libsql/linux-x64-musl': 0.5.29 + '@libsql/win32-x64-msvc': 0.5.29 + lie@3.1.1: dependencies: immediate: 3.0.6 @@ -30264,6 +30599,8 @@ snapshots: progress@2.0.3: {} + promise-limit@2.7.0: {} + promise-retry@2.0.1: dependencies: err-code: 2.0.3 @@ -31261,6 +31598,31 @@ snapshots: sprintf-js@1.1.3: {} + sql.js@1.14.1: {} + + sqlite-vec-darwin-arm64@0.1.9: + optional: true + + sqlite-vec-darwin-x64@0.1.9: + optional: true + + sqlite-vec-linux-arm64@0.1.9: + optional: true + + sqlite-vec-linux-x64@0.1.9: + optional: true + + sqlite-vec-windows-x64@0.1.9: + optional: true + + sqlite-vec@0.1.9: + optionalDependencies: + sqlite-vec-darwin-arm64: 0.1.9 + sqlite-vec-darwin-x64: 0.1.9 + sqlite-vec-linux-arm64: 0.1.9 + sqlite-vec-linux-x64: 0.1.9 + sqlite-vec-windows-x64: 0.1.9 + srvx@0.11.15: {} sshpk@1.18.0: @@ -31873,7 +32235,8 @@ snapshots: undici-types@7.16.0: {} - undici-types@7.19.2: {} + undici-types@7.19.2: + optional: true undici@6.24.1: {} @@ -31983,11 +32346,6 @@ snapshots: '@unocss/preset-mini': 66.6.8 unocss: 66.6.8(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) - unocss-preset-scrollbar@4.0.0(unocss@66.6.8(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))): - dependencies: - '@unocss/preset-mini': 66.6.8 - unocss: 66.6.8(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) - unocss@66.6.8(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(vite@6.4.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): dependencies: '@unocss/cli': 66.6.8 @@ -32036,30 +32394,6 @@ snapshots: - '@emnapi/runtime' - vite - unocss@66.6.8(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): - dependencies: - '@unocss/cli': 66.6.8 - '@unocss/core': 66.6.8 - '@unocss/preset-attributify': 66.6.8 - '@unocss/preset-icons': 66.6.8 - '@unocss/preset-mini': 66.6.8 - '@unocss/preset-tagify': 66.6.8 - '@unocss/preset-typography': 66.6.8 - '@unocss/preset-uno': 66.6.8 - '@unocss/preset-web-fonts': 66.6.8 - '@unocss/preset-wind': 66.6.8 - '@unocss/preset-wind3': 66.6.8 - '@unocss/preset-wind4': 66.6.8 - '@unocss/transformer-attributify-jsx': 66.6.8(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) - '@unocss/transformer-compile-class': 66.6.8 - '@unocss/transformer-directives': 66.6.8 - '@unocss/transformer-variant-group': 66.6.8 - '@unocss/vite': 66.6.8(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) - transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' - - vite - unpack-string@0.0.2: {} unpipe@1.0.0: {} @@ -32072,6 +32406,14 @@ snapshots: unplugin: 2.3.11 vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + unplugin-combine@2.3.0(esbuild@0.27.2)(rolldown@1.0.0-rc.15)(rollup@4.60.1)(unplugin@2.3.11)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): + optionalDependencies: + esbuild: 0.27.2 + rolldown: 1.0.0-rc.15 + rollup: 4.60.1 + unplugin: 2.3.11 + vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + unplugin-combine@2.3.0(esbuild@0.27.2)(rolldown@1.0.0-rc.15)(rollup@4.60.1)(unplugin@2.3.11)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): optionalDependencies: esbuild: 0.27.2 @@ -32106,6 +32448,19 @@ snapshots: transitivePeerDependencies: - supports-color + unplugin-info@1.3.2(esbuild@0.27.2)(rollup@4.60.1)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): + dependencies: + ci-info: 4.4.0 + git-url-parse: 16.1.0 + simple-git: 3.36.0 + unplugin: 2.3.11 + optionalDependencies: + esbuild: 0.27.2 + rollup: 4.60.1 + vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + transitivePeerDependencies: + - supports-color + unplugin-info@1.3.2(esbuild@0.27.2)(rollup@4.60.1)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): dependencies: ci-info: 4.4.0 @@ -32215,6 +32570,17 @@ snapshots: rollup: 4.60.1 vite: 6.4.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + unplugin-yaml@4.1.0(esbuild@0.27.2)(rolldown@1.0.0-rc.15)(rollup@4.60.1)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): + dependencies: + '@rollup/pluginutils': 5.3.0(rollup@4.60.1) + unplugin: 3.0.0 + yaml: 2.8.3 + optionalDependencies: + esbuild: 0.27.2 + rolldown: 1.0.0-rc.15 + rollup: 4.60.1 + vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + unplugin@2.3.11: dependencies: '@jridgewell/remapping': 2.3.5 @@ -32399,12 +32765,22 @@ snapshots: - rollup - supports-color + vite-dev-rpc@1.1.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): + dependencies: + birpc: 2.9.0 + vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vite-hot-client: 2.1.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + vite-dev-rpc@1.1.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): dependencies: birpc: 2.9.0 vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) vite-hot-client: 2.1.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + vite-hot-client@2.1.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): + dependencies: + vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vite-hot-client@2.1.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): dependencies: vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) @@ -32450,6 +32826,21 @@ snapshots: - tsx - yaml + vite-plugin-inspect@11.3.3(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): + dependencies: + ansis: 4.2.0 + debug: 4.4.3(supports-color@10.2.2) + error-stack-parser-es: 1.0.5 + ohash: 2.0.11 + open: 10.2.0 + perfect-debounce: 2.1.0 + sirv: 3.0.2 + unplugin-utils: 0.3.1 + vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vite-dev-rpc: 1.1.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + transitivePeerDependencies: + - supports-color + vite-plugin-inspect@11.3.3(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): dependencies: ansis: 4.2.0 @@ -32481,6 +32872,13 @@ snapshots: - typescript - ws + vite-plugin-mkcert@2.0.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): + dependencies: + debug: 4.4.3(supports-color@10.2.2) + supports-color: 10.2.2 + undici: 8.1.0 + vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vite-plugin-mkcert@2.0.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): dependencies: debug: 4.4.3(supports-color@10.2.2) @@ -32499,6 +32897,20 @@ snapshots: transitivePeerDependencies: - supports-color + vite-plugin-vue-devtools@8.1.1(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3)): + dependencies: + '@vue/devtools-core': 8.1.1(vue@3.5.32(typescript@5.9.3)) + '@vue/devtools-kit': 8.1.1 + '@vue/devtools-shared': 8.1.1 + sirv: 3.0.2 + vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vite-plugin-inspect: 11.3.3(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + vite-plugin-vue-inspector: 5.3.2(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + transitivePeerDependencies: + - '@nuxt/kit' + - supports-color + - vue + vite-plugin-vue-devtools@8.1.1(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3)): dependencies: '@vue/devtools-core': 8.1.1(vue@3.5.32(typescript@5.9.3)) @@ -32513,6 +32925,21 @@ snapshots: - supports-color - vue + vite-plugin-vue-inspector@5.3.2(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-proposal-decorators': 7.28.0(@babel/core@7.29.0) + '@babel/plugin-syntax-import-attributes': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.0) + '@babel/plugin-transform-typescript': 7.28.5(@babel/core@7.29.0) + '@vue/babel-plugin-jsx': 1.5.0(@babel/core@7.29.0) + '@vue/compiler-dom': 3.5.32 + kolorist: 1.8.0 + magic-string: 0.30.21 + vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + transitivePeerDependencies: + - supports-color + vite-plugin-vue-inspector@5.3.2(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): dependencies: '@babel/core': 7.29.0 @@ -32528,6 +32955,16 @@ snapshots: transitivePeerDependencies: - supports-color + vite-plugin-vue-layouts@0.11.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-router@5.0.4(@vue/compiler-sfc@3.5.32)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)): + dependencies: + debug: 4.4.3(supports-color@10.2.2) + fast-glob: 3.3.3 + vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vue: 3.5.32(typescript@5.9.3) + vue-router: 5.0.4(@vue/compiler-sfc@3.5.32)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)) + transitivePeerDependencies: + - supports-color + vite-plugin-vue-layouts@0.11.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-router@5.0.4(@vue/compiler-sfc@3.5.32)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)): dependencies: debug: 4.4.3(supports-color@10.2.2) @@ -32556,6 +32993,24 @@ snapshots: tsx: 4.21.0 yaml: 2.8.3 + vite@7.3.2(@types/node@24.12.2)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): + dependencies: + esbuild: 0.27.2 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.9 + rollup: 4.60.1 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 24.12.2 + fsevents: 2.3.3 + jiti: 2.6.1 + less: 4.6.4 + lightningcss: 1.32.0 + terser: 5.46.1 + tsx: 4.21.0 + yaml: 2.8.3 + vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): dependencies: esbuild: 0.27.2 @@ -32786,6 +33241,54 @@ snapshots: - vue-tsc - webpack + vue-macros@3.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@vueuse/core@14.2.1(vue@3.5.32(typescript@5.9.3)))(esbuild@0.27.2)(rolldown@1.0.0-rc.15)(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-tsc@3.2.6(typescript@5.9.3))(vue@3.5.32(typescript@5.9.3)): + dependencies: + '@vue-macros/better-define': 3.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(vue@3.5.32(typescript@5.9.3)) + '@vue-macros/boolean-prop': 3.1.2(vue@3.5.32(typescript@5.9.3)) + '@vue-macros/chain-call': 3.1.2(vue@3.5.32(typescript@5.9.3)) + '@vue-macros/common': 3.1.2(vue@3.5.32(typescript@5.9.3)) + '@vue-macros/config': 3.1.2(vue@3.5.32(typescript@5.9.3)) + '@vue-macros/define-emit': 3.1.2(vue@3.5.32(typescript@5.9.3)) + '@vue-macros/define-models': 3.1.2(@vueuse/core@14.2.1(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)) + '@vue-macros/define-prop': 3.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(vue@3.5.32(typescript@5.9.3)) + '@vue-macros/define-props': 3.1.2(@vue-macros/reactivity-transform@3.1.2(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)) + '@vue-macros/define-props-refs': 3.1.2(vue@3.5.32(typescript@5.9.3)) + '@vue-macros/define-render': 3.1.2(vue@3.5.32(typescript@5.9.3)) + '@vue-macros/define-slots': 3.1.2(vue@3.5.32(typescript@5.9.3)) + '@vue-macros/define-stylex': 3.1.2(vue@3.5.32(typescript@5.9.3)) + '@vue-macros/devtools': 3.1.2(typescript@5.9.3)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + '@vue-macros/export-expose': 3.1.2(vue@3.5.32(typescript@5.9.3)) + '@vue-macros/export-props': 3.1.2(vue@3.5.32(typescript@5.9.3)) + '@vue-macros/export-render': 3.1.2(vue@3.5.32(typescript@5.9.3)) + '@vue-macros/hoist-static': 3.1.2(vue@3.5.32(typescript@5.9.3)) + '@vue-macros/jsx-directive': 3.1.2(typescript@5.9.3) + '@vue-macros/named-template': 3.1.2(vue@3.5.32(typescript@5.9.3)) + '@vue-macros/reactivity-transform': 3.1.2(vue@3.5.32(typescript@5.9.3)) + '@vue-macros/script-lang': 3.1.2(vue@3.5.32(typescript@5.9.3)) + '@vue-macros/setup-block': 3.1.2(vue@3.5.32(typescript@5.9.3)) + '@vue-macros/setup-component': 3.1.2(vue@3.5.32(typescript@5.9.3)) + '@vue-macros/setup-sfc': 3.1.2(vue@3.5.32(typescript@5.9.3)) + '@vue-macros/short-bind': 3.1.2(vue@3.5.32(typescript@5.9.3)) + '@vue-macros/short-emits': 3.1.2(vue@3.5.32(typescript@5.9.3)) + '@vue-macros/short-vmodel': 3.1.2(vue@3.5.32(typescript@5.9.3)) + '@vue-macros/volar': 3.1.2(typescript@5.9.3)(vue-tsc@3.2.6(typescript@5.9.3))(vue@3.5.32(typescript@5.9.3)) + unplugin: 2.3.11 + unplugin-combine: 2.3.0(esbuild@0.27.2)(rolldown@1.0.0-rc.15)(rollup@4.60.1)(unplugin@2.3.11)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + unplugin-vue-define-options: 3.1.2(vue@3.5.32(typescript@5.9.3)) + vue: 3.5.32(typescript@5.9.3) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + - '@rspack/core' + - '@vueuse/core' + - esbuild + - rolldown + - rollup + - typescript + - vite + - vue-tsc + - webpack + vue-macros@3.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@vueuse/core@14.2.1(vue@3.5.32(typescript@5.9.3)))(esbuild@0.27.2)(rolldown@1.0.0-rc.15)(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-tsc@3.2.6(typescript@5.9.3))(vue@3.5.32(typescript@5.9.3)): dependencies: '@vue-macros/better-define': 3.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(vue@3.5.32(typescript@5.9.3)) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 2246bedc3b..45d3198204 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -49,6 +49,7 @@ catalog: '@iconify-json/ph': ^1.2.2 '@iconify-json/tabler': ^1.2.33 '@intlify/core': ^11.3.2 + '@libsql/client': ^0.15.15 '@modelcontextprotocol/sdk': ^1.29.0 '@moeru/eslint-config': 0.1.0-beta.15 '@moeru/eventa': 1.0.0-beta.3 @@ -97,6 +98,7 @@ catalog: embla-carousel-vue: 9.0.0-rc02 es-toolkit: 1.43.0 get-port-please: ^3.2.0 + gpt-tokenizer: ^3.4.0 histoire: 1.0.0-beta.1 hono: 4.11.3 hono-rate-limiter: ^0.5.3 @@ -116,6 +118,8 @@ catalog: pinia: ^3.0.4 posthog-js: 1.306.1 splitpanes: ^4.0.4 + sql.js: ^1.13.0 + sqlite-vec: ^0.1.7-alpha.2 std-env: ^4.0.0 superjson: ^2.2.6 tsdown: ^0.21.8 diff --git a/services/qq-bot/.env b/services/qq-bot/.env new file mode 100644 index 0000000000..e69de29bb2 diff --git a/services/qq-bot/.github/copilot-instructions.md b/services/qq-bot/.github/copilot-instructions.md new file mode 100644 index 0000000000..8ad7137538 --- /dev/null +++ b/services/qq-bot/.github/copilot-instructions.md @@ -0,0 +1,65 @@ +# Copilot instructions for `services/qq-bot` + +## Build, test, and lint commands + +Run commands from repo root (`airi`) unless noted. + +- Install workspace deps: `pnpm install` +- Run this service once: `pnpm -F @proj-airi/qq-bot start` +- Run this service in watch mode: `pnpm -F @proj-airi/qq-bot dev` +- Typecheck this service: `pnpm -F @proj-airi/qq-bot typecheck` + +Repository-level commands (used when validating broader impact): + +- Lint all workspaces: `pnpm lint` +- Auto-fix lint/format: `pnpm lint:fix` +- Run all tests: `pnpm test:run` + +Single test command pattern: + +- `pnpm exec vitest run ` +- Example: `pnpm exec vitest run services/qq-bot/src/some-feature.test.ts` + +## High-level architecture + +This package is a QQ OneBot adapter scaffold built around NapLink and a staged message pipeline. + +- `src/config.ts` is the configuration center: + - defines `BotConfigSchema` with Valibot, + - exports all config types via `v.InferOutput`, + - loads YAML and applies env fallback for LLM fields (`LLM_API_BASE_URL`, `LLM_API_KEY`, `LLM_MODEL`). +- `src/client.ts` is the runtime composition root: + - builds `NapLink` client, + - wires `Dispatcher -> PipelineRunner`, + - normalizes incoming `message.group` / `message.private` events, + - runs graceful shutdown (`SIGINT` / `SIGTERM`). +- `src/types/*` defines protocol-safe internal contracts: + - `event.ts`: normalized `QQMessageEvent` and `sessionId` format (`qq:{type}:{id}`), + - `context.ts`: pipeline blackboard (`PipelineContext`) and `StageResult`, + - `message.ts`: typed message segments with input/output separation, + - `response.ts`: discriminated union payloads + fail-fast factory helpers. +- `src/pipeline/stage.ts` defines the stage base contract and timing/error wrapper. +- `src/pipeline/extensions.ts` reserves a strongly-typed cross-stage extension area. +- `src/utils/logger.ts` + `src/utils/naplink-logger-adapter.ts` provide unified logging and NapLink logger bridging. + +Design reference for the intended 7-stage pipeline (`Filter -> Wake -> RateLimit -> Session -> Process -> Decorate -> Respond`) is in: + +- `Project AIRI — QQ OneBot 适配器设计文档 ... .md` + +Current repository status to keep in mind while coding: + +- Treat the checked-in source tree as the source of truth. + +## Key conventions in this codebase + +- Keep config as a single source of truth in Valibot schemas; derive TS types from schemas instead of parallel interfaces. +- Use explicit discriminated unions (`kind`, `action`, `type`) for pipeline flow and payloads; avoid loosely typed records. +- Preserve the input/output message segment split: + - input may contain `reply` segments, + - output payloads must not contain `reply`; use `replyTo` and let dispatch layer inject reply segments. +- Use `PipelineContext` as the shared stage blackboard; cross-stage extra data belongs in `context.extensions` with typed fields, not ad-hoc metadata. +- Follow fail-fast helpers in `src/types/response.ts` (throw on invalid empty payloads) instead of silent fallbacks. +- Keep session identity format consistent: `qq:{sourceType}:{groupId|userId}`. +- Prefer factory-based assembly for runtime wiring (`createBot`, `createDispatcher`, normalizers, runner) and keep orchestration in composition roots. +- Route all logs through `createLogger(...)`/`initLoggers(...)`; do not introduce standalone logging patterns. +- Use `pnpm` workspace filters (`pnpm -F ...`) for service-scoped runs in this monorepo. diff --git a/services/qq-bot/.gitignore b/services/qq-bot/.gitignore new file mode 100644 index 0000000000..1d3a736e30 --- /dev/null +++ b/services/qq-bot/.gitignore @@ -0,0 +1,3 @@ +# Ignore the config file and data directory +config.yaml +data/* diff --git a/services/qq-bot/Dockerfile b/services/qq-bot/Dockerfile new file mode 100644 index 0000000000..5f9c3bc4b0 --- /dev/null +++ b/services/qq-bot/Dockerfile @@ -0,0 +1,34 @@ +FROM node:22-alpine AS builder + +WORKDIR /app + +RUN corepack enable + +COPY pnpm-lock.yaml pnpm-workspace.yaml package.json tsconfig.json ./ +COPY services/qq-bot/package.json services/qq-bot/tsconfig.json ./services/qq-bot/ +COPY services/qq-bot/src ./services/qq-bot/src +COPY packages/server-sdk/package.json packages/server-sdk/tsconfig.json ./packages/server-sdk/ +COPY packages/server-sdk/src ./packages/server-sdk/src +COPY packages/server-shared/package.json packages/server-shared/tsconfig.json ./packages/server-shared/ +COPY packages/server-shared/src ./packages/server-shared/src + +RUN pnpm install --frozen-lockfile +RUN pnpm --filter @proj-airi/qq-bot... build + +FROM node:22-alpine AS production + +WORKDIR /app + +RUN corepack enable + +COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./ +COPY services/qq-bot/package.json ./services/qq-bot/package.json +COPY packages/server-sdk/package.json ./packages/server-sdk/package.json +COPY packages/server-sdk/src ./packages/server-sdk/src +COPY packages/server-shared/package.json ./packages/server-shared/package.json +COPY packages/server-shared/src ./packages/server-shared/src +COPY --from=builder /app/services/qq-bot/dist ./services/qq-bot/dist + +RUN pnpm install --frozen-lockfile --prod + +CMD ["node", "services/qq-bot/dist/index.js"] diff --git a/services/qq-bot/README.md b/services/qq-bot/README.md new file mode 100644 index 0000000000..2899604523 --- /dev/null +++ b/services/qq-bot/README.md @@ -0,0 +1,469 @@ +# Project AIRI — QQ OneBot Adapter + +> QQ platform adapter for [Project AIRI](https://github.com/moeru-ai/airi) (⭐ 34.6K), based on OneBot V11 protocol. Connects to NapCat via forward WebSocket using NapLink SDK, with a 7-stage pipeline architecture. +> + +--- + +## Table of Contents + +- [Features](#features) +- [Architecture Overview](#architecture-overview) +- [Pipeline Stages](#pipeline-stages) +- [Quick Start (Foolproof Setup)](#quick-start-foolproof-setup) +- [Configuration Reference](#configuration-reference) +- [Project Structure](#project-structure) +- [Type Architecture](#type-architecture) +- [Development](#development) + +--- + +## Features + +- **OneBot V11** protocol via NapLink SDK (typed, auto-reconnect, heartbeat) +- **7-stage pipeline**: Filter → Wake → RateLimit → Session → Process → Decorate → Respond +- **Rule-first, LLM-last**: pre-filtering saves tokens +- **Config-driven**: all behaviors in one YAML, supports hot-reload +- **Adapter-only**: protocol translation only; business logic lives in the pipeline +- **QQ-native**: groups, private chats, poke, @mention, reply — all first-class + +--- + +## Architecture Overview + +```mermaid +flowchart TB + subgraph QQBot["QQ OneBot Adapter (services/qq-bot)"] + NL["NapLink Client
(Forward WS)"] --> Norm["Event Normalizer"] + Norm --> Pipeline["Pipeline (7 stages)"] + Pipeline --> Dispatch["Response Dispatcher"] + end + QQImpl["QQ Implementation
(NapCat / Lagrange / LLOneBot)"] <-->|"OneBot V11
Forward WebSocket"| NL + Dispatch -->|"NapLink API calls"| NL + Pipeline <-->|"LLM API"| AIRI["AIRI LLM Service"] +``` + +### Four Core Modules + +1. **NapLink Client** — Protocol connection layer. Manages forward WebSocket with built-in heartbeat, exponential backoff reconnect, and API timeout control. +2. **Event Normalizer** — Maps NapLink's hierarchical event callbacks (`message.group`, `notice.notify.poke`, etc.) into a unified `QQMessageEvent`. +3. **Pipeline** — Configurable 7-stage chain. Messages flow through each stage sequentially. +4. **Response Dispatcher** — Calls NapLink's wrapped API methods (`client.sendGroupMessage()`, `client.sendPrivateMessage()`) to send responses. + +--- + +## Pipeline Stages + +```mermaid +flowchart LR + F["① Filter"] --> W["② Wake"] + W --> R["③ RateLimit"] + R --> S["④ Session"] + S --> P["⑤ Process"] + P --> D["⑥ Decorate"] + D --> Res["⑦ Respond"] +``` + +| Stage | Responsibility | +| --- | --- | +| **① Filter** | Drop noise: system bots (QQ Manager), blacklists, whitelist mode, empty/emoji-only messages | +| **② Wake** | Decide if bot should respond: private chat, @bot, reply, keyword, or random | +| **③ RateLimit** | Prevent spam: per-session, per-user, global sliding windows + cooldown | +| **④ Session** | Maintain per-session message history ring buffer for LLM context | +| **⑤ Process** | Core logic: built-in commands → plugin hooks → LLM via `@xsai/generate-text` | +| **⑥ Decorate** | Post-process LLM output: split long messages, Markdown → QQ format, content filter | +| **⑦ Respond** | Send via NapLink API with simulated typing delay and retry | + +Each stage returns one of: + +- `{ action: 'continue' }` — proceed to next stage +- `{ action: 'skip' }` — abort silently, no reply +- `{ action: 'respond', payload }` — send response immediately and stop + +--- + +## Quick Start (Foolproof Setup) + +### Prerequisites + +- Node.js ≥ 20 +- A running [NapCat](https://github.com/NapNeko/NapCatQQ) instance with forward WebSocket enabled +- An OpenAI-compatible LLM API endpoint + +### Step 1 — Clone & Install + +```bash +git clone https://github.com/moeru-ai/airi.git +cd airi/services/qq-bot +npm install +``` + +### Step 2 — Configure NapCat + +In NapCat's web UI or config file, enable **forward WebSocket** and note the address (default: `ws://localhost:3001`). + +If you set an access token in NapCat, note it down. + +### Step 3 — Create Your Config File + +Copy the example config: + +```bash +cp config.example.yaml config.yaml +``` + +Then open `config.yaml` and fill in the **three required fields**: + +```yaml +# ① NapCat WebSocket address +naplink: + connection: + url: 'ws://localhost:3001' # ← change this to your NapCat address + token: 'your_token_here' # ← remove this line if no token set + +# ② AIRI server +airi: + url: 'ws://localhost:6121/ws' # ← your AIRI server WebSocket address + token: 'your-airi-token' # ← remove this line if no token required + +# ③ Wake words (how to trigger the bot in group chats) +wake: + keywords: + - 'airi' + - '爱莉' +``` + +Everything else has sensible defaults — you don't need to touch it. + +### Step 4 — Set Environment Variables (Alternative to YAML) + +If you prefer not to hardcode values in YAML, use environment variables with your deployment/template tooling: + +```bash +export AIRI_URL="ws://localhost:6121/ws" +export AIRI_TOKEN="your-airi-token" +``` + +Then reference them in your `config.yaml` values. + +### Step 5 — Run + +```bash +npm run start +# or for development with auto-reload: +npm run dev +``` + +You should see: + +``` +[12:00:00.000] [INFO ] [naplink] Connected to ws://localhost:3001 +[12:00:00.123] [INFO ] [index ] Bot QQ: 123456789 +[12:00:00.124] [INFO ] [index ] Pipeline ready with 7 stages +``` + +### Step 6 — Test It + +- **Private chat**: send any message to the bot QQ → bot replies +- **Group chat**: @bot or say a keyword → bot replies +- **Built-in commands**: `/help`, `/status`, `/clear` + +--- + +## Configuration Reference + +### `naplink` — Connection + +```yaml +naplink: + connection: + url: 'ws://localhost:3001' # NapCat WS address (required) + token: '' # Access token (optional) + timeout: 30000 # Connection timeout ms + pingInterval: 30000 # Heartbeat interval ms (0 = disable) + reconnect: + enabled: true + maxAttempts: 10 + backoff: + initial: 1000 + max: 60000 + multiplier: 2 + api: + timeout: 30000 + retries: 3 +``` + +### `filter` — Message Filtering + +```yaml +filter: + blacklistUsers: [] # QQ numbers to always ignore + blacklistGroups: [] # Group IDs to always ignore + whitelistMode: false # If true, only respond in whitelistGroups + whitelistGroups: [] + ignoreSystemUsers: # Auto-filtered system bots + - '2854196310' # QQ Manager (default) + ignoreEmptyMessages: true # Filter pure-emoji / empty messages +``` + +### `wake` — Wake Conditions + +```yaml +wake: + keywords: ['airi', '爱莉'] # Trigger keywords + keywordMatchMode: 'contains' # "prefix" | "contains" | "regex" + randomWakeRate: 0.05 # 0~1, random group chat wake probability + alwaysWakeInPrivate: true # Always respond in private chat +``` + +**Wake priority** (highest → lowest): + +1. Private chat message +2. @bot +3. Reply to bot message +4. Keyword match +5. Random (group only) + +### `rateLimit` — Rate Limiting + +```yaml +rateLimit: + perSession: + max: 10 + windowMs: 60000 # 10 messages per minute per group/chat + perUser: + max: 20 + windowMs: 60000 + global: + max: 100 + windowMs: 60000 + cooldownMs: 2000 # Post-reply cooldown + onLimited: 'silent' # "silent" | "notify" + notifyMessage: '慢一点嘛~' # Used when onLimited = notify +``` + +### `session` — Context Window + +```yaml +session: + maxHistoryPerSession: 50 # Ring buffer size per session + contextWindow: 20 # How many messages to send to LLM + timeoutMs: 1800000 # Session timeout (30 min) + isolateByTopic: false # QQ channel topic isolation (reserved) +``` + +### `process` — Core Processing + +```yaml +process: + commands: + prefix: '/' + enabled: ['help', 'status', 'clear'] + replyTimeoutMs: 120000 + sendMaxRetries: 5 +``` + +### `airi` — AIRI Server Connection + +```yaml +airi: + url: 'ws://localhost:6121/ws' + token: 'your-airi-token' +``` + +### `decorate` — Response Post-processing + +```yaml +decorate: + maxMessageLength: 4500 # Split messages longer than this + splitStrategy: 'multi-message' # "truncate" | "multi-message" + autoReply: true # Quote the original message + contentFilter: + enabled: false + replacements: {} # e.g. {"badword": "***"} +``` + +### `respond` — Sending + +```yaml +respond: + typingDelay: + min: 300 # Simulate typing delay range (ms) + max: 1200 + multiMessageDelay: 500 # Gap between multi-message sends + retryCount: 2 + retryDelayMs: 1000 +``` + +### `logging` — Global Log Level + +```yaml +logging: + level: 'info' # "debug" | "info" | "warn" | "error" | "off" +``` + +--- + +## Project Structure + +``` +services/qq-bot/ +├── src/ +│ ├── index.ts # Entry: init NapLink → register events → connect +│ ├── config.ts # Config types + Valibot schema + loader +│ ├── client.ts # NapLink instance lifecycle +│ ├── types/ +│ │ ├── index.ts # Barrel export +│ │ ├── context.ts # PipelineContext, WakeReason, StageResult +│ │ ├── event.ts # QQMessageEvent, EventSource, buildSessionId +│ │ ├── message.ts # MessageSegment discriminated union + utils +│ │ └── response.ts # ResponsePayload + factory functions +│ ├── normalizer/ +│ │ └── index.ts # NapLink event data → QQMessageEvent +│ ├── dispatcher/ +│ │ └── index.ts # Calls NapLink API to send responses +│ ├── pipeline/ +│ │ ├── extensions.ts # PipelineExtensions (shared stage data) +│ │ ├── runner.ts # Pipeline execution engine +│ │ ├── stage.ts # Abstract base class (timing + logging) +│ │ ├── filter.ts # ① FilterStage +│ │ ├── wake.ts # ② WakeStage +│ │ ├── rate-limit.ts # ③ RateLimitStage +│ │ ├── session.ts # ④ SessionStage +│ │ ├── process.ts # ⑤ ProcessStage +│ │ ├── decorate.ts # ⑥ DecorateStage +│ │ └── respond.ts # ⑦ RespondStage +│ ├── commands/ +│ │ ├── index.ts # Command registry +│ │ ├── help.ts +│ │ ├── status.ts +│ │ └── clear.ts +│ └── utils/ +│ ├── logger.ts # Unified logger (two-phase init + registry) +│ ├── naplink-logger-adapter.ts # Adapts LoggerInstance to NapLink Logger +│ ├── message-buffer.ts # Generic ring buffer (O(1) push/pop) +│ └── rate-limiter.ts # Sliding window limiter + cooldown tracker +├── config.example.yaml +├── package.json +└── tsconfig.json +``` + +--- + +## Type Architecture + +### Key Design Decisions + +**1. MessageSegment as discriminated union** + +All 9 segment types (`text`, `image`, `at`, `reply`, `face`, `file`, `voice`, `forward`, `poke`) are strongly typed. Switch on `seg.type` to narrow automatically. + +**2. PipelineStage as abstract class** + +Base class handles timing and logging via the `run()` template method. Subclasses only implement `execute()`. + +**3. Config via Valibot schema** + +One schema = TypeScript types + runtime validation + default values. No interface/validator drift. + +**4. Input vs Output message segments** + +- `InputMessageSegment` (includes `ReplySegment`) — used in `event.chain` and session history +- `OutputMessageSegment` (excludes `ReplySegment`) — used in `ResponsePayload` +- `ReplySegment` is injected by the Dispatcher from `response.replyTo`, never from stages + +**5. Circular dependency elimination** + +`PipelineContext`, `WakeReason`, and `StageResult` live in `types/context.ts`, breaking the `event.ts ↔ stage.ts` cycle. + +**6. Two-phase logger initialization** + +`createLogger('ns')` is safe to call at import time (uses default `info` level). Call `initLoggers(config)` after config loads to update all registered instances — including hot-reload. + +### Dependency Flow + +```mermaid +flowchart LR + config.ts --> pipeline/runner.ts + types/context.ts --> types/event.ts + types/context.ts --> pipeline/stage.ts + types/message.ts --> types/event.ts + types/response.ts --> types/context.ts + pipeline/extensions.ts --> types/context.ts + pipeline/stage.ts --> pipeline/filter.ts + pipeline/stage.ts --> pipeline/wake.ts + pipeline/stage.ts --> pipeline/rate-limit.ts + pipeline/stage.ts --> pipeline/session.ts + pipeline/stage.ts --> pipeline/process.ts + pipeline/stage.ts --> pipeline/decorate.ts + pipeline/stage.ts --> pipeline/respond.ts +``` + +--- + +## Development + +### Built-in Commands + +| Command | Description | +| --- | --- | +| `/help` | Show available commands | +| `/status` | Show pipeline status and config summary | +| `/clear` | Clear current session history | + +### Adding a Custom Stage + +1. Create `src/pipeline/my-stage.ts` extending `PipelineStage` +2. Implement `execute(event): Promise` +3. Register in `pipeline/runner.ts` constructor + +```ts +export class MyStage extends PipelineStage { + readonly name = 'MyStage' + constructor(private readonly config: MyConfig) { + super() + this.initLogger() + } + + async execute(event: QQMessageEvent): Promise { + // your logic here + return { action: 'continue' } + } +} +``` + +### Logging + +```ts +import { createLogger } from './utils/logger' + +const logger = createLogger('my-module') +logger.debug('detailed info') +logger.info('normal info') +logger.warn('something odd') +logger.error('something broke', error) +``` + +Set `NO_COLOR=1` to disable colored output (e.g. in CI/CD). + +### Environment Variables + +| Variable | Purpose | +| --- | --- | +| `AIRI_URL` | AIRI server WebSocket URL | +| `AIRI_TOKEN` | AIRI server token | +| `NO_COLOR` | Disable ANSI color output | + +--- + +## Acknowledgements + +The 7-stage pipeline architecture is heavily inspired by (read: shamelessly borrowed from) [AstrBot](https://github.com/Soulter/AstrBot). AstrBot is a fully-featured, elegantly architected multi-platform LLM bot framework — our pipeline is essentially a QQ OneBot-specific simplified edition of theirs. + +Huge thanks to the AstrBot team and contributors for their open-source work 🙏 + +--- + +## License + +MIT — see [AIRI main repo](https://github.com/moeru-ai/airi) for details. diff --git a/services/qq-bot/airi-client.ts b/services/qq-bot/airi-client.ts new file mode 100644 index 0000000000..9739a3edf6 --- /dev/null +++ b/services/qq-bot/airi-client.ts @@ -0,0 +1 @@ +export * from './src/airi-client' diff --git a/services/qq-bot/config.example.yaml b/services/qq-bot/config.example.yaml new file mode 100644 index 0000000000..3ca302f357 --- /dev/null +++ b/services/qq-bot/config.example.yaml @@ -0,0 +1,145 @@ +# ═══════════════════════════════════════════════════════════════ +# AIRI server 连接 +# ═══════════════════════════════════════════════════════════════ +airi: + url: ws://localhost:6121/ws + token: 'your-airi-token' + # 在 windows 下,这个 token 被配置在: + # %APPDATA%\Roaming\@proj-airi\server-channel-config.json 的 AuthToken 字段,复制过来即可 + +# ═══════════════════════════════════════════════════════════════ +# NapCat 连接 +# ═══════════════════════════════════════════════════════════════ +naplink: + connection: + url: ws://localhost:3001 + token: your-napcat-token + # timeout: 30000 # 连接超时 (ms) + # pingInterval: 30000 # 心跳间隔 (ms),0 = 禁用 + # reconnect: + # enabled: true + # maxAttempts: 10 + # backoff: + # initial: 1000 # 初始退避 (ms) + # max: 60000 # 最大退避 (ms) + # multiplier: 2 + # logging: + # level: debug # NapLink 内部日志级别: debug/info/warn/error/off + # api: + # timeout: 30000 # API 调用超时 (ms) + # retries: 3 # API 失败重试次数 + +# ═══════════════════════════════════════════════════════════════ +# 各阶段配置 +# ═══════════════════════════════════════════════════════════════ + +# ① FilterStage — 基础过滤 +filter: + blacklistUsers: [] + blacklistGroups: [] + whitelistMode: false + whitelistGroups: [] + # whitelistUsers: [] # 白名单用户 QQ 号(whitelistMode = true 时生效) + ignoreSystemUsers: [] + ignoreEmptyMessages: true + +# ② WakeStage — 唤醒判定 +wake: + keywords: ['airi', '爱莉'] + keywordMatchMode: contains # prefix / contains / regex + randomWakeRate: 0 # 群聊随机唤醒概率 (0~1) + alwaysWakeInPrivate: true + +# ③ RateLimitStage — 多维度限流 +rateLimit: + perSession: {max: 10, windowMs: 60000} + perUser: {max: 10, windowMs: 60000} + global: {max: 50, windowMs: 60000} + cooldownMs: 1000 + onLimited: silent # silent / notify + # notifyMessage: '请稍后再试~' # 仅 onLimited: notify 时生效 + +# ④ SessionStage — 会话上下文 +session: + maxHistoryPerSession: 50 + contextWindow: 20 + timeoutMs: 1800000 + isolateByTopic: false # 频道话题隔离(预留) + +# ⑤ ProcessStage — 核心处理 +process: + replyTimeoutMs: 120000 # 网络差时加大 + sendMaxRetries: 5 + commands: + prefix: '/' + enabled: [help, status, new, switch, history, clear] + +# ⑥ DecorateStage — 响应装饰 +decorate: + maxMessageLength: 4500 + splitStrategy: multi-message # truncate / multi-message + autoReply: true + contentFilter: + enabled: false + replacements: {} + +# ⑦ RespondStage — 发送行为 +respond: + typingDelay: {min: 300, max: 800} + multiMessageDelay: 500 + retryCount: 2 + retryDelayMs: 1000 + +# ═══════════════════════════════════════════════════════════════ +# 持久化 & 语义 +# ═══════════════════════════════════════════════════════════════ + +# SQLite 持久化 +db: + path: data/qq-bot.db + maxHistoryRows: 500 + pruneIntervalMs: 3600000 # 清理间隔 (ms),0 = 禁用 + +# Embedding 配置 +embedding: + enabled: true + provider: bailian + apiKey: ${BAILIAN_API_KEY} + model: text-embedding-v4 + dimension: 1024 + +# 语义检索 +semanticRetrieval: + enabled: true + topK: 5 + +# 上下文压缩 +# compression: +# enabled: true +# threshold: 0.82 # 触发阈值(占 maxContextWindow 比例) +# strategy: llm-summary # truncate / llm-summary +# truncateRounds: 2 # truncate 每次丢弃轮数 +# keepRecentRounds: 4 # summary 保留最近轮数 +# maxContextWindow: 8192 # 模型上下文 token 上限 +# summaryPrompt: | +# 基于完整对话历史,生成关键要点和进展的简洁摘要: +# 1. 系统性覆盖所有讨论的核心话题及最终结论 +# 2. 如果使用了工具,总结工具调用次数和关键发现 +# 3. 用用户的语言撰写摘要 + +# ═══════════════════════════════════════════════════════════════ +# Agent 主动发言(实验性) +# ═══════════════════════════════════════════════════════════════ +# agentLoop: +# enabled: false +# intervalMs: 60000 # 检查间隔 (ms) +# minUnreadToCheck: 3 # 最小未读数才触发检查 +# maxProactivePerHour: 5 # 每 session 每小时最大主动发言次数 + +# ═══════════════════════════════════════════════════════════════ +# 全局 +# ═══════════════════════════════════════════════════════════════ +logging: + level: info # debug / info / warn / error / off + +# botQQ: '123456789' # Bot QQ 号(用于检测 @ ) diff --git a/services/qq-bot/docker-compose.yaml b/services/qq-bot/docker-compose.yaml new file mode 100644 index 0000000000..6fe1088b6a --- /dev/null +++ b/services/qq-bot/docker-compose.yaml @@ -0,0 +1,10 @@ +services: + qq-bot: + build: + context: ../.. + dockerfile: services/qq-bot/Dockerfile + restart: unless-stopped + env_file: + - .env + volumes: + - ./config.yaml:/app/config.yaml:ro diff --git a/services/qq-bot/package.json b/services/qq-bot/package.json new file mode 100644 index 0000000000..8186c6c0e7 --- /dev/null +++ b/services/qq-bot/package.json @@ -0,0 +1,48 @@ +{ + "name": "@proj-airi/qq-bot", + "type": "module", + "private": true, + "description": "QQ bot for AIRI via OneBot V11 protocol", + "author": { + "name": "Moeru AI Project AIRI Team", + "email": "airi@moeru.ai", + "url": "https://github.com/moeru-ai" + }, + "contributors": [ + { + "name": "Phoenix Lin", + "url": "https://github.com/PhoenixForrestLin" + } + ], + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/moeru-ai/airi.git", + "directory": "services/qq-bot" + }, + "scripts": { + "start": "tsx --env-file=.env --env-file-if-exists=.env.local src/index.ts", + "dev": "tsx watch --env-file=.env --env-file-if-exists=.env.local src/index.ts", + "build": "tsc --noEmit false --outDir dist", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@guiiai/logg": "catalog:", + "@libsql/client": "catalog:", + "@naplink/naplink": "~0.0.10", + "@proj-airi/server-sdk": "workspace:^", + "@proj-airi/server-shared": "workspace:^", + "@xsai/generate-text": "catalog:", + "@xsai/shared-chat": "catalog:", + "es-toolkit": "^1.45.1", + "gpt-tokenizer": "catalog:", + "sql.js": "catalog:", + "sqlite-vec": "catalog:", + "valibot": "catalog:", + "yaml": "^2.7.1" + }, + "devDependencies": { + "@types/node": "^24.12.0", + "tsx": "^4.21.0" + } +} diff --git a/services/qq-bot/src/agent/loop.ts b/services/qq-bot/src/agent/loop.ts new file mode 100644 index 0000000000..c11acf7cdc --- /dev/null +++ b/services/qq-bot/src/agent/loop.ts @@ -0,0 +1,400 @@ +import type { AiriClient } from '../airi-client.js' +import type { MessageHistoryRow } from '../db/message-history-repo.js' +import type { ResponseDispatcher } from '../dispatcher/index.js' +import type { PassiveRecordStage } from '../pipeline/passive-record.js' +import type { QQMessageEvent } from '../types/event.js' + +import { ContextUpdateStrategy } from '../airi-client.js' +import { createDefaultContext } from '../types/context.js' +import { createTextResponse } from '../types/response.js' +import { createLogger } from '../utils/logger.js' +import { normalizeContent } from '../utils/normalize-content.js' + +export interface AgentLoopConfig { + enabled: boolean + intervalMs: number + minUnreadToCheck: number + maxProactivePerHour: number +} + +type ProactiveAction = 'respond' | 'ignore' | 'wait' + +interface ProactiveDecision { + action: ProactiveAction + message?: string +} + +interface AiriChatOutputEvent { + data?: { + 'message'?: { + content?: unknown + } + 'gen-ai:chat'?: { + input?: { + data?: { + overrides?: { + sessionId?: string + } + } + } + } + } +} + +interface AiriInputTextPayload { + type: 'input:text' + data: { + text: string + textRaw: string + overrides: { + messagePrefix: string + sessionId: string + } + contextUpdates: Array<{ + strategy: ContextUpdateStrategy + text: string + content: string + }> + } +} + +const DEFAULT_REPLY_TIMEOUT_MS = 60_000 +const DEFAULT_SEND_MAX_RETRIES = 3 +const AGENT_SESSION_SUFFIX = ':agent-loop' +const UNREAD_FETCH_LIMIT = 50 +const FENCE_PREFIX_RE = /^```[a-zA-Z]*\s*/u +const FENCE_SUFFIX_RE = /```$/u +const SESSION_ID_RE = /^qq:(private|group|guild):(.+)$/u + +export class AgentLoop { + private timer?: ReturnType + private running = false + + private readonly logger = createLogger('agent-loop') + private readonly sessionCheckpoints = new Map() + private readonly lastEvaluated = new Map() + private readonly proactiveCounters = new Map() + private readonly pendingReplies = new Map void>() + + constructor( + private readonly config: AgentLoopConfig, + private readonly passiveRecord: PassiveRecordStage, + private readonly airiClient: AiriClient, + private readonly dispatcher: ResponseDispatcher, + ) { + this.registerOutputListener() + } + + start(): void { + if (this.running) + return + + this.running = true + void this.tick() + } + + stop(): void { + this.running = false + if (this.timer) + clearTimeout(this.timer) + } + + private registerOutputListener(): void { + this.airiClient.onEvent('output:gen-ai:chat:message', (event: AiriChatOutputEvent) => { + const sessionId = event.data?.['gen-ai:chat']?.input?.data?.overrides?.sessionId + const content = normalizeContent(event.data?.message?.content) + + if (!sessionId) + return + + const resolve = this.pendingReplies.get(sessionId) + if (!resolve) + return + + this.pendingReplies.delete(sessionId) + resolve(content.trim()) + }) + } + + private async tick(): Promise { + try { + if (!this.running || !this.config.enabled) + return + + const sessions = this.passiveRecord.listActiveSessionIds() + for (const sessionId of sessions) + await this.checkSession(sessionId) + } + catch (error) { + this.logger.error('AgentLoop tick failed', error as Error) + } + finally { + if (this.running && this.config.enabled) + this.timer = setTimeout(() => void this.tick(), this.config.intervalMs) + } + } + + private async checkSession(sessionId: string): Promise { + if (!this.isGroupSession(sessionId)) + return + + if (!this.sessionCheckpoints.has(sessionId)) { + const latestId = await this.passiveRecord.getLatestMessageId(sessionId) + if (latestId != null) + this.sessionCheckpoints.set(sessionId, latestId) + return + } + + const checkpoint = this.sessionCheckpoints.get(sessionId) ?? 0 + const unread = await this.passiveRecord.getMessagesAfter(sessionId, checkpoint, UNREAD_FETCH_LIMIT) + if (unread.length === 0) + return + + const newest = unread.at(-1) + if (!newest) + return + + const newestId = newest.id + + if (unread.length < this.config.minUnreadToCheck) { + this.sessionCheckpoints.set(sessionId, newestId) + return + } + + if (!this.withinProactiveQuota(sessionId)) { + this.sessionCheckpoints.set(sessionId, newestId) + return + } + + // 如果这批消息已经评估过且没有新消息,跳过,不再调用 LLM + const lastEval = this.lastEvaluated.get(sessionId) ?? 0 + if (newestId <= lastEval) + return + + const decision = await this.requestDecision(sessionId, unread) + + if (decision.action === 'respond' && decision.message?.trim()) { + const syntheticEvent = this.buildSyntheticEvent(sessionId, newest) + await this.dispatcher.send(syntheticEvent, createTextResponse(decision.message.trim())) + this.recordProactive(sessionId) + this.sessionCheckpoints.set(sessionId, newestId) + this.lastEvaluated.delete(sessionId) + return + } + + if (decision.action === 'ignore') { + this.sessionCheckpoints.set(sessionId, newestId) + this.lastEvaluated.delete(sessionId) + return + } + + // action === 'wait':不推进 checkpoint,但标记已评估 + // 当有新消息到来时 (newestId > lastEval),会重新评估 + this.lastEvaluated.set(sessionId, newestId) + } + + private withinProactiveQuota(sessionId: string): boolean { + const now = Date.now() + const oneHourAgo = now - 60 * 60 * 1000 + const points = (this.proactiveCounters.get(sessionId) ?? []).filter(ts => ts >= oneHourAgo) + + this.proactiveCounters.set(sessionId, points) + return points.length < this.config.maxProactivePerHour + } + + private recordProactive(sessionId: string): void { + const now = Date.now() + const oneHourAgo = now - 60 * 60 * 1000 + const points = (this.proactiveCounters.get(sessionId) ?? []).filter(ts => ts >= oneHourAgo) + + points.push(now) + this.proactiveCounters.set(sessionId, points) + } + + private async requestDecision(sessionId: string, unread: MessageHistoryRow[]): Promise { + const decisionSessionId = `${sessionId}${AGENT_SESSION_SUFFIX}` + const prompt = this.buildDecisionPrompt(unread) + + const payload: AiriInputTextPayload = { + type: 'input:text' as const, + data: { + text: prompt, + textRaw: prompt, + overrides: { + messagePrefix: '(QQ AgentLoop): ', + sessionId: decisionSessionId, + }, + contextUpdates: [ + { + strategy: ContextUpdateStrategy.AppendSelf, + text: '你正在为 QQ 群消息做主动发言决策。', + content: '你正在为 QQ 群消息做主动发言决策。', + }, + ], + }, + } + + const sent = await this.sendWithRetry(payload) + if (!sent) + return { action: 'wait' } + + const raw = await this.waitForReply(decisionSessionId) + if (raw === null) + return { action: 'wait' } + + return this.parseDecision(raw) + } + + private buildDecisionPrompt(unread: MessageHistoryRow[]): string { + const summary = unread + .map((row, index) => { + const createdAt = new Date(row.createdAt).toLocaleString('zh-CN') + const sender = row.senderName ?? row.senderId + const text = (row.rawText ?? '').trim() || '[无可读文本内容]' + return `${index + 1}. [${createdAt}] ${sender}: ${text}` + }) + .join('\n') + + return [ + '你是 QQ 群聊中的 AI 助手,请判断是否需要“主动发言”。', + `当前有 ${unread.length} 条未读消息,摘要如下:`, + summary, + '请严格返回 JSON(不要返回 markdown 代码块):', + '{"action":"respond|ignore|wait","message":"当 action=respond 时给出要发送的中文内容,其它情况可留空"}', + '判断标准:', + '1) 讨论与 AI 助手明显相关,且你能提供有价值的信息时可 respond。', + '2) 若只是闲聊、与助手无关、或信息不足,优先 ignore。', + '3) 若还需要等待更多上下文再判断,返回 wait。', + '4) 若 respond,message 简洁、自然、避免自我重复。', + ].join('\n') + } + + private parseDecision(raw: string): ProactiveDecision { + const cleaned = this.extractJsonPayload(raw) + + try { + const parsed = JSON.parse(cleaned) as Partial + if (parsed.action === 'respond') { + return { + action: 'respond', + message: typeof parsed.message === 'string' ? parsed.message : '', + } + } + + if (parsed.action === 'ignore' || parsed.action === 'wait') + return { action: parsed.action } + } + catch { + this.logger.warn(`AgentLoop decision parse failed, fallback to ignore: ${raw.slice(0, 120)}`) + } + + const lower = raw.toLowerCase() + if (lower.includes('"action":"wait"') || lower.includes(' action: wait')) + return { action: 'wait' } + + return { action: 'ignore' } + } + + private extractJsonPayload(raw: string): string { + const trimmed = raw.trim() + if (!trimmed.startsWith('```')) + return trimmed + + const withoutFence = trimmed + .replace(FENCE_PREFIX_RE, '') + .replace(FENCE_SUFFIX_RE, '') + .trim() + + return withoutFence + } + + private async sendWithRetry(payload: AiriInputTextPayload): Promise { + for (let attempt = 1; attempt <= DEFAULT_SEND_MAX_RETRIES; attempt++) { + try { + await this.airiClient.ensureConnected({ timeout: 10_000 }) + } + catch { + if (attempt < DEFAULT_SEND_MAX_RETRIES) + await this.delay(2_000) + continue + } + + const ok = this.airiClient.send(payload) + if (ok) + return true + + if (attempt < DEFAULT_SEND_MAX_RETRIES) + await this.delay(2_000) + } + + return false + } + + private waitForReply(sessionId: string): Promise { + return new Promise((resolve) => { + const timeout = setTimeout(() => { + this.pendingReplies.delete(sessionId) + resolve(null) + }, DEFAULT_REPLY_TIMEOUT_MS) + + this.pendingReplies.set(sessionId, (text: string) => { + clearTimeout(timeout) + resolve(text) + }) + }) + } + + private buildSyntheticEvent(sessionId: string, latestRow: MessageHistoryRow): QQMessageEvent { + const parsed = this.parseSessionId(sessionId) + const sourceType = parsed?.type ?? 'group' + + const source = sourceType === 'private' + ? { + platform: 'qq' as const, + type: 'private' as const, + userId: parsed?.id ?? latestRow.senderId, + userName: latestRow.senderName ?? latestRow.senderId, + sessionId, + } + : { + platform: 'qq' as const, + type: 'group' as const, + userId: latestRow.senderId, + userName: latestRow.senderName ?? latestRow.senderId, + groupId: parsed?.id, + groupName: undefined, + sessionId, + } + + return { + id: `agent-loop-${Date.now()}`, + timestamp: Date.now(), + source, + raw: { kind: 'agent-loop' }, + chain: [], + text: '', + context: createDefaultContext(), + stopped: false, + } + } + + private parseSessionId(sessionId: string): { type: 'private' | 'group' | 'guild', id: string } | null { + const matched = SESSION_ID_RE.exec(sessionId) + if (!matched) + return null + + const [, type, id] = matched + return { + type: type as 'private' | 'group' | 'guild', + id, + } + } + + private isGroupSession(sessionId: string): boolean { + return sessionId.startsWith('qq:group:') + } + + private async delay(ms: number): Promise { + await new Promise(resolve => setTimeout(resolve, ms)) + } +} diff --git a/services/qq-bot/src/airi-client.ts b/services/qq-bot/src/airi-client.ts new file mode 100644 index 0000000000..3fdd7c53a7 --- /dev/null +++ b/services/qq-bot/src/airi-client.ts @@ -0,0 +1,59 @@ +// src/airi-client.ts +// ───────────────────────────────────────────────────────────── +// AIRI Server SDK 客户端封装 +// +// 功能:创建并管理与 AIRI 主程序的 WebSocket 连接。 +// 设计依据: +// - 通过 @proj-airi/server-sdk 的 Client(内部称 ServerChannel) +// 连接 AIRI server,以 WebSocket 双向收发事件。 +// - 模块只负责连接管理,业务逻辑由 ProcessStage 处理。 +// ───────────────────────────────────────────────────────────── + +import { Client as AiriChannel, ContextUpdateStrategy } from '@proj-airi/server-sdk' + +import { createLogger } from './utils/logger.js' + +export { ContextUpdateStrategy } + +export type AiriClient = ReturnType + +/** + * 创建 AIRI server 连接实例。 + * + * @param url - AIRI server WebSocket 地址,如 ws://localhost:6121/ws + * @param token - 连接令牌(与 AIRI server 配置一致,可选) + */ +export function createAiriClient(url: string, token?: string): AiriChannel { + const logger = createLogger('airi-client') + + const client = new AiriChannel({ + name: 'qq', + possibleEvents: [ + 'input:text', + 'module:configure', + 'output:gen-ai:chat:message', + ], + token: token ?? '', + url, + heartbeat: { + readTimeout: 60_000, + pingInterval: 20_000, + }, + }) + + client.onConnectionStateChange(({ previousStatus, status }) => { + logger.info(`AIRI connection: ${previousStatus} → ${status}`) + }) + + // 连接状态日志(AiriChannel 内部会自动重连) + client.onEvent('module:configure', (event) => { + logger.info('Received module:configure from AIRI server', event.data) + }) + + client.onEvent('output:gen-ai:chat:message', (event) => { + logger.debug('[AIRI ← output:gen-ai:chat:message]', JSON.stringify(event)) + }) + + logger.info(`AIRI client created, target: ${url}`) + return client +} diff --git a/services/qq-bot/src/client.ts b/services/qq-bot/src/client.ts new file mode 100644 index 0000000000..be23f46fd0 --- /dev/null +++ b/services/qq-bot/src/client.ts @@ -0,0 +1,437 @@ +// src/client.ts +// ───────────────────────────────────────────────────────────── +// NapLink 客户端工厂 & 生命周期管理 +// +// 功能:创建并管理 NapLink WebSocket 客户端实例, +// 注册消息事件 → Normalizer → PipelineRunner 的处理链路, +// 提供优雅关闭(graceful shutdown)支持。 +// +// 设计依据: +// - 工厂函数模式(非 class):createBot(config) 返回 Bot 接口, +// 避免 class 的继承复杂度,函数式组合更适合一次性初始化场景。 +// 参考 NapLink 自身的 new NapLink(config) 模式,但在上层 +// 用工厂封装,隐藏内部装配细节。 +// - Phase 1 仅注册 message.group 和 message.private 两类事件, +// notice / request 等事件在后续 Phase 扩展。 +// - NapLink 日志注入:NapLink 内部 level 设为 'debug'(全量吐出), +// 实际过滤由 NapLinkLoggerAdapter 内部的 LoggerInstance 控制, +// 全局 logging.level 变更时(initLoggers)NapLink 日志也自动调整。 +// - botQQ 自动检测:connect 后通过 getLoginInfo() 获取, +// config.botQQ 作为显式覆盖入口(可跳过一次 API 调用)。 +// - 进程信号处理:SIGINT / SIGTERM 触发 disconnect + 清理, +// 确保 WebSocket 正常关闭,NapCat 侧不残留半开连接。 +// +// 依赖(→ 表示本文件调用的模块): +// → config.ts — BotConfig 类型 +// → utils/logger.ts — createLogger +// → utils/naplink-logger-adapter.ts — NapLinkLoggerAdapter +// → normalizer/index.ts — normalizeGroupMessage, normalizePrivateMessage(待实现) +// → pipeline/runner.ts — PipelineRunner(待实现完整版) +// → dispatcher/index.ts — createDispatcher(待实现) +// ───────────────────────────────────────────────────────────── + +import type { + GroupMessageEvent, + PokeNotice, + PrivateMessageEvent, +} from '@naplink/naplink' + +import type { BotConfig } from './config.js' + +import process from 'node:process' + +import { NapLink } from '@naplink/naplink' + +import { createAiriClient } from './airi-client.js' +import { BailianEmbeddingProvider } from './context/embedding-provider.js' +import { SemanticRetriever } from './context/semantic-retriever.js' +import { ConversationRepo } from './db/conversation-repo.js' +import { initDb } from './db/index.js' +import { MessageHistoryRepo } from './db/message-history-repo.js' +import { createDispatcher } from './dispatcher/index.js' +import { normalizeGroupMessage, normalizePokeEvent, normalizePrivateMessage } from './normalizer/index.js' +import { PipelineRunner } from './pipeline/runner.js' +import { BotMessageTracker } from './utils/bot-message-tracker.js' +import { createLogger } from './utils/logger.js' +import { NapLinkLoggerAdapter } from './utils/naplink-logger-adapter.js' + +// ─── 模块级 Logger ────────────────────────────────────────── +// client.ts 是顶层编排模块,日志命名空间 'client', +// 与 NapLink 自身的 'naplink' 命名空间区分。 + +const logger = createLogger('client') + +export function createNapLinkClient(config: BotConfig): NapLink { + return new NapLink({ + ...config.naplink, + logging: { + ...config.naplink.logging, + logger: new NapLinkLoggerAdapter(), + }, + }) +} + +// ─── Bot 接口 ──────────────────────────────────────────────── +// +// 功能:定义 createBot() 返回的公共契约。 +// 设计依据: +// - 最小暴露面原则:只暴露 connect / disconnect / client / botQQ。 +// - client(NapLink 实例)暴露给 Dispatcher 等需要直接调用 +// NapLink API(sendGroupMessage 等)的场景。 +// - botQQ 在 connect() 后填充,供 WakeStage 的 @bot 唤醒判断 +// 和 Normalizer 的消息链处理(removeAtSegments)。 +// - 使用 interface 而非 type:语义更清晰(描述对象形状), +// 且 IDE hover 提示更友好。 + +export interface Bot { + /** 连接到 NapCat 并开始处理消息。connect 后 botQQ 可用。 */ + connect: () => Promise + /** 断开连接并清理资源(同步操作,NapLink disconnect 是同步的)。 */ + disconnect: () => void + /** NapLink 客户端实例引用(Dispatcher 使用,流水线内部不应直接访问)。 */ + readonly client: NapLink + /** Bot QQ 号(字符串),connect() 后可用。connect 前为空字符串。 */ + readonly botQQ: string +} + +// ─── 工厂函数 ──────────────────────────────────────────────── +// +// 功能:组装 NapLink 客户端、Normalizer、PipelineRunner、Dispatcher, +// 注册事件监听,返回可启动的 Bot 对象。 +// +// 设计依据: +// - 工厂函数而非 class:一次性组装,不需要继承或多态。 +// 闭包天然持有 config、client、runner 等内部状态, +// Bot 接口只暴露必要的公共方法(信息隐藏)。 +// - 组装顺序:NapLink → Dispatcher → PipelineRunner → 事件注册。 +// 依赖链决定顺序:Dispatcher 需要 NapLink client, +// Runner 需要 config + Dispatcher,事件回调需要 Normalizer + Runner。 +// - 事件注册在 connect() 调用之前完成:NapLink connect() 返回后 +// 立即开始触发缓冲的事件,提前注册确保不丢失连接后的首批消息。 +// +// @param config - 完整的 BotConfig(已通过 Valibot 验证 + 默认值填充) +// @returns Bot 实例(调用 connect() 后开始工作) + +export async function createBot(config: BotConfig): Promise { + // ━━━ 步骤 1:创建 NapLink 实例 ━━━━━━━━━━━━━━━━━━━━━━━━━━ + // + // 功能:实例化 NapLink WebSocket 客户端。 + // 设计依据: + // - 透传 config.naplink 的所有字段(connection、reconnect、api), + // NapLink 内部据此管理连接、重连、心跳、API 超时。 + // - logging.logger 注入 NapLinkLoggerAdapter: + // NapLink level 设为 config 中的值(默认 'debug',全量吐出), + // 适配器内部的 LoggerInstance('naplink') 按全局 logging.level 过滤。 + // 这样 initLoggers(config) 刷新级别后,NapLink 日志也自动调整, + // 无需重建 NapLink 实例或重连 WebSocket。 + // - 展开 config.naplink.logging 并覆盖 logger 字段: + // 保留用户可能在 YAML 中配置的 NapLink logging.level, + // 同时注入自定义 logger。 + + const client = createNapLinkClient(config) + const airiConfig = config.airi ?? { + url: 'ws://localhost:6121/ws', + token: undefined, + } + + // ━━━ 步骤 2:创建 Dispatcher ━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + // + // 功能:Dispatcher 持有 NapLink client 引用,封装消息发送逻辑 + // (sendGroupMessage / sendPrivateMessage / sendForward)。 + // 设计依据: + // - 在 PipelineRunner 之前创建:RespondStage 需要 Dispatcher + // 来发送最终响应,Runner 构造时将 Dispatcher 传给 RespondStage。 + // - createDispatcher 是工厂函数(与 createBot 同模式), + // 接收 NapLink client 作为唯一依赖。 + + const botMessageTracker = new BotMessageTracker() + const dispatcher = createDispatcher(client, config.respond, botMessageTracker) + const airiClient = createAiriClient(airiConfig.url, airiConfig.token) + const db = await initDb(config.db?.path ?? 'data/qq-bot.db') + const messageHistoryRepo = new MessageHistoryRepo(db) + const conversationRepo = new ConversationRepo(db) + + const embeddingConfig = config.embedding ?? { + enabled: true, + provider: 'bailian' as const, + apiKey: undefined, + model: 'text-embedding-v4', + dimension: 1024, + } + const semanticConfig = config.semanticRetrieval ?? { + enabled: true, + topK: 5, + } + + let semanticRetriever: SemanticRetriever | undefined + if (embeddingConfig.enabled && semanticConfig.enabled) { + if (embeddingConfig.provider === 'bailian' && embeddingConfig.apiKey) { + semanticRetriever = new SemanticRetriever( + new BailianEmbeddingProvider(embeddingConfig.apiKey, { + model: embeddingConfig.model, + dimension: embeddingConfig.dimension, + }), + db, + ) + } + else { + logger.warn('Semantic retrieval enabled but embedding provider is not fully configured, feature will be disabled') + } + } + + // ━━━ 步骤 3:创建 PipelineRunner ━━━━━━━━━━━━━━━━━━━━━━━━━ + // + // 功能:组装 7 阶段流水线(Filter → Wake → RateLimit → Session → + // Process → Decorate → Respond),提供 run(event) 入口。 + // 设计依据: + // - Runner 接收完整 config + dispatcher: + // 各 Stage 从 config 的对应子段获取自己的配置, + // RespondStage 额外需要 dispatcher 发送消息。 + // - Runner 的生命周期与 Bot 一致(闭包持有), + // 不支持运行时替换 Stage(配置热重载通过重建 Runner 实现, + // 但当前 Phase 不实现热重载——YAGNI)。 + + const runner = new PipelineRunner( + config, + airiClient, + dispatcher, + messageHistoryRepo, + conversationRepo, + semanticRetriever, + botMessageTracker, + ) + await runner.preheatPassiveRecords(await runner.listKnownSessionIds()) + + // ━━━ 步骤 4:内部状态 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + // + // _botQQ 通过闭包持有,connect() 后通过 getBotQQ() 填充。 + // Bot 接口通过 getter 暴露为只读属性。 + // 初始为空字符串(而非 undefined): + // - 避免下游做 null check,string 类型始终一致 + // - connect() 前调用 bot.botQQ 返回 '' 而非报错, + // 调用方可通过 bot.botQQ === '' 判断是否已连接 + + let _botQQ = '' + + // ━━━ 步骤 5:getBotQQ — 自动检测 Bot QQ 号 ━━━━━━━━━━━━━━ + // + // 功能:获取当前登录的 Bot QQ 号,供 WakeStage 和 Normalizer 使用。 + // 设计依据: + // - 优先使用 config.botQQ(显式配置跳过 API 调用, + // 适用于测试环境或多账号场景)。 + // - 否则调用 NapLink getLoginInfo(): + // 返回 { user_id: number, nickname: string }, + // 取 user_id 并 String() 转为字符串。 + // - 必须在 client.connect() 之后调用——NapLink 需先建立 + // WebSocket 连接才能发送 OneBot get_login_info action。 + // - user_id 转 string 原因:QQ 号虽然目前不超过 JS 安全整数 + // 范围(Number.MAX_SAFE_INTEGER = 2^53 - 1),但统一用 string + // 与 EventSource.userId、AtSegment.data.qq 类型对齐, + // 避免跨模块的 number/string 转换噪音。 + + async function getBotQQ(): Promise { + if (config.botQQ) { + logger.info(`Using configured botQQ: ${config.botQQ}`) + return config.botQQ + } + + logger.info('botQQ not configured, detecting via getLoginInfo()...') + const loginInfo = await client.getLoginInfo() + const qq = String(loginInfo.user_id) + logger.info(`Detected botQQ: ${qq} (nickname: ${loginInfo.nickname})`) + return qq + } + + // ━━━ 步骤 6:注册消息事件 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + // + // 功能:监听 NapLink 的 message.group 和 message.private 事件, + // 将事件 data 通过 Normalizer 标准化为 QQMessageEvent, + // 然后交给 PipelineRunner 执行 7 阶段处理。 + // + // 设计依据: + // - Phase 1 仅处理消息事件(message.group + message.private): + // 这是 bot 的核心功能,notice(戳一戳、成员变动等)和 + // request(好友/入群申请)在后续 Phase 扩展。 + // - 事件回调内 try-catch 错误隔离: + // 单条消息处理失败(Normalizer 异常、Stage 异常、LLM 超时等) + // 不应崩溃整个 bot 进程。只记录错误日志,继续处理后续消息。 + // 参考 AstrBot 的事件循环:except Exception 捕获后 log + continue。 + // - Normalizer 在回调内调用(而非 Runner 内部): + // 职责分离 — client.ts 负责「从 NapLink 拿到数据并标准化」, + // Runner 的 run() 只接收统一的 QQMessageEvent,不感知 NapLink。 + // 这使 Runner 可被独立测试(传入 mock QQMessageEvent)。 + // - _botQQ 通过闭包传递给 Normalizer: + // Normalizer 需要 botQQ 来处理 @bot 消息段。 + // 时序安全:事件回调在 connect() 后才触发, + // 而 _botQQ 在 connect() 中已通过 getBotQQ() 填充。 + + function registerEvents(): void { + // ── 群聊消息处理 ── + // NapLink 'message.group' 事件回调参数 data 类型为 + // GroupMessageEventData(含 group_id, user_id, message, raw_message 等)。 + // normalizeGroupMessage 将其映射为 QQMessageEvent(source.type = 'group')。 + client.on('message.group', async (data) => { + try { + const event = normalizeGroupMessage(data as GroupMessageEvent, _botQQ) + await runner.run(event) + } + catch (err) { + logger.error('Failed to process group message', err as Error) + } + }) + + // ── 私聊消息处理 ── + // NapLink 'message.private' 事件回调参数 data 类型为 + // PrivateMessageEventData(含 user_id, message, raw_message 等,无 group_id)。 + // normalizePrivateMessage 将其映射为 QQMessageEvent(source.type = 'private')。 + client.on('message.private', async (data) => { + try { + const event = normalizePrivateMessage(data as PrivateMessageEvent, _botQQ) + await runner.run(event) + } + catch (err) { + logger.error('Failed to process private message', err as Error) + } + }) + + // Phase 5: 戳一戳事件(仅戳 bot 自己时触发) + client.on('notice.notify.poke', async (data) => { + try { + const event = normalizePokeEvent(data as PokeNotice, _botQQ) + if (!event) + return + await runner.run(event) + } + catch (err) { + logger.error('Failed to process poke event', err as Error) + } + }) + + logger.debug('Message event handlers registered (message.group, message.private)') + } + + // ━━━ 步骤 7:注册生命周期事件 ━━━━━━━━━━━━━━━━━━━━━━━━━━━ + // + // 功能:监听 NapLink 连接 / 断开 / 重连事件,输出状态日志。 + // 设计依据: + // - connect / disconnect / reconnecting 是 NapLink EventEmitter + // 内置的生命周期事件(见 NapLink 事件文档)。 + // - 仅做日志输出,不干预 NapLink 的自动重连机制: + // 重连策略由 config.naplink.reconnect 控制(指数退避、最大次数等), + // NapLink 内部自行管理,我们不需要手动触发。 + // - 不监听 meta_event.heartbeat:NapLink 已内置心跳管理, + // 额外监听只会在 debug 日志中产生高频噪音(默认 30s 一次)。 + // - disconnect 用 warn 级别:断连通常需要关注(网络问题、NapCat 崩溃等), + // 但如果是主动调用 bot.disconnect() 则为预期行为,warn 不过分。 + + function registerLifecycleEvents(): void { + client.on('connect', () => { + logger.info('WebSocket connected to NapCat') + }) + + client.on('disconnect', () => { + logger.warn('WebSocket disconnected from NapCat') + }) + + client.on('reconnecting', () => { + logger.info('Attempting to reconnect to NapCat...') + }) + } + + // ━━━ 步骤 8:优雅关闭(Graceful Shutdown)━━━━━━━━━━━━━━━ + // + // 功能:注册 SIGINT (Ctrl+C) 和 SIGTERM (kill / PM2 stop) 处理器, + // 在进程退出前断开 NapLink 连接。 + // + // 设计依据: + // - 不调 disconnect 直接退出的后果: + // NapCat 侧 WebSocket 连接进入 CLOSE_WAIT / 半开状态, + // 占用连接资源直到超时清理(默认可能数分钟)。 + // 对于 NapCat 只允许单个 bot 连接的部署模式, + // 这会导致新 bot 实例无法立即连接。 + // - process.once() 而非 process.on(): + // 防止用户快速连按 Ctrl+C 导致 shutdown 函数重复执行, + // 第二次 Ctrl+C 触发 Node.js 默认行为(强制退出)。 + // - process.exit(0): + // NapLink disconnect() 是同步方法(关闭 WebSocket 并清理定时器), + // 不需要 await。exit(0) 确保 Node.js 事件循环终止, + // 不会因残留的 setInterval(如心跳检测)阻止进程退出。 + // - 参考 NapLink 最佳实践文档的 PM2 部署方案: + // PM2 stop 发送 SIGINT 给子进程,bot 响应信号执行清理。 + + function setupGracefulShutdown(): void { + const shutdown = (signal: string) => { + logger.info(`Received ${signal}, shutting down...`) + try { + client.disconnect() + logger.info('NapLink client disconnected') + } + catch (err) { + // disconnect 失败不应阻止进程退出 + // 可能原因:WebSocket 已关闭、NapCat 已不可达 + logger.error('Error during disconnect', err as Error) + } + process.exit(0) + } + + process.once('SIGINT', () => shutdown('SIGINT')) + process.once('SIGTERM', () => shutdown('SIGTERM')) + } + + // ━━━ 步骤 9:组装 & 事件注册 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + // + // 设计依据: + // - 事件注册在工厂函数内完成(而非 connect() 内): + // NapLink connect() 返回的 Promise resolve 后立即开始 + // 触发缓冲的事件,如果在 connect() 后才注册监听器, + // 可能丢失连接后的首批消息。 + // - 注册顺序:消息事件 → 生命周期事件 → 信号处理。 + // 消息事件最先注册,确保就绪;生命周期事件次之(日志用途); + // 信号处理最后(仅影响进程退出流程)。 + + registerEvents() + registerLifecycleEvents() + setupGracefulShutdown() + + // ━━━ 步骤 10:返回 Bot 接口 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + // + // 闭包持有 client、runner、dispatcher、_botQQ 等内部状态, + // Bot 接口只暴露 4 个成员,实现信息隐藏。 + // getter 保证 client 和 botQQ 为只读(外部无法 bot.botQQ = 'xxx')。 + + return { + async connect() { + logger.info('Connecting to NapCat...') + + // NapLink connect() 建立 WebSocket 连接, + // 等待 OneBot lifecycle.connect 元事件确认连接成功。 + // 失败时抛出 ConnectionError(由调用方 index.ts catch 处理)。 + await client.connect() + + // 连接成功后获取 botQQ —— 必须在 connect 之后, + // 因为 getLoginInfo 需要通过已建立的 WebSocket 发送 action。 + _botQQ = await getBotQQ() + runner.setBotQQ(_botQQ) + + logger.info(`Bot is ready (QQ: ${_botQQ})`) + }, + + disconnect() { + logger.info('Manual disconnect requested') + client.disconnect() + logger.info('Disconnected') + }, + + get client() { + return client + }, + + get botQQ() { + return _botQQ + }, + } +} + +export function createClient(config: BotConfig): NapLink { + return createNapLinkClient(config) +} diff --git a/services/qq-bot/src/commands/clear.ts b/services/qq-bot/src/commands/clear.ts new file mode 100644 index 0000000000..87d9f59140 --- /dev/null +++ b/services/qq-bot/src/commands/clear.ts @@ -0,0 +1,9 @@ +import type { CommandHandler } from './types.js' + +import { createTextResponse } from '../types/response.js' + +// NOTICE: 实际清理动作由 PipelineRunner 在发送前根据 +// event.context.extensions._clearSession 统一执行。 +export const clearCommand: CommandHandler = async (_event, _args, _context) => { + return createTextResponse('已清除当前会话上下文。') +} diff --git a/services/qq-bot/src/commands/help.ts b/services/qq-bot/src/commands/help.ts new file mode 100644 index 0000000000..7b03c29069 --- /dev/null +++ b/services/qq-bot/src/commands/help.ts @@ -0,0 +1,13 @@ +import type { CommandHandler } from './types.js' + +import { createTextResponse } from '../types/response.js' + +export const helpCommand: CommandHandler = async (_event, _args, _context) => { + const text = [ + 'AIRI QQ Bot 可用命令:', + '/help - 显示帮助', + '/status - 查看运行状态', + '/clear - 清空当前会话上下文', + ].join('\n') + return createTextResponse(text) +} diff --git a/services/qq-bot/src/commands/index.ts b/services/qq-bot/src/commands/index.ts new file mode 100644 index 0000000000..009e9eee75 --- /dev/null +++ b/services/qq-bot/src/commands/index.ts @@ -0,0 +1,35 @@ +import type { CommandsConfig } from '../config.js' +import type { CommandHandler } from './types.js' + +import { clearCommand } from './clear.js' +import { helpCommand } from './help.js' +import { statusCommand } from './status.js' + +export interface CommandRegistry { + prefix: string + handlers: Map +} + +const BUILTIN_COMMANDS: Record = { + clear: clearCommand, + help: helpCommand, + status: statusCommand, +} + +export function createCommandRegistry(config: CommandsConfig): CommandRegistry { + const resolvedConfig = config ?? { + prefix: '/', + enabled: ['help', 'status', 'new', 'switch', 'history', 'clear'], + } + const handlers = new Map() + for (const commandName of resolvedConfig.enabled) { + const handler = BUILTIN_COMMANDS[commandName] + if (handler) + handlers.set(commandName, handler) + } + + return { + prefix: resolvedConfig.prefix, + handlers, + } +} diff --git a/services/qq-bot/src/commands/status.ts b/services/qq-bot/src/commands/status.ts new file mode 100644 index 0000000000..f6ec76d34a --- /dev/null +++ b/services/qq-bot/src/commands/status.ts @@ -0,0 +1,13 @@ +import type { CommandHandler } from './types.js' + +import { createTextResponse } from '../types/response.js' + +export const statusCommand: CommandHandler = async (_event, _args, context) => { + const uptimeSeconds = Math.floor((Date.now() - context.startedAt) / 1000) + const text = [ + 'AIRI QQ Bot 状态:', + `运行时长:${uptimeSeconds}s`, + `已处理消息:${context.processedCount}`, + ].join('\n') + return createTextResponse(text) +} diff --git a/services/qq-bot/src/commands/types.ts b/services/qq-bot/src/commands/types.ts new file mode 100644 index 0000000000..b418de7bd2 --- /dev/null +++ b/services/qq-bot/src/commands/types.ts @@ -0,0 +1,13 @@ +import type { QQMessageEvent } from '../types/event.js' +import type { ResponsePayload } from '../types/response.js' + +export interface CommandContext { + processedCount: number + startedAt: number +} + +export type CommandHandler = ( + event: QQMessageEvent, + args: string[], + context: CommandContext, +) => Promise diff --git a/services/qq-bot/src/config.ts b/services/qq-bot/src/config.ts new file mode 100644 index 0000000000..c373392df1 --- /dev/null +++ b/services/qq-bot/src/config.ts @@ -0,0 +1,773 @@ +// src/config.ts +// ───────────────────────────────────────────────────────────── +// 配置类型定义、Valibot Schema、加载函数 +// +// 功能:定义 QQ Bot 的完整配置结构,使用 Valibot schema 实现 +// 运行时验证 + 默认值填充 + TypeScript 类型推断三合一。 +// 设计依据: +// - 单一 schema 源(Single Source of Truth):所有配置的类型、 +// 默认值、验证规则都由 Valibot schema 定义,避免 interface +// 与验证逻辑不同步。 +// - 配置优先级:YAML 文件 > 环境变量 > 内置默认值。 +// Valibot 处理 YAML + 默认值,loadConfig 后置处理 env fallback。 +// - 与设计文档 §完整配置文件结构 一一对应,每个 Stage 的配置 +// 独立为子 schema,顶层 BotConfigSchema 组合所有子段。 +// - 各 PipelineStage 子类构造函数接收对应的子类型 +// (如 FilterStage(config: FilterConfig)),通过 v.InferOutput 推断。 +// ───────────────────────────────────────────────────────────── + +import fs from 'node:fs' +import process from 'node:process' + +import { parse as parseYaml } from 'yaml' + +import * as v from 'valibot' + +import { createLogger } from './utils/logger.js' + +// ─── 惰性 Logger ───────────────────────────────────────────── +// 与 response.ts 同理:模块加载时 config 尚未就绪, +// 首次调用 getLogger() 时才实例化。 +// 但 loadConfig 本身是启动阶段调用,此时 logger 已通过 +// createLogger 获得默认 info 级别实例,足够使用。 + +const logger = createLogger('config') +const EMBEDDING_ENV_PLACEHOLDER_RE = /^\$\{([A-Z0-9_]+)\}$/u + +// ═══════════════════════════════════════════════════════════════ +// §1 NapLink 连接配置 Schema +// ═══════════════════════════════════════════════════════════════ +// 功能:定义 NapLink SDK 构造函数所需的连接参数。 +// 设计依据: +// - 字段与 NapLink 的 NapLinkConfig 类型一一对应,直接透传。 +// - url 是唯一必填项(NapCat 的 WebSocket 地址)。 +// - 超时和心跳间隔有合理默认值,用户通常不需要修改。 + +const NapLinkConnectionSchema = v.object({ + /** NapCat WebSocket 地址,如 "ws://localhost:3001" */ + url: v.string(), + /** 访问令牌(可选,NapCat 配置了 token 时需要匹配) */ + token: v.optional(v.string()), + /** 连接超时(毫秒),默认 30000 */ + timeout: v.optional(v.number(), 30_000), + /** 心跳间隔(毫秒),默认 30000,0 = 禁用 */ + pingInterval: v.optional(v.number(), 30_000), +}) + +// ─── NapLink 重连配置 ─── +// 功能:控制断线后的自动重连行为。 +// 设计依据: +// - 指数退避(exponential backoff)是 WebSocket 重连的业界标准。 +// - NapLink SDK 内置重连机制,这些参数直接透传给 SDK。 +// - 默认启用重连,最多 10 次,初始延迟 1s,最大 60s,倍率 2。 + +const NapLinkBackoffSchema = v.object({ + /** 初始延迟(毫秒) */ + initial: v.optional(v.number(), 1_000), + /** 最大延迟(毫秒) */ + max: v.optional(v.number(), 60_000), + /** 退避倍数 */ + multiplier: v.optional(v.number(), 2), +}) + +const NapLinkReconnectSchema = v.object({ + /** 是否启用自动重连 */ + enabled: v.optional(v.boolean(), true), + /** 最大重连次数 */ + maxAttempts: v.optional(v.number(), 10), + /** 退避策略 */ + backoff: v.optional(NapLinkBackoffSchema, { + initial: 1_000, + max: 60_000, + multiplier: 2, + }), +}) + +// ─── NapLink 日志配置 ─── +// 设计依据:NapLink 自身的日志级别控制。 +// 实际做法是把 NapLink level 设为 'debug'(全部吐出), +// 由我们的 NapLinkLoggerAdapter 内部 LoggerInstance 控制过滤。 +// 但仍暴露此配置项,允许用户在 NapLink 层级额外限制。 + +const NapLinkLoggingSchema = v.object({ + level: v.optional( + v.picklist(['debug', 'info', 'warn', 'error', 'off']), + 'debug', + ), +}) + +// ─── NapLink API 配置 ─── +// 功能:控制 NapLink API 调用(OneBot action)的超时和重试。 +// 设计依据:NapLink 对每个 action 调用都有内置的超时和重试机制, +// 这些参数直接透传给 SDK。 + +const NapLinkApiSchema = v.object({ + /** API 调用超时(毫秒) */ + timeout: v.optional(v.number(), 30_000), + /** API 失败重试次数 */ + retries: v.optional(v.number(), 3), +}) + +// ─── NapLink 顶层 Schema ─── + +const NapLinkSchema = v.object({ + connection: NapLinkConnectionSchema, + reconnect: v.optional(NapLinkReconnectSchema, { + enabled: true, + maxAttempts: 10, + backoff: { initial: 1_000, max: 60_000, multiplier: 2 }, + }), + logging: v.optional(NapLinkLoggingSchema, { level: 'debug' }), + api: v.optional(NapLinkApiSchema, { timeout: 30_000, retries: 3 }), +}) + +// ═══════════════════════════════════════════════════════════════ +// §2 流水线各阶段配置 Schema +// ═══════════════════════════════════════════════════════════════ + +// ─── ① FilterStage 配置 ─── +// 功能:基础过滤——黑白名单、系统用户、空消息过滤。 +// 设计依据: +// - 合并 AstrBot 的 WakingCheck + Whitelist 两个阶段为一个。 +// - whitelistMode 默认关闭(false),即默认接受所有非黑名单消息。 +// - QQ 管家 (2854196310) 作为默认系统用户写入 ignoreSystemUsers。 +// - ignoreEmptyMessages 默认 true,过滤纯表情/空内容消息, +// 避免浪费 LLM token。 +// - whitelistUsers 是对设计文档的补充:whitelistMode 开启时, +// 除了白名单群,也应支持白名单用户(如管理员私聊)。 + +const FilterConfigSchema = v.object({ + /** QQ 号黑名单 */ + blacklistUsers: v.optional(v.array(v.string()), []), + /** 群号黑名单 */ + blacklistGroups: v.optional(v.array(v.string()), []), + /** 是否启用白名单模式(启用后仅允许白名单内的群/用户) */ + whitelistMode: v.optional(v.boolean(), false), + /** 白名单群号(whitelistMode = true 时生效) */ + whitelistGroups: v.optional(v.array(v.string()), []), + /** + * 白名单用户 QQ 号(whitelistMode = true 时生效) + * 设计补充:原设计文档仅有 whitelistGroups, + * 但白名单模式下也需要支持特定用户(如管理员私聊), + * 故增加此字段。FilterStage 判定逻辑: + * whitelistMode && !(whitelistGroups.includes(groupId) || whitelistUsers.includes(userId)) + * → skip + */ + whitelistUsers: v.optional(v.array(v.string()), []), + /** 系统用户 QQ 号(始终过滤),默认含 QQ 管家 */ + ignoreSystemUsers: v.optional(v.array(v.string()), ['2854196310']), + /** 是否过滤纯表情/空消息 */ + ignoreEmptyMessages: v.optional(v.boolean(), true), +}) + +// ─── ② WakeStage 配置 ─── +// 功能:判定消息是否需要 bot 响应。 +// 设计依据: +// - 唤醒条件优先级:私聊 > @bot > 回复bot > 关键词 > 随机。 +// - keywordMatchMode 支持三种模式,'contains' 最常用作为默认。 +// - randomWakeRate 默认 0(关闭),避免 bot 未经配置就随机回复。 +// - alwaysWakeInPrivate 默认 true:私聊场景 bot 应始终响应。 + +const WakeConfigSchema = v.object({ + /** 触发关键词列表,如 ["airi", "爱莉"] */ + keywords: v.optional(v.array(v.string()), []), + /** 关键词匹配模式:prefix = 前缀匹配,contains = 包含,regex = 正则 */ + keywordMatchMode: v.optional( + v.picklist(['prefix', 'contains', 'regex']), + 'contains', + ), + /** + * 群聊随机唤醒概率 (0~1),0 = 关闭。 + * loadConfig 后置校验会 clamp 到 [0, 1] 范围。 + */ + randomWakeRate: v.optional(v.number(), 0), + /** 私聊是否始终唤醒 */ + alwaysWakeInPrivate: v.optional(v.boolean(), true), +}) + +// ─── ③ RateLimitStage 配置 ─── +// 功能:防止 bot 刷屏,多维度限流。 +// 设计依据: +// - 三个维度独立限流(per-session / per-user / global), +// 参考 AstrBot 的 RateLimitStage 但增加了 per-user 维度。 +// - 滑动窗口(windowMs)比固定窗口更平滑,避免窗口边界突发。 +// - cooldownMs 是回复后的冷却期,防止同一会话连续触发。 +// - onLimited 默认 'silent'(静默丢弃),避免限流提示本身成为刷屏。 + +const RateLimitWindowSchema = v.object({ + /** 窗口内最大允许次数 */ + max: v.number(), + /** 窗口长度(毫秒) */ + windowMs: v.number(), +}) + +const RateLimitConfigSchema = v.object({ + /** 每会话限流:同一群/私聊内 N 条/窗口 */ + perSession: v.optional(RateLimitWindowSchema, { max: 10, windowMs: 60_000 }), + /** 每用户限流:同一用户 N 条/窗口 */ + perUser: v.optional(RateLimitWindowSchema, { max: 10, windowMs: 60_000 }), + /** 全局限流:所有消息 N 条/窗口 */ + global: v.optional(RateLimitWindowSchema, { max: 60, windowMs: 60_000 }), + /** 单次回复后冷却时间(毫秒) */ + cooldownMs: v.optional(v.number(), 3_000), + /** 被限流时的策略:silent = 静默丢弃,notify = 回复提示 */ + onLimited: v.optional(v.picklist(['silent', 'notify']), 'silent'), + /** 限流提示语(仅 onLimited = 'notify' 时使用) */ + notifyMessage: v.optional(v.string(), '请稍后再试~'), +}) + +// ─── ④ SessionStage 配置 ─── +// 功能:控制会话上下文管理行为。 +// 设计依据: +// - maxHistoryPerSession = 50:环形缓冲区容量, +// 参考 AIRI Telegram 的 100 条但减半 +// (QQ 群聊消息更碎片化,50 条已覆盖足够上下文)。 +// - contextWindow = 20:传给 LLM 的上下文条数, +// 与 AIRI Telegram 一致,平衡上下文质量与 token 消耗。 +// - timeoutMs = 30 分钟:会话超时重置, +// 参考常见聊天机器人的会话过期策略。 +// - isolateByTopic:QQ 频道话题隔离,Phase 5 预留。 + +const SessionConfigSchema = v.object({ + /** 环形缓冲区容量(每会话最大历史条数) */ + maxHistoryPerSession: v.optional(v.number(), 50), + /** LLM 上下文窗口大小(取最近 N 条传给 LLM) */ + contextWindow: v.optional(v.number(), 20), + /** 会话超时(毫秒),超时后清空上下文 */ + timeoutMs: v.optional(v.number(), 30 * 60 * 1_000), + /** QQ 频道话题隔离(Phase 5 预留) */ + isolateByTopic: v.optional(v.boolean(), false), +}) + +// ─── ④.1 DB 配置 ─── +// 功能:控制本地 SQLite 持久化路径与清理策略。 +const DbConfigSchema = v.object({ + /** SQLite 文件路径 */ + path: v.optional(v.string(), 'data/qq-bot.db'), + /** 每个 session 保留的消息历史条数上限 */ + maxHistoryRows: v.optional(v.number(), 500), + /** 定时清理间隔(毫秒),0 = 禁用 */ + pruneIntervalMs: v.optional(v.number(), 3_600_000), +}) + +// ─── ④.3 Embedding 配置 ─── +// 功能:控制 embedding 提供方与模型参数。 +const EmbeddingConfigSchema = v.object({ + enabled: v.optional(v.boolean(), true), + provider: v.optional(v.picklist(['bailian']), 'bailian'), + apiKey: v.optional(v.string()), + model: v.optional(v.string(), 'text-embedding-v4'), + dimension: v.optional(v.number(), 1024), +}) + +// ─── ④.4 语义检索配置 ─── +// 功能:控制是否启用语义检索与返回条数。 +const SemanticRetrievalConfigSchema = v.object({ + enabled: v.optional(v.boolean(), true), + topK: v.optional(v.number(), 5), +}) + +// ─── ④.2 Context Compression 配置 ─── +// 功能:在上下文接近模型窗口上限时压缩历史,避免硬截断。 +const CompressionConfigSchema = v.object({ + /** 是否启用上下文压缩 */ + enabled: v.optional(v.boolean(), true), + /** 触发阈值(占 maxContextWindow 的比例) */ + threshold: v.optional(v.number(), 0.82), + /** 压缩策略:truncate 或 llm-summary */ + strategy: v.optional(v.picklist(['truncate', 'llm-summary']), 'llm-summary'), + /** truncate 策略:每次丢弃轮数 */ + truncateRounds: v.optional(v.number(), 2), + /** llm-summary 策略:保留最近轮数 */ + keepRecentRounds: v.optional(v.number(), 4), + /** 模型上下文窗口上限 */ + maxContextWindow: v.optional(v.number(), 8192), + /** llm-summary 的摘要提示词 */ + summaryPrompt: v.optional(v.string(), [ + '基于完整对话历史,生成关键要点和进展的简洁摘要:', + '1. 系统性覆盖所有讨论的核心话题及最终结论', + '2. 如果使用了工具,总结工具调用次数和关键发现', + '3. 用用户的语言撰写摘要', + ].join('\n')), +}) + +// ─── ⑤ ProcessStage 配置 ─── +// 功能:核心处理阶段的配置,包含命令系统。 +// 设计依据: +// - 命令前缀默认 '/',与大多数 QQ bot 惯例一致。 +// env fallback,在 loadConfig 后置处理中实现(§5 步骤 4)。 +// 这三个字段在 schema 中均为 v.optional(v.string()), +// 不设 Valibot 层默认值——默认值来自环境变量。 +// - systemPrompt 默认空字符串,留空 = 不注入 system message, +// 由用户在 YAML 中自定义角色设定。 +// - temperature 0.7 是对话场景的常用值(兼顾创意与一致性)。 +// - maxTokens 2048 适合大多数单轮回复场景。 + +const ProcessConfigSchema = v.object({ + /** AIRI 响应超时(毫秒),默认 60000 */ + replyTimeoutMs: v.optional(v.number(), 60_000), + /** 发送失败重试次数,默认 3 */ + sendMaxRetries: v.optional(v.number(), 3), + commands: v.optional( + v.object({ + prefix: v.optional(v.string(), '/'), + enabled: v.optional( + v.array(v.string()), + ['help', 'status', 'new', 'switch', 'history', 'clear'], + ), + }), + { prefix: '/', enabled: ['help', 'status', 'new', 'switch', 'history', 'clear'] }, + ), +}) + +// ─── ⑥ DecorateStage 配置 ─── +// 功能:响应装饰——消息分割、格式转换、内容过滤。 +// 设计依据: +// - maxMessageLength = 4500:QQ 单条消息字符限制约 4500~5000, +// 留 500 字符余量避免边界截断。 +// - splitStrategy 默认 'multi-message':长消息拆为多条发送, +// 比截断(truncate)用户体验更好。 +// - autoReply 默认 true:群聊中自动引用原消息, +// 声明式设置 response.replyTo,Dispatcher 统一注入 ReplySegment。 +// - contentFilter 默认关闭,用户按需启用敏感词替换。 + +const ContentFilterSchema = v.object({ + /** 是否启用内容过滤 */ + enabled: v.optional(v.boolean(), false), + /** 敏感词替换映射 { "原词": "替换词" } */ + replacements: v.optional(v.record(v.string(), v.string()), {}), +}) + +const DecorateConfigSchema = v.object({ + /** 单条消息最大长度(字符数) */ + maxMessageLength: v.optional(v.number(), 4500), + /** 长消息拆分策略:truncate = 截断,multi-message = 拆分为多条 */ + splitStrategy: v.optional( + v.picklist(['truncate', 'multi-message']), + 'multi-message', + ), + /** 是否自动引用原消息(声明式,Dispatcher 统一注入 ReplySegment) */ + autoReply: v.optional(v.boolean(), true), + /** 内容过滤配置 */ + contentFilter: v.optional(ContentFilterSchema, { + enabled: false, + replacements: {}, + }), +}) + +// ─── ⑦ RespondStage 配置 ─── +// 功能:控制消息发送行为——打字延迟、重试等。 +// 设计依据: +// - typingDelay 模拟人类打字速度,范围 200~1000ms, +// 避免 bot 回复过快显得不自然(参考 AIRI Telegram 的 30s sleep, +// 但大幅缩短——30s 太久,QQ 群聊节奏更快)。 +// - multiMessageDelay = 500ms:多条消息间隔, +// 避免 QQ 客户端的消息合并机制把多条消息合成一条。 +// - retryCount = 2:业务层重试次数,NapLink 自身的 api.retries +// 作为底层兜底(两层重试互补)。 +// - retryDelayMs = 1000:重试间隔 1 秒,给 NapCat 恢复时间。 + +const TypingDelaySchema = v.object({ + /** 最小延迟(毫秒) */ + min: v.optional(v.number(), 200), + /** 最大延迟(毫秒) */ + max: v.optional(v.number(), 1000), +}) + +const RespondConfigSchema = v.object({ + /** 模拟打字延迟范围(实际延迟在 min~max 间随机取值) */ + typingDelay: v.optional(TypingDelaySchema, { min: 200, max: 1000 }), + /** 多条消息间隔(毫秒) */ + multiMessageDelay: v.optional(v.number(), 500), + /** 发送失败重试次数(业务层) */ + retryCount: v.optional(v.number(), 2), + /** 重试间隔(毫秒) */ + retryDelayMs: v.optional(v.number(), 1_000), +}) + +const AgentLoopConfigSchema = v.object({ + enabled: v.optional(v.boolean(), false), + intervalMs: v.optional(v.number(), 60_000), + minUnreadToCheck: v.optional(v.number(), 3), + maxProactivePerHour: v.optional(v.number(), 5), +}) + +// ═══════════════════════════════════════════════════════════════ +// §3 全局日志配置 Schema +// ═══════════════════════════════════════════════════════════════ +// 功能:控制全局日志级别,覆盖所有 LoggerInstance。 +// 设计依据: +// - 与 logger.ts 的 initLoggers(config) 对接: +// loadConfig 返回后,index.ts 调用 initLoggers(config) +// 遍历注册表统一刷新级别。 +// - 默认 'info':生产环境的常用级别, +// 开发时可在 YAML 中切换为 'debug'。 + +const GlobalLoggingSchema = v.object({ + level: v.optional( + v.picklist(['debug', 'info', 'warn', 'error', 'off']), + 'info', + ), +}) + +// ═══════════════════════════════════════════════════════════════ +// §4 顶层 BotConfig Schema +// ═══════════════════════════════════════════════════════════════ +// 功能:组合所有子段,形成完整的配置结构。 +// 设计依据: +// - naplink 是唯一没有顶层默认值的段——connection.url 必须由用户 +// 在 YAML 中显式配置(NapCat 地址因部署环境而异)。 +// - 其余所有段都有完整默认值:用户只需配置 naplink.connection.url +// 和 LLM 凭证(env)就能启动最小可用 bot。 +// - botQQ 可选,未设置时通过 client.getLoginInfo() 自动获取, +// 在 client.ts 的 getBotQQ() 中处理。 + +const AiriSchema = v.object({ + url: v.optional(v.string(), 'ws://localhost:6121/ws'), + token: v.optional(v.string()), +}) + +export const BotConfigSchema = v.object({ + /** NapLink 连接配置(直接透传给 NapLink 构造函数) */ + naplink: NapLinkSchema, + airi: v.optional(AiriSchema, {}), + /** ① FilterStage 配置 */ + filter: v.optional(FilterConfigSchema, { + blacklistUsers: [], + blacklistGroups: [], + whitelistMode: false, + whitelistGroups: [], + whitelistUsers: [], + ignoreSystemUsers: ['2854196310'], + ignoreEmptyMessages: true, + }), + + /** ② WakeStage 配置 */ + wake: v.optional(WakeConfigSchema, { + keywords: [], + keywordMatchMode: 'contains' as const, + randomWakeRate: 0, + alwaysWakeInPrivate: true, + }), + + /** ③ RateLimitStage 配置 */ + rateLimit: v.optional(RateLimitConfigSchema, { + perSession: { max: 10, windowMs: 60_000 }, + perUser: { max: 10, windowMs: 60_000 }, + global: { max: 60, windowMs: 60_000 }, + cooldownMs: 3_000, + onLimited: 'silent' as const, + notifyMessage: '请稍后再试~', + }), + + /** ④ SessionStage 配置 */ + session: v.optional(SessionConfigSchema, { + maxHistoryPerSession: 50, + contextWindow: 20, + timeoutMs: 30 * 60 * 1_000, + isolateByTopic: false, + }), + + /** SQLite 持久化配置 */ + db: v.optional(DbConfigSchema, { + path: 'data/qq-bot.db', + maxHistoryRows: 500, + pruneIntervalMs: 3_600_000, + }), + + /** Embedding 配置 */ + embedding: v.optional(EmbeddingConfigSchema, { + enabled: true, + provider: 'bailian' as const, + apiKey: undefined, + model: 'text-embedding-v4', + dimension: 1024, + }), + + /** 语义检索配置 */ + semanticRetrieval: v.optional(SemanticRetrievalConfigSchema, { + enabled: true, + topK: 5, + }), + + /** 上下文压缩配置 */ + compression: v.optional(CompressionConfigSchema, { + enabled: true, + threshold: 0.82, + strategy: 'llm-summary' as const, + truncateRounds: 2, + keepRecentRounds: 4, + maxContextWindow: 8192, + summaryPrompt: [ + '基于完整对话历史,生成关键要点和进展的简洁摘要:', + '1. 系统性覆盖所有讨论的核心话题及最终结论', + '2. 如果使用了工具,总结工具调用次数和关键发现', + '3. 用用户的语言撰写摘要', + ].join('\n'), + }), + + /** ⑤ ProcessStage 配置 */ + process: v.optional(ProcessConfigSchema, { + replyTimeoutMs: 60_000, + sendMaxRetries: 3, + commands: { prefix: '/', enabled: ['help', 'status', 'new', 'switch', 'history', 'clear'] }, + }), + + /** ⑥ DecorateStage 配置 */ + decorate: v.optional(DecorateConfigSchema, { + maxMessageLength: 4500, + splitStrategy: 'multi-message' as const, + autoReply: true, + contentFilter: { enabled: false, replacements: {} }, + }), + + /** ⑦ RespondStage 配置 */ + respond: v.optional(RespondConfigSchema, { + typingDelay: { min: 200, max: 1000 }, + multiMessageDelay: 500, + retryCount: 2, + retryDelayMs: 1_000, + }), + + agentLoop: v.optional(AgentLoopConfigSchema, { + enabled: false, + intervalMs: 60_000, + minUnreadToCheck: 3, + maxProactivePerHour: 5, + }), + + /** 全局日志级别(覆盖所有 logger 实例) */ + logging: v.optional(GlobalLoggingSchema, { level: 'info' }), + + /** + * Bot QQ 号(可选)。 + * 未设置时通过 client.getLoginInfo() 自动获取(见 client.ts getBotQQ)。 + * 显式设置可跳过一次 API 调用,也可用于测试环境。 + */ + botQQ: v.optional(v.string()), +}) + +// ═══════════════════════════════════════════════════════════════ +// §5 类型导出 +// ═══════════════════════════════════════════════════════════════ +// 功能:从 Valibot schema 推断 TypeScript 类型并导出。 +// 设计依据: +// - 使用 v.InferOutput 而非手写 interface,确保类型与 schema +// 始终同步(Single Source of Truth)。 +// - 各阶段子类型独立导出,供 PipelineStage 子类构造函数使用 +// (如 FilterStage(config: FilterConfig))。 +// - 顶层 BotConfig 供 index.ts、client.ts、PipelineRunner 使用。 + +/** 完整配置类型(顶层) */ +export type BotConfig = v.InferOutput + +/** NapLink 连接配置类型 */ +export type NapLinkConfig = v.InferOutput + +/** ① FilterStage 配置类型 */ +export type FilterConfig = v.InferOutput + +/** ② WakeStage 配置类型 */ +export type WakeConfig = v.InferOutput + +/** ③ RateLimitStage 配置类型 */ +export type RateLimitConfig = v.InferOutput + +/** ④ SessionStage 配置类型 */ +export type SessionConfig = v.InferOutput + +/** ④.1 DB 配置类型 */ +export type DbConfig = v.InferOutput + +/** ④.2 Context Compression 配置类型 */ +export interface CompressionConfig extends v.InferOutput {} + +/** ④.3 Embedding 配置类型 */ +export type EmbeddingConfig = v.InferOutput + +/** ④.4 语义检索配置类型 */ +export type SemanticRetrievalConfig = v.InferOutput + +/** ⑤ ProcessStage 配置类型(含 commands + llm) */ +export type ProcessConfig = v.InferOutput + +/** 内置命令配置类型(来源于 ProcessConfig.commands) */ +export type CommandsConfig = ProcessConfig['commands'] + +/** ⑥ DecorateStage 配置类型 */ +export type DecorateConfig = v.InferOutput + +/** ⑦ RespondStage 配置类型 */ +export type RespondConfig = v.InferOutput + +/** AgentLoop 配置类型 */ +export type AgentLoopConfig = v.InferOutput + +// ═══════════════════════════════════════════════════════════════ +// §6 配置加载函数 +// ═══════════════════════════════════════════════════════════════ + +/** 默认配置文件路径 */ +const DEFAULT_CONFIG_PATH = 'config.yaml' + +/** + * 加载并验证配置文件。 + * + * 功能:从 YAML 文件读取配置,通过 Valibot schema 验证并填充默认值, + * 最后对 LLM 字段做环境变量 fallback。 + * 设计依据: + * - 配置优先级:YAML 文件 > 环境变量 > 内置默认值。 + * - Valibot v.parse() 一步完成验证 + 默认值填充 + 类型推断。 + * - 三种错误场景分别给出清晰提示: + * 1. 文件不存在 → 提示路径和示例文件 + * 2. YAML 语法错误 → yaml 库自带行号信息 + * 3. Schema 验证失败 → 格式化 Valibot issues 为可读文本 + * - 后置业务校验(warn 级别,不阻止启动): + * randomWakeRate 越界。 + * + * @param path - YAML 文件路径(默认 'config.yaml') + * @returns 验证后的 BotConfig 对象(所有字段已填充默认值) + * @throws 文件不存在、YAML 语法错误、Schema 验证失败时抛出 Error + */ +export function loadConfig(path: string = DEFAULT_CONFIG_PATH): BotConfig { + // ─── 步骤 1:读取文件 ─── + // 使用同步读取:loadConfig 在启动阶段调用, + // 此时无并发需求,同步更简洁且易于错误处理。 + let rawYaml: string + try { + rawYaml = fs.readFileSync(path, 'utf-8') + } + catch (err) { + const message = err instanceof Error ? err.message : String(err) + throw new Error( + `Failed to read config file "${path}": ${message}\n` + + 'Hint: Copy config.example.yaml to config.yaml and fill in your settings.', + ) + } + + // ─── 步骤 2:解析 YAML ─── + // yaml 库的 parse() 在语法错误时抛出 YAMLParseError, + // 自带行号和位置信息,直接透传给用户。 + let rawObject: unknown + try { + rawObject = parseYaml(rawYaml) + } + catch (err) { + const message = err instanceof Error ? err.message : String(err) + throw new Error(`Failed to parse YAML in "${path}": ${message}`) + } + + // 空文件或纯注释 → yaml.parse 返回 null/undefined + if (rawObject == null || typeof rawObject !== 'object') { + throw new Error( + `Config file "${path}" is empty or does not contain a valid YAML object.`, + ) + } + + // ─── 步骤 3:Valibot 验证 + 默认值填充 ─── + // v.parse(schema, input): + // - 通过 → 返回类型安全的 BotConfig(所有 v.optional 字段已填充默认值) + // - 失败 → 抛出 ValiError,包含结构化 issues 数组 + let config: BotConfig + try { + config = v.parse(BotConfigSchema, rawObject) + } + catch (err) { + // 格式化 Valibot 错误为可读文本 + // 每个 issue 包含 path(字段路径)和 message(错误描述) + if (err instanceof v.ValiError) { + const issues = err.issues + .map((issue) => { + const fieldPath = issue.path + ?.map((p: v.IssuePathItem) => p.key) + .join('.') + ?? '(root)' + return ` - ${fieldPath}: ${issue.message}` + }) + .join('\n') + throw new Error( + `Config validation failed for "${path}":\n${issues}`, + ) + } + throw err + } + + // ─── 步骤 4:后置业务校验(warn 级别,不阻止启动) ─── + // 设计依据: + // 某些跨字段约束或语义约束无法用 Valibot 单字段 schema 表达。 + // 用 warn(非 throw):允许用户先启动 bot 再逐步完善配置。 + + // randomWakeRate 越界保护:clamp 到 [0, 1] 并 warn。 + // 不在 Valibot schema 中用 v.minValue/v.maxValue, + // 因为我们想 clamp 而非 reject(宽容策略)。 + const wakeConfig = config.wake ?? { + keywords: [], + keywordMatchMode: 'contains' as const, + randomWakeRate: 0, + alwaysWakeInPrivate: true, + } + + if (wakeConfig.randomWakeRate < 0 || wakeConfig.randomWakeRate > 1) { + logger.warn( + `wake.randomWakeRate (${wakeConfig.randomWakeRate}) is outside [0, 1] range, ` + + 'clamping to nearest bound.', + ) + wakeConfig.randomWakeRate = Math.max(0, Math.min(1, wakeConfig.randomWakeRate)) + } + + config.wake = wakeConfig + + // embedding.apiKey 支持 ${ENV_VAR} 占位符注入。 + const embeddingConfig = config.embedding + if (embeddingConfig.apiKey) { + const match = embeddingConfig.apiKey.match(EMBEDDING_ENV_PLACEHOLDER_RE) + if (match) { + const envKey = match[1] + embeddingConfig.apiKey = process.env[envKey] + if (!embeddingConfig.apiKey) + logger.warn(`embedding.apiKey env var is empty: ${envKey}`) + } + } + + if (embeddingConfig.enabled && !embeddingConfig.apiKey) + logger.warn('embedding is enabled but embedding.apiKey is empty, semantic embedding will be disabled at runtime') + + logger.info(`Config loaded from "${path}"`) + return config +} + +/** + * 监听配置文件变更并在成功重载时回调。 + * + * 设计说明: + * - 使用 fs.watch + 防抖,避免编辑器一次保存触发多次 reload。 + * - reload 失败仅记录错误并保留当前运行配置,不中断服务。 + */ +export function watchConfig( + path: string, + onReload: (config: BotConfig) => void, +): () => void { + let debounceTimer: ReturnType | undefined + + const watcher = fs.watch(path, () => { + if (debounceTimer) + clearTimeout(debounceTimer) + + debounceTimer = setTimeout(() => { + try { + const nextConfig = loadConfig(path) + logger.info(`Config reloaded from "${path}"`) + onReload(nextConfig) + } + catch (err) { + logger.error(`Config reload failed for "${path}"`, err as Error) + } + }, 500) + }) + + return () => { + watcher.close() + if (debounceTimer) + clearTimeout(debounceTimer) + } +} diff --git a/services/qq-bot/src/context/compressor.ts b/services/qq-bot/src/context/compressor.ts new file mode 100644 index 0000000000..0327cbd930 --- /dev/null +++ b/services/qq-bot/src/context/compressor.ts @@ -0,0 +1,194 @@ +import type { AiriClient } from '../airi-client.js' +import type { Conversation } from '../db/conversation-repo.js' +import type { OpenAIMessage } from '../types/context.js' + +import { createLogger } from '../utils/logger.js' +import { normalizeContent } from '../utils/normalize-content.js' +import { estimateTokens } from '../utils/token-estimator.js' + +export interface CompressorConfig { + /** Trigger compression at threshold * maxContextWindow. */ + threshold: number + /** Compression strategy. */ + strategy: 'truncate' | 'llm-summary' + /** Rounds dropped for truncate strategy. */ + truncateRounds: number + /** Keep latest N rounds for llm-summary strategy. */ + keepRecentRounds: number + /** Prompt template used for llm-summary strategy. */ + summaryPrompt: string +} + +const SUMMARY_OVERHEAD_PREFIX = '[摘要]' +const SUMMARY_TIMEOUT_MS = 20_000 + +export class ContextCompressor { + private readonly logger = createLogger('context-compressor') + private readonly pendingSummaries = new Map void>() + + constructor( + private readonly config: CompressorConfig, + private readonly airiClient?: AiriClient, + ) { + if (config.strategy === 'llm-summary' && airiClient) + this.registerSummaryListener() + } + + async compress( + conversation: Conversation, + maxContextWindow: number, + ): Promise<{ messages: OpenAIMessage[], tokenUsage: number }> { + const thresholdLimit = Math.max(1, Math.floor(maxContextWindow * this.config.threshold)) + + let messages = this.parseConversationContent(conversation.content) + let tokenUsage = estimateTokens(messages) + + if (tokenUsage < thresholdLimit) + return { messages, tokenUsage } + + if (this.config.strategy === 'truncate') { + messages = this.truncateOldestRounds(messages, this.config.truncateRounds) + } + else { + messages = await this.compressBySummary(messages, conversation.sessionId) + } + + tokenUsage = estimateTokens(messages) + + // Defensive fallback: keep halving oldest history until under threshold. + while (tokenUsage >= thresholdLimit && messages.length > 1) { + const cutIndex = Math.floor(messages.length / 2) + messages = messages.slice(cutIndex) + tokenUsage = estimateTokens(messages) + } + + return { messages, tokenUsage } + } + + private parseConversationContent(content: string | null): OpenAIMessage[] { + if (!content) + return [] + + try { + const parsed = JSON.parse(content) as unknown + if (!Array.isArray(parsed)) + return [] + + const messages: OpenAIMessage[] = [] + for (const item of parsed) { + if (!item || typeof item !== 'object') + continue + + const role = (item as { role?: unknown }).role + const messageContent = (item as { content?: unknown }).content + + if ((role === 'system' || role === 'user' || role === 'assistant') && typeof messageContent === 'string') + messages.push({ role, content: messageContent }) + } + + return messages + } + catch { + this.logger.warn('Failed to parse conversation content in compressor, using empty history') + return [] + } + } + + private truncateOldestRounds(messages: OpenAIMessage[], truncateRounds: number): OpenAIMessage[] { + const dropCount = Math.max(0, truncateRounds) * 2 + if (dropCount <= 0) + return messages + + return messages.slice(Math.min(dropCount, messages.length)) + } + + private async compressBySummary(messages: OpenAIMessage[], sessionId: string): Promise { + const keepCount = Math.max(0, this.config.keepRecentRounds) * 2 + + if (messages.length <= keepCount) + return messages + + const splitIndex = Math.max(0, messages.length - keepCount) + const earlier = messages.slice(0, splitIndex) + const recent = messages.slice(splitIndex) + + const summary = await this.generateSummary(earlier, sessionId) + if (!summary) { + this.logger.warn('Summary generation failed, fallback to truncate strategy') + return this.truncateOldestRounds(messages, this.config.truncateRounds) + } + + return [ + { + role: 'system', + content: `${SUMMARY_OVERHEAD_PREFIX}\n${summary}`, + }, + ...recent, + ] + } + + private registerSummaryListener(): void { + this.airiClient?.onEvent('output:gen-ai:chat:message', (event: any) => { + const summarySessionId = event.data?.['gen-ai:chat']?.input?.data?.overrides?.sessionId + const rawContent: unknown = event.data?.message?.content + + if (!summarySessionId || !summarySessionId.startsWith('ctx-summary:')) + return + + const resolve = this.pendingSummaries.get(summarySessionId) + if (!resolve) + return + + this.pendingSummaries.delete(summarySessionId) + const content = normalizeContent(rawContent) + resolve(content.trim()) + }) + } + + private async generateSummary(messages: OpenAIMessage[], sessionId: string): Promise { + if (!this.airiClient) + return null + + const summarySessionId = `ctx-summary:${sessionId}:${Date.now()}` + const historyText = messages + .map(message => `${message.role}: ${message.content}`) + .join('\n') + + const payload = { + type: 'input:text' as const, + data: { + text: `${this.config.summaryPrompt}\n\n对话历史:\n${historyText}`, + textRaw: `${this.config.summaryPrompt}\n\n对话历史:\n${historyText}`, + overrides: { + sessionId: summarySessionId, + messagePrefix: '(ContextCompressor): ', + }, + } as any, + } + + try { + await this.airiClient.ensureConnected({ timeout: 10_000 }) + const sent = this.airiClient.send(payload) + if (!sent) + return null + return await this.waitForSummary(summarySessionId) + } + catch { + return null + } + } + + private waitForSummary(summarySessionId: string): Promise { + return new Promise((resolve) => { + const timer = setTimeout(() => { + this.pendingSummaries.delete(summarySessionId) + resolve(null) + }, SUMMARY_TIMEOUT_MS) + + this.pendingSummaries.set(summarySessionId, (text) => { + clearTimeout(timer) + resolve(text.length > 0 ? text : null) + }) + }) + } +} diff --git a/services/qq-bot/src/context/embedding-provider.ts b/services/qq-bot/src/context/embedding-provider.ts new file mode 100644 index 0000000000..c21d918759 --- /dev/null +++ b/services/qq-bot/src/context/embedding-provider.ts @@ -0,0 +1,81 @@ +export interface EmbeddingProvider { + embed: (text: string) => Promise + batchEmbed: (texts: string[]) => Promise + readonly dimension: number +} + +interface EmbeddingResponse { + data: Array<{ + embedding: number[] + index?: number + }> +} + +interface BailianEmbeddingProviderOptions { + model?: string + dimension?: number + maxBatchSize?: number + baseUrl?: string +} + +export class BailianEmbeddingProvider implements EmbeddingProvider { + readonly dimension: number + + private readonly baseUrl: string + private readonly model: string + private readonly maxBatchSize: number + + constructor( + private readonly apiKey: string, + options: BailianEmbeddingProviderOptions = {}, + ) { + this.dimension = options.dimension ?? 1024 + this.baseUrl = options.baseUrl ?? 'https://dashscope.aliyuncs.com/compatible-mode/v1' + this.model = options.model ?? 'text-embedding-v4' + // NOTICE: 百炼 embedding 单次请求上限 10 条,这里强制钳制到 1~10。 + this.maxBatchSize = Math.max(1, Math.min(10, options.maxBatchSize ?? 10)) + } + + async embed(text: string): Promise { + const [result] = await this.batchEmbed([text]) + return result + } + + async batchEmbed(texts: string[]): Promise { + if (texts.length === 0) + return [] + + const results: number[][] = [] + + for (let index = 0; index < texts.length; index += this.maxBatchSize) { + const batch = texts.slice(index, index + this.maxBatchSize) + const response = await fetch(`${this.baseUrl}/embeddings`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${this.apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: this.model, + input: batch, + dimensions: this.dimension, + }), + }) + + if (!response.ok) + throw new Error(`Bailian embedding request failed (${response.status}): ${await response.text()}`) + + const json = await response.json() as EmbeddingResponse + if (!Array.isArray(json.data)) + throw new Error('Bailian embedding response missing data array') + + const sorted = [...json.data] + .sort((left, right) => (left.index ?? 0) - (right.index ?? 0)) + .map(item => item.embedding) + + results.push(...sorted) + } + + return results + } +} diff --git a/services/qq-bot/src/context/semantic-retriever.ts b/services/qq-bot/src/context/semantic-retriever.ts new file mode 100644 index 0000000000..2f15d4173f --- /dev/null +++ b/services/qq-bot/src/context/semantic-retriever.ts @@ -0,0 +1,280 @@ +import type { Client, InValue } from '@libsql/client' + +import type { MessageHistoryRow } from '../db/message-history-repo.js' +import type { EmbeddingProvider } from './embedding-provider.js' + +import { createLogger } from '../utils/logger.js' + +const logger = createLogger('semantic-retriever') + +export class SemanticRetriever { + private vectorQueryAvailable: boolean | undefined + + constructor( + private readonly embeddingProvider: EmbeddingProvider, + private readonly db: Client, + ) {} + + /** + * 语义检索:对 query 做 embedding,返回 topK 条最相关的历史消息。 + * 排除 excludeIds(已在时序窗口中的消息)。 + */ + async findRelevant( + sessionId: string, + query: string, + topK: number, + excludeIds: number[] = [], + ): Promise { + const trimmed = query.trim() + if (!trimmed || topK <= 0) + return [] + + const queryEmbedding = await this.embeddingProvider.embed(trimmed) + + // 优先尝试 sqlite-vec 检索;若当前环境不可用则自动回退到 JS 暴力 KNN。 + const vecResult = await this.findRelevantWithVec(sessionId, queryEmbedding, topK, excludeIds) + if (vecResult) + return vecResult + + return await this.findRelevantByScan(sessionId, queryEmbedding, topK, excludeIds) + } + + /** + * 异步为新消息生成 embedding 并存储。 + * 在 PassiveRecordStage 的 DB 写入后触发。 + */ + async embedAndStore(messageId: number, text: string): Promise { + const trimmed = text.trim() + if (!trimmed) + return + + const embedding = await this.embeddingProvider.embed(trimmed) + if (embedding.length !== this.embeddingProvider.dimension) { + logger.warn( + `Embedding dimension mismatch, expected=${this.embeddingProvider.dimension}, actual=${embedding.length}, messageId=${messageId}`, + ) + return + } + + const embeddingJson = JSON.stringify(embedding) + + await this.db.execute({ + sql: `INSERT INTO message_embeddings_cache (message_id, embedding) + VALUES (?, ?) + ON CONFLICT(message_id) + DO UPDATE SET embedding = excluded.embedding`, + args: [messageId, embeddingJson], + }) + + await this.insertIntoVecTable(messageId, embedding) + } + + private async findRelevantWithVec( + sessionId: string, + queryEmbedding: number[], + topK: number, + excludeIds: number[], + ): Promise { + if (this.vectorQueryAvailable === false) + return null + + const candidateK = Math.max(topK * 8, 40) + + try { + const queryVector = this.toVecJson(queryEmbedding) + const result = await this.db.execute({ + sql: `SELECT message_id, distance + FROM message_embeddings + WHERE embedding MATCH ? + AND k = ? + ORDER BY distance ASC + LIMIT ?`, + args: [queryVector, candidateK, candidateK], + }) + + this.vectorQueryAvailable = true + + const rankedIds = result.rows + .map((row) => { + const record = row as Record + return { + id: Number(record.message_id), + distance: Number(record.distance), + } + }) + .filter(item => Number.isFinite(item.id)) + + if (rankedIds.length === 0) + return [] + + const allowedRows = await this.getRowsByIds(sessionId, rankedIds.map(item => item.id), excludeIds) + const rowById = new Map(allowedRows.map(row => [row.id, row])) + + return rankedIds + .map(item => rowById.get(item.id)) + .filter((row): row is MessageHistoryRow => Boolean(row)) + .slice(0, topK) + } + catch (error) { + logger.warn('sqlite-vec query is unavailable, fallback to brute-force semantic retrieval', { + error: error instanceof Error ? error.message : String(error), + }) + this.vectorQueryAvailable = false + return null + } + } + + private async findRelevantByScan( + sessionId: string, + queryEmbedding: number[], + topK: number, + excludeIds: number[], + ): Promise { + const args: InValue[] = [sessionId] + + let sql = `SELECT mh.id, mh.session_id, mh.sender_id, mh.sender_name, mh.content, mh.raw_text, mh.created_at, mec.embedding + FROM message_history mh + INNER JOIN message_embeddings_cache mec ON mec.message_id = mh.id + WHERE mh.session_id = ?` + + if (excludeIds.length > 0) { + const placeholders = excludeIds.map(() => '?').join(', ') + sql += ` AND mh.id NOT IN (${placeholders})` + args.push(...excludeIds) + } + + // 限制候选集大小,避免在极端长会话里全量扫描。 + const candidateLimit = Math.max(topK * 40, 120) + sql += ' ORDER BY mh.created_at DESC LIMIT ?' + args.push(candidateLimit) + + const result = await this.db.execute({ sql, args }) + const scored = result.rows + .map((row) => { + const record = row as Record + const embedding = this.parseEmbedding(record.embedding) + if (!embedding) + return null + + return { + row: this.mapMessageHistoryRow(record), + score: cosineSimilarity(queryEmbedding, embedding), + } + }) + .filter((item): item is { row: MessageHistoryRow, score: number } => Boolean(item)) + .sort((left, right) => right.score - left.score) + .slice(0, topK) + + return scored.map(item => item.row) + } + + private async getRowsByIds(sessionId: string, ids: number[], excludeIds: number[]): Promise { + if (ids.length === 0) + return [] + + const args: InValue[] = [sessionId, ...ids] + const idPlaceholders = ids.map(() => '?').join(', ') + + let sql = `SELECT id, session_id, sender_id, sender_name, content, raw_text, created_at + FROM message_history + WHERE session_id = ? + AND id IN (${idPlaceholders})` + + if (excludeIds.length > 0) { + const excludePlaceholders = excludeIds.map(() => '?').join(', ') + sql += ` AND id NOT IN (${excludePlaceholders})` + args.push(...excludeIds) + } + + const result = await this.db.execute({ sql, args }) + return result.rows.map(row => this.mapMessageHistoryRow(row as Record)) + } + + private async insertIntoVecTable(messageId: number, embedding: number[]): Promise { + if (this.vectorQueryAvailable === false) + return + + try { + const vector = this.toVecJson(embedding) + + await this.db.execute({ + sql: 'DELETE FROM message_embeddings WHERE message_id = ?', + args: [messageId], + }) + + await this.db.execute({ + sql: 'INSERT INTO message_embeddings (message_id, embedding) VALUES (?, ?)', + args: [messageId, vector], + }) + + this.vectorQueryAvailable = true + } + catch (error) { + logger.warn('sqlite-vec table write is unavailable, fallback to cache-only embeddings', { + error: error instanceof Error ? error.message : String(error), + }) + this.vectorQueryAvailable = false + } + } + + private parseEmbedding(value: InValue): number[] | null { + if (typeof value !== 'string') + return null + + try { + const parsed = JSON.parse(value) as unknown + if (!Array.isArray(parsed)) + return null + + const embedding = parsed + .map(item => Number(item)) + .filter(num => Number.isFinite(num)) + + if (embedding.length !== this.embeddingProvider.dimension) + return null + + return embedding + } + catch { + return null + } + } + + private mapMessageHistoryRow(row: Record): MessageHistoryRow { + return { + id: Number(row.id), + sessionId: String(row.session_id), + senderId: String(row.sender_id), + senderName: row.sender_name != null ? String(row.sender_name) : null, + content: String(row.content), + rawText: row.raw_text != null ? String(row.raw_text) : null, + createdAt: Number(row.created_at), + } + } + + private toVecJson(embedding: number[]): string { + return `[${embedding.join(',')}]` + } +} + +function cosineSimilarity(left: number[], right: number[]): number { + if (left.length !== right.length || left.length === 0) + return -1 + + let dot = 0 + let leftNorm = 0 + let rightNorm = 0 + + for (let index = 0; index < left.length; index++) { + const lv = left[index] + const rv = right[index] + dot += lv * rv + leftNorm += lv * lv + rightNorm += rv * rv + } + + if (leftNorm === 0 || rightNorm === 0) + return -1 + + return dot / (Math.sqrt(leftNorm) * Math.sqrt(rightNorm)) +} diff --git a/services/qq-bot/src/db/conversation-repo.ts b/services/qq-bot/src/db/conversation-repo.ts new file mode 100644 index 0000000000..bb70742e5c --- /dev/null +++ b/services/qq-bot/src/db/conversation-repo.ts @@ -0,0 +1,138 @@ +import type { Client, InValue } from '@libsql/client' + +import { randomUUID } from 'node:crypto' + +export interface Conversation { + id: number + conversationId: string + sessionId: string + title: string | null + personaId: string | null + content: string | null + tokenUsage: number + createdAt: number + updatedAt: number +} + +export interface ConversationUpdate { + title?: string + content?: string + tokenUsage?: number + personaId?: string +} + +export class ConversationRepo { + constructor(private readonly db: Client) {} + + async create(sessionId: string, personaId?: string): Promise { + const conversationId = randomUUID() + const now = Date.now() + + await this.db.execute({ + sql: `INSERT INTO conversations (conversation_id, session_id, persona_id, content, created_at, updated_at) + VALUES (?, ?, ?, '[]', ?, ?)`, + args: [conversationId, sessionId, personaId ?? null, now, now], + }) + + await this.db.execute({ + sql: `INSERT OR REPLACE INTO active_conversations (session_id, conversation_id) + VALUES (?, ?)`, + args: [sessionId, conversationId], + }) + + return (await this.getById(conversationId))! + } + + async getCurrent(sessionId: string): Promise { + const result = await this.db.execute({ + sql: `SELECT c.* FROM conversations c + JOIN active_conversations ac ON ac.conversation_id = c.conversation_id + WHERE ac.session_id = ?`, + args: [sessionId], + }) + + if (result.rows.length <= 0) + return null + + return this.mapRow(result.rows[0] as Record) + } + + async switchTo(sessionId: string, conversationId: string): Promise { + await this.db.execute({ + sql: `INSERT OR REPLACE INTO active_conversations (session_id, conversation_id) + VALUES (?, ?)`, + args: [sessionId, conversationId], + }) + } + + async update(conversationId: string, updates: ConversationUpdate): Promise { + const sets: string[] = ['updated_at = ?'] + const params: InValue[] = [Date.now()] + + if (updates.title !== undefined) { + sets.push('title = ?') + params.push(updates.title) + } + if (updates.content !== undefined) { + sets.push('content = ?') + params.push(updates.content) + } + if (updates.tokenUsage !== undefined) { + sets.push('token_usage = ?') + params.push(updates.tokenUsage) + } + if (updates.personaId !== undefined) { + sets.push('persona_id = ?') + params.push(updates.personaId) + } + + params.push(conversationId) + await this.db.execute({ + sql: `UPDATE conversations SET ${sets.join(', ')} WHERE conversation_id = ?`, + args: params, + }) + } + + async delete(conversationId: string): Promise { + await this.db.execute({ + sql: 'DELETE FROM active_conversations WHERE conversation_id = ?', + args: [conversationId], + }) + await this.db.execute({ + sql: 'DELETE FROM conversations WHERE conversation_id = ?', + args: [conversationId], + }) + } + + async list(sessionId: string): Promise { + const result = await this.db.execute({ + sql: 'SELECT * FROM conversations WHERE session_id = ? ORDER BY updated_at DESC', + args: [sessionId], + }) + return result.rows.map(row => this.mapRow(row as Record)) + } + + async getById(conversationId: string): Promise { + const result = await this.db.execute({ + sql: 'SELECT * FROM conversations WHERE conversation_id = ?', + args: [conversationId], + }) + if (result.rows.length <= 0) + return null + return this.mapRow(result.rows[0] as Record) + } + + private mapRow(row: Record): Conversation { + return { + id: Number(row.id), + conversationId: String(row.conversation_id), + sessionId: String(row.session_id), + title: row.title != null ? String(row.title) : null, + personaId: row.persona_id != null ? String(row.persona_id) : null, + content: row.content != null ? String(row.content) : null, + tokenUsage: Number(row.token_usage), + createdAt: Number(row.created_at), + updatedAt: Number(row.updated_at), + } + } +} diff --git a/services/qq-bot/src/db/index.ts b/services/qq-bot/src/db/index.ts new file mode 100644 index 0000000000..95e0f9eb9a --- /dev/null +++ b/services/qq-bot/src/db/index.ts @@ -0,0 +1,71 @@ +import fs from 'node:fs' +import path from 'node:path' + +import { createClient } from '@libsql/client' + +let client: ReturnType + +export async function initDb(dbPath: string = 'data/qq-bot.db'): Promise> { + const directory = path.dirname(dbPath) + if (directory && directory !== '.') + fs.mkdirSync(directory, { recursive: true }) + + client = createClient({ url: `file:${dbPath}` }) + + await client.executeMultiple(` + CREATE TABLE IF NOT EXISTS message_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + sender_id TEXT NOT NULL, + sender_name TEXT, + content TEXT NOT NULL, + raw_text TEXT, + created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000) + ); + CREATE INDEX IF NOT EXISTS idx_mh_session ON message_history(session_id, created_at); + + CREATE TABLE IF NOT EXISTS conversations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + conversation_id TEXT NOT NULL UNIQUE, + session_id TEXT NOT NULL, + title TEXT, + persona_id TEXT, + content TEXT, + token_usage INTEGER DEFAULT 0, + created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000), + updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000) + ); + CREATE INDEX IF NOT EXISTS idx_conv_session ON conversations(session_id, updated_at); + + CREATE TABLE IF NOT EXISTS active_conversations ( + session_id TEXT PRIMARY KEY, + conversation_id TEXT NOT NULL REFERENCES conversations(conversation_id) + ); + + CREATE TABLE IF NOT EXISTS message_embeddings_cache ( + message_id INTEGER PRIMARY KEY, + embedding TEXT NOT NULL, + created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000), + updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000) + ); + `) + + try { + // sqlite-vec 向量表(优先使用)。 + await client.execute(` + CREATE VIRTUAL TABLE IF NOT EXISTS message_embeddings USING vec0( + message_id INTEGER, + embedding float[1024] + ) + `) + } + catch { + // libsql 环境可能不支持 vec0,SemanticRetriever 会自动回退到 cache 表暴力 KNN。 + } + + return client +} + +export function getDb(): ReturnType { + return client +} diff --git a/services/qq-bot/src/db/message-history-repo.ts b/services/qq-bot/src/db/message-history-repo.ts new file mode 100644 index 0000000000..5fa25cb7a8 --- /dev/null +++ b/services/qq-bot/src/db/message-history-repo.ts @@ -0,0 +1,114 @@ +import type { Client, InValue } from '@libsql/client' + +export interface MessageHistoryRow { + id: number + sessionId: string + senderId: string + senderName: string | null + content: string + rawText: string | null + createdAt: number +} + +export class MessageHistoryRepo { + constructor(private readonly db: Client) {} + + async insertAndGetId(record: { + sessionId: string + senderId: string + senderName?: string + content: string + rawText?: string + }): Promise { + const result = await this.db.execute({ + sql: `INSERT INTO message_history (session_id, sender_id, sender_name, content, raw_text) + VALUES (?, ?, ?, ?, ?)`, + args: [ + record.sessionId, + record.senderId, + record.senderName ?? null, + record.content, + record.rawText ?? null, + ], + }) + + return Number(result.lastInsertRowid) + } + + async insert(record: { + sessionId: string + senderId: string + senderName?: string + content: string + rawText?: string + }): Promise { + await this.db.execute({ + sql: `INSERT INTO message_history (session_id, sender_id, sender_name, content, raw_text) + VALUES (?, ?, ?, ?, ?)`, + args: [ + record.sessionId, + record.senderId, + record.senderName ?? null, + record.content, + record.rawText ?? null, + ], + }) + } + + async getRecent(sessionId: string, limit: number): Promise { + const result = await this.db.execute({ + sql: `SELECT id, session_id, sender_id, sender_name, content, raw_text, created_at + FROM message_history + WHERE session_id = ? + ORDER BY created_at DESC, id DESC + LIMIT ?`, + args: [sessionId, limit], + }) + + return result.rows.map(row => this.mapRow(row as Record)).reverse() + } + + async getAfter(sessionId: string, afterId: number, limit: number): Promise { + const result = await this.db.execute({ + sql: `SELECT id, session_id, sender_id, sender_name, content, raw_text, created_at + FROM message_history + WHERE session_id = ? AND id > ? + ORDER BY id ASC + LIMIT ?`, + args: [sessionId, afterId, limit], + }) + + return result.rows.map(row => this.mapRow(row as Record)) + } + + async prune(sessionId: string, keepCount: number): Promise { + const result = await this.db.execute({ + sql: `DELETE FROM message_history + WHERE session_id = ? AND id NOT IN ( + SELECT id FROM message_history + WHERE session_id = ? + ORDER BY created_at DESC, id DESC + LIMIT ? + )`, + args: [sessionId, sessionId, keepCount], + }) + return result.rowsAffected + } + + async listSessionIds(): Promise { + const result = await this.db.execute('SELECT DISTINCT session_id FROM message_history') + return result.rows.map(row => String((row as Record).session_id)) + } + + private mapRow(row: Record): MessageHistoryRow { + return { + id: Number(row.id), + sessionId: String(row.session_id), + senderId: String(row.sender_id), + senderName: row.sender_name != null ? String(row.sender_name) : null, + content: String(row.content), + rawText: row.raw_text != null ? String(row.raw_text) : null, + createdAt: Number(row.created_at), + } + } +} diff --git a/services/qq-bot/src/dispatcher/index.ts b/services/qq-bot/src/dispatcher/index.ts new file mode 100644 index 0000000000..bc69055b68 --- /dev/null +++ b/services/qq-bot/src/dispatcher/index.ts @@ -0,0 +1,199 @@ +// src/dispatcher/index.ts +// ───────────────────────────────────────────────────────────── +// ResponsePayload 派发层:把内部响应模型转换为 NapLink API 调用。 +// ───────────────────────────────────────────────────────────── + +import type { NapLink } from '@naplink/naplink' + +import type { RespondConfig } from '../config.js' +import type { QQMessageEvent } from '../types/event.js' +import type { OutputMessageSegment } from '../types/message.js' +import type { ForwardNode, ResponsePayload } from '../types/response.js' +import type { BotMessageTracker } from '../utils/bot-message-tracker.js' + +import { createLogger } from '../utils/logger.js' + +const logger = createLogger('dispatcher') + +class NonRetryableSendError extends Error {} + +export class ResponseDispatcher { + constructor( + private readonly client: NapLink, + private readonly config: RespondConfig, + private readonly botMessageTracker?: BotMessageTracker, + ) {} + + async send(event: QQMessageEvent, payload: ResponsePayload): Promise { + switch (payload.kind) { + case 'silent': + return + case 'message': { + const messageBatches = this.splitForDispatch(payload.segments) + for (let i = 0; i < messageBatches.length; i++) { + await this.delay(this.randomTypingDelay()) + await this.sendMessage(event, messageBatches[i]!, payload.replyTo) + if (i < messageBatches.length - 1) + await this.delay(this.config.multiMessageDelay) + } + return + } + case 'forward': + await this.delay(this.randomTypingDelay()) + await this.sendForward(event, payload.forward) + return + default: { + const _exhaustive: never = payload + return _exhaustive + } + } + } + + private splitForDispatch(segments: OutputMessageSegment[]): OutputMessageSegment[][] { + if (segments.length <= 1) + return [segments] + + const allText = segments.every(seg => seg.type === 'text') + if (!allText) + return [segments] + + return segments.map(seg => [seg]) + } + + private async sendMessage( + event: QQMessageEvent, + segments: OutputMessageSegment[], + replyTo?: string, + ): Promise { + const finalSegments = replyTo + ? [{ type: 'reply', data: { id: replyTo } } as const, ...segments] + : segments + + for (let attempt = 0; attempt <= this.config.retryCount; attempt++) { + try { + if (event.source.type === 'group' && event.source.groupId) { + const res = await this.client.sendGroupMessage(event.source.groupId, finalSegments) + this.assertOneBotSuccess(res) + this.trackSentMessage(res) + } + else { + const res = await this.client.sendPrivateMessage(event.source.userId, finalSegments) + this.assertOneBotSuccess(res) + this.trackSentMessage(res) + } + return + } + catch (err) { + const isLast = attempt >= this.config.retryCount + if (err instanceof NonRetryableSendError || !this.isRetryableError(err) || isLast) + throw err + logger.warn(`sendMessage failed, retrying (${attempt + 1}/${this.config.retryCount})`, err) + await this.delay(this.config.retryDelayMs) + } + } + } + + private async sendForward(event: QQMessageEvent, nodes: ForwardNode[]): Promise { + if (event.source.type === 'group' && event.source.groupId) { + const naplinkNodes = nodes.map((node) => { + return { + type: 'node', + data: { + name: node.name, + uin: node.uin, + content: node.content, + ...(node.time != null && { time: node.time }), + }, + } + }) + + for (let attempt = 0; attempt <= this.config.retryCount; attempt++) { + try { + const res = await this.client.sendGroupForwardMessage(event.source.groupId, naplinkNodes) + this.assertOneBotSuccess(res) + return + } + catch (err) { + const isLast = attempt >= this.config.retryCount + if (err instanceof NonRetryableSendError || !this.isRetryableError(err) || isLast) + throw err + logger.warn(`sendForward failed, retrying (${attempt + 1}/${this.config.retryCount})`, err) + await this.delay(this.config.retryDelayMs) + } + } + return + } + + // NOTICE: OneBot V11 无私聊合并转发标准动作;私聊回退为逐条消息发送。 + for (let i = 0; i < nodes.length; i++) { + await this.sendMessage(event, nodes[i]!.content) + if (i < nodes.length - 1) + await this.delay(this.config.multiMessageDelay) + } + } + + private assertOneBotSuccess(response: unknown): void { + if (!response || typeof response !== 'object') + return + + const maybeRetcode = (response as { retcode?: unknown }).retcode + if (typeof maybeRetcode === 'number' && maybeRetcode !== 0) { + throw new NonRetryableSendError(`OneBot action failed with retcode=${maybeRetcode}`) + } + } + + private trackSentMessage(response: unknown): void { + if (!this.botMessageTracker || !response || typeof response !== 'object') + return + + const result = response as { + message_id?: unknown + data?: { message_id?: unknown } + } + + const messageId = result.message_id ?? result.data?.message_id + if (typeof messageId === 'string' || typeof messageId === 'number') + this.botMessageTracker.track(messageId) + } + + private isRetryableError(error: unknown): boolean { + const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase() + return [ + 'timeout', + 'timed out', + 'network', + 'socket', + 'econn', + 'enotfound', + 'ehostunreach', + 'connection', + 'reconnect', + ].some(keyword => message.includes(keyword)) + } + + private randomTypingDelay(): number { + const min = Math.min(this.config.typingDelay.min, this.config.typingDelay.max) + const max = Math.max(this.config.typingDelay.min, this.config.typingDelay.max) + return Math.floor(Math.random() * (max - min + 1)) + min + } + + private async delay(ms: number): Promise { + await new Promise(resolve => setTimeout(resolve, ms)) + } +} + +export function createDispatcher( + client: NapLink, + config?: RespondConfig, + botMessageTracker?: BotMessageTracker, +): ResponseDispatcher { + const fallback: RespondConfig = config ?? { + typingDelay: { min: 200, max: 1_000 }, + multiMessageDelay: 500, + retryCount: 2, + retryDelayMs: 1_000, + } + return new ResponseDispatcher(client, fallback, botMessageTracker) +} + +export type { ForwardNode } diff --git a/services/qq-bot/src/index.ts b/services/qq-bot/src/index.ts new file mode 100644 index 0000000000..5d1b156caf --- /dev/null +++ b/services/qq-bot/src/index.ts @@ -0,0 +1,197 @@ +// src/index.ts +// ───────────────────────────────────────────────────────────── +// 入口:初始化所有模块,启动 NapLink 连接 +// ───────────────────────────────────────────────────────────── + +import type { + GroupMessageEvent, + PokeNotice, + PrivateMessageEvent, +} from '@naplink/naplink' + +import process from 'node:process' + +import { AgentLoop } from './agent/loop.js' +import { createAiriClient } from './airi-client.js' +import { createNapLinkClient } from './client.js' +import { loadConfig } from './config.js' +import { BailianEmbeddingProvider } from './context/embedding-provider.js' +import { SemanticRetriever } from './context/semantic-retriever.js' +import { ConversationRepo } from './db/conversation-repo.js' +import { initDb } from './db/index.js' +import { MessageHistoryRepo } from './db/message-history-repo.js' +import { createDispatcher } from './dispatcher/index.js' +import { + normalizeGroupMessage, + normalizePokeEvent, + normalizePrivateMessage, +} from './normalizer/index.js' +import { PipelineRunner } from './pipeline/runner.js' +import { BotMessageTracker } from './utils/bot-message-tracker.js' +import { createLogger, initLoggers } from './utils/logger.js' + +async function main() { + // ─── 加载配置 ────────────────────────────────────────────── + const config = await loadConfig() + + // ─── 初始化日志(阶段二:刷新全部 logger 实例) ──────────── + initLoggers(config) + const logger = createLogger('main') + logger.info('Config loaded, loggers initialized') + + const airiConfig = config.airi ?? { + url: 'ws://localhost:6121/ws', + token: undefined, + } + const dbConfig = config.db ?? { + path: 'data/qq-bot.db', + maxHistoryRows: 500, + pruneIntervalMs: 3_600_000, + } + + // ─── 创建 AIRI 连接 ──────────────────────────────────────── + const airiClient = createAiriClient(airiConfig.url, airiConfig.token) + logger.info(`Connecting to AIRI server: ${airiConfig.url}`) + + // ─── 初始化 NapLink ──────────────────────────────────────── + const client = createNapLinkClient(config) + + // ─── 初始化持久化存储 ────────────────────────────────────── + const db = await initDb(dbConfig.path) + const messageHistoryRepo = new MessageHistoryRepo(db) + const conversationRepo = new ConversationRepo(db) + + const embeddingConfig = config.embedding ?? { + enabled: true, + provider: 'bailian' as const, + apiKey: undefined, + model: 'text-embedding-v4', + dimension: 1024, + } + const semanticConfig = config.semanticRetrieval ?? { + enabled: true, + topK: 5, + } + + let semanticRetriever: SemanticRetriever | undefined + if (embeddingConfig.enabled && semanticConfig.enabled) { + if (embeddingConfig.provider === 'bailian' && embeddingConfig.apiKey) { + semanticRetriever = new SemanticRetriever( + new BailianEmbeddingProvider(embeddingConfig.apiKey, { + model: embeddingConfig.model, + dimension: embeddingConfig.dimension, + }), + db, + ) + } + else { + logger.warn('Semantic retrieval enabled but embedding provider is not fully configured, feature will be disabled') + } + } + + // ─── 创建 Pipeline Runner ────────────────────────────────── + const botMessageTracker = new BotMessageTracker() + const dispatcher = createDispatcher(client, config.respond, botMessageTracker) + const runner = new PipelineRunner( + config, + airiClient, + dispatcher, + messageHistoryRepo, + conversationRepo, + semanticRetriever, + botMessageTracker, + ) + await runner.preheatPassiveRecords(await runner.listKnownSessionIds()) + + let agentLoop: AgentLoop | undefined + if (config.agentLoop?.enabled) { + agentLoop = new AgentLoop( + config.agentLoop, + runner.getPassiveRecordStage(), + airiClient, + dispatcher, + ) + agentLoop.start() + logger.info('AgentLoop started') + } + + let pruneTimer: ReturnType | undefined + if (dbConfig.pruneIntervalMs > 0) { + pruneTimer = setInterval(() => { + runner.pruneHistory(dbConfig.maxHistoryRows) + .then((changes) => { + if (changes > 0) + logger.info(`Pruned ${changes} message_history rows`) + }) + .catch((error) => { + logger.error('Failed to prune message_history rows', error as Error) + }) + }, dbConfig.pruneIntervalMs) + } + + let botQQ = config.botQQ ?? '' + if (botQQ) + runner.setBotQQ(botQQ) + + // 获取 bot 自身 QQ 号,注入给 WakeStage(用于 @bot 检测) + client.once('ready', async () => { + try { + const loginInfo = await client.getLoginInfo() + botQQ = String(loginInfo.user_id) + runner.setBotQQ(botQQ) + logger.info(`Bot QQ: ${botQQ}`) + } + catch (err) { + logger.warn('getLoginInfo() failed, using config.botQQ fallback', err as Error) + } + }) + + // 注册消息事件 → 流水线 + client.on('message.group', (data: GroupMessageEvent) => { + runner.run(normalizeGroupMessage(data, botQQ)).catch( + err => logger.error('Pipeline error (group)', err as Error), + ) + }) + + client.on('message.private', (data: PrivateMessageEvent) => { + runner.run(normalizePrivateMessage(data, botQQ)).catch( + err => logger.error('Pipeline error (private)', err as Error), + ) + }) + + client.on('notice.notify.poke', (data: PokeNotice) => { + const event = normalizePokeEvent(data, botQQ) + + if (!event) + return + + runner.run(event).catch( + err => logger.error('Pipeline error (poke)', err as Error), + ) + }) + + // ─── 启动 NapLink 连接 ───────────────────────────────────── + await client.connect() + logger.info('NapLink connected, bot is running') + + // ─── 优雅退出 ────────────────────────────────────────────── + async function shutdown(signal: string) { + logger.info(`Received ${signal}, shutting down...`) + if (pruneTimer) + clearInterval(pruneTimer) + agentLoop?.stop() + + airiClient.close() + await client.disconnect() + db.close() + process.exit(0) + } + + process.on('SIGINT', () => shutdown('SIGINT')) + process.on('SIGTERM', () => shutdown('SIGTERM')) +} + +main().catch((err) => { + console.error('[main] Fatal error:', err) + process.exit(1) +}) diff --git a/services/qq-bot/src/normalizer/index.ts b/services/qq-bot/src/normalizer/index.ts new file mode 100644 index 0000000000..b868c9da8e --- /dev/null +++ b/services/qq-bot/src/normalizer/index.ts @@ -0,0 +1,133 @@ +// src/normalizer/index.ts +// ───────────────────────────────────────────────────────────── +// NapLink 事件标准化:OneBot 事件 -> QQMessageEvent +// ───────────────────────────────────────────────────────────── + +import type { + GroupMessageEvent, + OneBotMessageSegment, + PokeNotice, + PrivateMessageEvent, +} from '@naplink/naplink' + +import type { QQMessageEvent } from '../types/event.js' +import type { InputMessageSegment } from '../types/message.js' + +import { createDefaultContext } from '../types/context.js' +import { buildSessionId } from '../types/event.js' +import { createLogger } from '../utils/logger.js' + +const logger = createLogger('normalizer') + +function normalizeChain(segments: OneBotMessageSegment[]): InputMessageSegment[] { + const result: InputMessageSegment[] = [] + + for (const seg of segments) { + switch (seg.type) { + case 'text': + result.push({ type: 'text', data: { text: seg.data.text } }) + break + case 'image': + result.push({ type: 'image', data: { file: seg.data.file, url: seg.data.url } }) + break + case 'at': + result.push({ type: 'at', data: { qq: String(seg.data.qq) } }) + break + case 'reply': + result.push({ type: 'reply', data: { id: String(seg.data.id) } }) + break + case 'face': + result.push({ type: 'face', data: { id: String(seg.data.id) } }) + break + case 'file': + result.push({ type: 'file', data: { file: seg.data.file, name: seg.data.name } }) + break + case 'record': + case 'audio': + result.push({ type: 'voice', data: { file: seg.data.file } }) + break + default: + logger.debug(`Unknown segment type skipped: ${seg.type}`) + break + } + } + + return result +} + +export function normalizeGroupMessage(data: GroupMessageEvent, _botQQ: string): QQMessageEvent { + const userId = String(data.sender.user_id ?? data.user_id) + const groupId = String(data.group_id) + const userName = data.sender.card || data.sender.nickname || userId + + return { + id: String(data.message_id), + timestamp: Date.now(), + source: { + platform: 'qq', + type: 'group', + userId, + userName, + groupId, + groupName: undefined, + sessionId: buildSessionId('group', groupId, userId), + }, + raw: data, + chain: normalizeChain(data.message), + text: data.raw_message ?? '', + context: createDefaultContext(), + stopped: false, + } +} + +export function normalizePrivateMessage(data: PrivateMessageEvent, _botQQ: string): QQMessageEvent { + const userId = String(data.sender.user_id ?? data.user_id) + const userName = data.sender.nickname || userId + + return { + id: String(data.message_id), + timestamp: Date.now(), + source: { + platform: 'qq', + type: 'private', + userId, + userName, + sessionId: buildSessionId('private', undefined, userId), + }, + raw: data, + chain: normalizeChain(data.message), + text: data.raw_message ?? '', + context: createDefaultContext(), + stopped: false, + } +} + +export function normalizePokeEvent(data: PokeNotice, botQQ: string): QQMessageEvent | null { + if (String(data.target_id) !== botQQ) + return null + + const userId = String(data.user_id) + const groupId = data.group_id != null ? String(data.group_id) : undefined + const type = groupId ? 'group' as const : 'private' as const + + return { + id: `poke-${Date.now()}-${userId}`, + timestamp: Date.now(), + source: { + platform: 'qq', + type, + userId, + userName: userId, + groupId, + sessionId: buildSessionId(type, groupId, userId), + }, + raw: data, + chain: [{ + type: 'poke', + data: { type: data.sub_type, id: String(data.target_id) }, + }], + text: '', + context: createDefaultContext(), + stopped: false, + } +} diff --git a/services/qq-bot/src/pipeline/conversation.ts b/services/qq-bot/src/pipeline/conversation.ts new file mode 100644 index 0000000000..150667f3d0 --- /dev/null +++ b/services/qq-bot/src/pipeline/conversation.ts @@ -0,0 +1,111 @@ +import type { ContextCompressor } from '../context/compressor.js' +import type { ConversationRepo } from '../db/conversation-repo.js' +import type { OpenAIMessage, StageResult } from '../types/context.js' +import type { QQMessageEvent } from '../types/event.js' + +import { KeyedMutex } from '../utils/async-mutex.js' +import { estimateTokens } from '../utils/token-estimator.js' +import { PipelineStage } from './stage.js' + +export class ConversationStage extends PipelineStage { + readonly name = 'ConversationStage' + private readonly mutex = new KeyedMutex() + + constructor( + private readonly repo: ConversationRepo, + private readonly compressor?: ContextCompressor, + private readonly maxContextWindow: number = 8192, + ) { + super() + this.initLogger() + } + + async execute(event: QQMessageEvent): Promise { + const sessionId = event.source.sessionId + + // 获取 per-session 锁,保证同 session 的 execute→afterProcess 串行 + const release = await this.mutex.acquire(sessionId) + event.context.extensions._conversationRelease = release + + let conversation = await this.repo.getCurrent(sessionId) + if (!conversation) + conversation = await this.repo.create(sessionId) + + let messages = this.parseConversationHistory(conversation.content) + + if (this.compressor) { + const compressed = await this.compressor.compress(conversation, this.maxContextWindow) + messages = compressed.messages + + await this.repo.update(conversation.conversationId, { + content: JSON.stringify(messages), + tokenUsage: compressed.tokenUsage, + }) + } + + else { + await this.repo.update(conversation.conversationId, { + tokenUsage: estimateTokens(messages), + }) + } + + event.context.conversationHistory = messages + event.context.conversationId = conversation.conversationId + + return { action: 'continue' } + } + + async afterProcess(event: QQMessageEvent, userMessage: string, assistantMessage: string): Promise { + const conversationId = event.context.conversationId + if (!conversationId) + return + + try { + // 重新从 DB 读取最新状态,而非使用 event 上的旧快照 + const fresh = await this.repo.getById(conversationId) + const history = fresh ? this.parseConversationHistory(fresh.content) : [] + + history.push({ role: 'user', content: userMessage }) + history.push({ role: 'assistant', content: assistantMessage }) + + await this.repo.update(conversationId, { + content: JSON.stringify(history), + tokenUsage: estimateTokens(history), + }) + } + finally { + // 释放锁,允许下一个同 session 事件进入 + const release = event.context.extensions._conversationRelease as (() => void) | undefined + event.context.extensions._conversationRelease = undefined + release?.() + } + } + + private parseConversationHistory(content: string | null): OpenAIMessage[] { + if (!content) + return [] + + try { + const parsed = JSON.parse(content) as unknown + if (!Array.isArray(parsed)) + return [] + + return parsed.filter((item): item is OpenAIMessage => { + if (!item || typeof item !== 'object') + return false + + const role = (item as { role?: unknown }).role + const messageContent = (item as { content?: unknown }).content + + return ( + (role === 'system' || role === 'user' || role === 'assistant') + && typeof messageContent === 'string' + ) + }) + } + catch { + this.logger.warn('Failed to parse conversation.content, reset to empty history') + return [] + } + } +} diff --git a/services/qq-bot/src/pipeline/decorate.ts b/services/qq-bot/src/pipeline/decorate.ts new file mode 100644 index 0000000000..10bf430c7b --- /dev/null +++ b/services/qq-bot/src/pipeline/decorate.ts @@ -0,0 +1,121 @@ +// src/pipeline/decorate.ts +// ───────────────────────────────────────────────────────────── +// ⑥ DecorateStage:内容替换、长文本拆分、自动 replyTo +// ───────────────────────────────────────────────────────────── + +import type { DecorateConfig } from '../config.js' +import type { StageResult } from '../types/context.js' +import type { QQMessageEvent } from '../types/event.js' +import type { OutputMessageSegment } from '../types/message.js' +import type { MessageResponse } from '../types/response.js' + +import { createMessageResponse, mergeAdjacentText } from '../types/response.js' +import { PipelineStage } from './stage.js' + +export class DecorateStage extends PipelineStage { + readonly name = 'DecorateStage' + + constructor(private readonly config: DecorateConfig) { + super() + this.initLogger() + } + + async execute(event: QQMessageEvent): Promise { + const response = event.context.response + if (!response) + return { action: 'continue' } + + if (response.kind !== 'message') + return { action: 'continue' } + + let segments = response.segments + segments = this.applyContentFilter(segments) + segments = mergeAdjacentText(segments) + segments = this.applySplitStrategy(segments) + + const replyTo = this.config.autoReply + ? (response.replyTo ?? event.id) + : response.replyTo + + const decorated: MessageResponse = createMessageResponse(segments, replyTo) + event.context.response = decorated + return { action: 'continue' } + } + + private applyContentFilter(segments: OutputMessageSegment[]): OutputMessageSegment[] { + if (!this.config.contentFilter.enabled) + return segments + const replacements = this.config.contentFilter.replacements + const entries = Object.entries(replacements) + if (entries.length === 0) + return segments + + return segments.map((segment) => { + if (segment.type !== 'text') + return segment + + let text = segment.data.text + for (const [from, to] of entries) + text = text.split(from).join(to) + + return { type: 'text', data: { text } } + }) + } + + private applySplitStrategy(segments: OutputMessageSegment[]): OutputMessageSegment[] { + if (segments.length === 0) + return segments + + const result: OutputMessageSegment[] = [] + for (const segment of segments) { + if (segment.type !== 'text') { + result.push(segment) + continue + } + + if (segment.data.text.length <= this.config.maxMessageLength) { + result.push(segment) + continue + } + + if (this.config.splitStrategy === 'truncate') { + result.push({ + type: 'text', + data: { text: segment.data.text.slice(0, this.config.maxMessageLength) }, + }) + continue + } + + const chunks = this.splitText(segment.data.text, this.config.maxMessageLength) + for (const chunk of chunks) { + result.push({ + type: 'text', + data: { text: chunk }, + }) + } + } + + return result + } + + private splitText(text: string, maxLength: number): string[] { + const chunks: string[] = [] + let remaining = text + + while (remaining.length > maxLength) { + let splitAt = remaining.lastIndexOf('\n', maxLength) + if (splitAt <= 0) + splitAt = remaining.lastIndexOf(' ', maxLength) + if (splitAt <= 0) + splitAt = maxLength + + chunks.push(remaining.slice(0, splitAt)) + remaining = remaining.slice(splitAt).trimStart() + } + + if (remaining) + chunks.push(remaining) + + return chunks + } +} diff --git a/services/qq-bot/src/pipeline/extensions.ts b/services/qq-bot/src/pipeline/extensions.ts new file mode 100644 index 0000000000..7757159e7d --- /dev/null +++ b/services/qq-bot/src/pipeline/extensions.ts @@ -0,0 +1,73 @@ +// src/pipeline/extensions.ts +// ───────────────────────────────────────────────────────────── +// 流水线扩展数据(PipelineExtensions)集中定义 +// +// 功能:承载流水线各阶段之间需要共享的、不属于 PipelineContext +// 顶层字段的扩展数据。挂载于 PipelineContext.extensions。 +// +// 设计依据: +// - 替代被移除的 ResponsePayload.metadata(P5 YAGNI 决策), +// 阶段间共享数据统一走 extensions,不污染响应载荷。 +// - 强类型,不使用 index signature(Record), +// 每个字段都有明确的类型定义和归属阶段标注。 +// +// ─── 添加字段规则 ────────────────────────────────────────── +// +// 1. 每个字段必须标注: +// - 写入方:哪个阶段负责写入 +// - 读取方:哪些阶段/模块会消费该字段 +// - 字段用途的一句话说明 +// +// 2. 所有字段必须为可选(?:)——流水线可能在任意阶段中止, +// 后续阶段不保证执行,读取方必须处理 undefined。 +// +// 3. 字段命名使用 camelCase,并以写入阶段的缩写作为前缀, +// 避免跨阶段命名冲突: +// wake_ → WakeStage +// rl_ → RateLimitStage +// sess_ → SessionStage +// proc_ → ProcessStage +// dec_ → DecorateStage +// 如果字段由多个阶段共同写入,使用最先写入的阶段前缀。 +// +// 4. 禁止存放可从 PipelineContext 顶层字段派生的数据 +// (如 isWakeUp、sessionHistory 已在顶层,不要重复)。 +// +// 5. 禁止存放 ResponsePayload 相关的数据 +// (响应内容走 context.response,不走 extensions)。 +// +// ───────────────────────────────────────────────────────────── + +/** + * 流水线阶段间共享的扩展数据。 + * + * 当前为空接口 — 各阶段实现时按需驱动添加字段, + * 遵循上方「添加字段规则」。 + */ +export interface PipelineExtensions { + // ─── WakeStage 写入 ─────────────────────────────────────── + + // ─── RateLimitStage 写入 ────────────────────────────────── + + // ─── SessionStage 写入 ──────────────────────────────────── + + // ─── ProcessStage 写入 ──────────────────────────────────── + /** 标记当前会话需要在响应后清理。ProcessStage 写入,Runner 读取 */ + proc_clearSession?: boolean + + // ─── DecorateStage 写入 ─────────────────────────────────── + + // ─── Runner 注入(非阶段写入) ───────────────────────────── + /** Bot QQ 号。Runner 在每次 run() 前注入,WakeStage 读取 */ + _botQQ?: string + /** ConversationStage 会话互斥锁释放函数。ConversationStage 写入,Runner/ConversationStage 释放 */ + _conversationRelease?: () => void +} + +/** + * 创建 extensions 初始值(PipelineContext 初始化时调用)。 + * 所有字段均为可选,初始值为空对象。 + */ +export function createExtensions(): PipelineExtensions { + return {} +} diff --git a/services/qq-bot/src/pipeline/filter.ts b/services/qq-bot/src/pipeline/filter.ts new file mode 100644 index 0000000000..217c742fc7 --- /dev/null +++ b/services/qq-bot/src/pipeline/filter.ts @@ -0,0 +1,62 @@ +// src/pipeline/filter.ts +// ───────────────────────────────────────────────────────────── +// ① FilterStage:黑白名单、系统号、空消息过滤 +// ───────────────────────────────────────────────────────────── + +import type { FilterConfig } from '../config.js' +import type { StageResult } from '../types/context.js' +import type { QQMessageEvent } from '../types/event.js' + +import { PipelineStage } from './stage.js' + +const DEFAULT_SYSTEM_USERS = ['2854196310'] + +export class FilterStage extends PipelineStage { + readonly name = 'FilterStage' + + private readonly systemUsers: Set + private readonly blackUsers: Set + private readonly blackGroups: Set + private readonly whiteUsers: Set + private readonly whiteGroups: Set + + constructor(private readonly config: FilterConfig) { + super() + this.initLogger() + + this.systemUsers = new Set([...DEFAULT_SYSTEM_USERS, ...config.ignoreSystemUsers]) + this.blackUsers = new Set(config.blacklistUsers) + this.blackGroups = new Set(config.blacklistGroups) + this.whiteUsers = new Set(config.whitelistUsers) + this.whiteGroups = new Set(config.whitelistGroups) + } + + async execute(event: QQMessageEvent): Promise { + const { source, text, chain } = event + + if (this.systemUsers.has(source.userId)) + return { action: 'skip' } + + if (this.blackUsers.has(source.userId)) + return { action: 'skip' } + + if (source.groupId && this.blackGroups.has(source.groupId)) + return { action: 'skip' } + + if (this.config.whitelistMode) { + const userAllowed = this.whiteUsers.has(source.userId) + const groupAllowed = source.groupId ? this.whiteGroups.has(source.groupId) : false + if (!userAllowed && !groupAllowed) + return { action: 'skip' } + } + + if (this.config.ignoreEmptyMessages) { + const isEmptyText = text.trim().length === 0 + const isOnlyFace = chain.length > 0 && chain.every(seg => seg.type === 'face') + if ((isEmptyText && chain.length === 0) || isOnlyFace) + return { action: 'skip' } + } + + return { action: 'continue' } + } +} diff --git a/services/qq-bot/src/pipeline/passive-record.ts b/services/qq-bot/src/pipeline/passive-record.ts new file mode 100644 index 0000000000..4b22d9165f --- /dev/null +++ b/services/qq-bot/src/pipeline/passive-record.ts @@ -0,0 +1,157 @@ +// src/pipeline/passive-record.ts +// ───────────────────────────────────────────────────────────── +// PassiveRecordStage:在 WakeStage 之前被动记录所有消息历史 +// ───────────────────────────────────────────────────────────── + +import type { SemanticRetriever } from '../context/semantic-retriever.js' +import type { MessageHistoryRepo, MessageHistoryRow } from '../db/message-history-repo.js' +import type { StageResult } from '../types/context.js' +import type { QQMessageEvent } from '../types/event.js' +import type { InputMessageSegment } from '../types/message.js' + +import { serializeChain } from '../utils/chain-serializer.js' +import { MessageBuffer } from '../utils/message-buffer.js' +import { PipelineStage } from './stage.js' + +export interface PassiveRecord { + senderId: string + senderName: string + chain: InputMessageSegment[] + timestamp: number +} + +interface PassiveSessionEntry { + buffer: MessageBuffer + lastActive: number +} + +export interface PassiveRecordConfig { + maxHistoryPerSession: number + timeoutMs: number +} + +export class PassiveRecordStage extends PipelineStage { + readonly name = 'PassiveRecordStage' + + private readonly buffers = new Map() + + constructor( + private readonly config: PassiveRecordConfig, + private readonly repo: MessageHistoryRepo, + private readonly semanticRetriever?: SemanticRetriever, + ) { + super() + this.initLogger() + } + + async execute(event: QQMessageEvent): Promise { + const key = event.source.sessionId + const now = Date.now() + + const entry = this.getOrCreateEntry(key) + + if (now - entry.lastActive > this.config.timeoutMs) { + this.logger.debug(`Session timeout, clearing passive buffer: ${key}`) + entry.buffer.clear() + } + + entry.lastActive = now + // 记录发送者和消息链,供 ContextInject/ProcessStage 组装可读历史上下文。 + const record: PassiveRecord = { + senderId: event.source.userId, + senderName: event.source.userName, + chain: event.chain, + timestamp: now, + } + entry.buffer.push(record) + + // NOTICE: DB 写入放入 setImmediate,避免阻塞 pipeline 热路径。 + const serialized = JSON.stringify(record.chain) + const rawText = serializeChain(record.chain, record.senderName) + setImmediate(() => { + (async () => { + const insertedId = await this.repo.insertAndGetId({ + sessionId: key, + senderId: record.senderId, + senderName: record.senderName, + content: serialized, + rawText, + }) + + if (this.semanticRetriever && rawText) + await this.semanticRetriever.embedAndStore(insertedId, rawText) + })().catch((err) => { + this.logger.error('[passive-record] DB write failed', err as Error) + }) + }) + + return { action: 'continue' } + } + + async preheat(knownSessionIds: string[]): Promise { + for (const sessionId of knownSessionIds) { + const rows = await this.repo.getRecent(sessionId, this.config.maxHistoryPerSession) + const entry = this.getOrCreateEntry(sessionId) + + for (const row of rows) { + try { + entry.buffer.push({ + senderId: row.senderId, + senderName: row.senderName ?? 'Unknown', + chain: JSON.parse(row.content) as InputMessageSegment[], + timestamp: row.createdAt, + }) + } + catch (error) { + this.logger.warn(`Failed to parse stored message history, id=${row.id}`, { + error: error instanceof Error ? error.message : String(error), + }) + } + } + + entry.lastActive = Date.now() + } + } + + getRecent(sessionId: string, n: number): PassiveRecord[] { + const entry = this.buffers.get(sessionId) + return entry ? entry.buffer.getRecent(n) : [] + } + + listActiveSessionIds(): string[] { + const now = Date.now() + const result: string[] = [] + + for (const [sessionId, entry] of this.buffers.entries()) { + if (now - entry.lastActive <= this.config.timeoutMs) + result.push(sessionId) + } + + return result + } + + async getMessagesAfter(sessionId: string, lastMessageId: number, limit: number): Promise { + return await this.repo.getAfter(sessionId, lastMessageId, limit) + } + + async getLatestMessageId(sessionId: string): Promise { + const rows = await this.repo.getRecent(sessionId, 1) + return rows[0]?.id + } + + clearSession(sessionId: string): void { + this.buffers.delete(sessionId) + } + + private getOrCreateEntry(sessionId: string): PassiveSessionEntry { + let entry = this.buffers.get(sessionId) + if (!entry) { + entry = { + buffer: new MessageBuffer(this.config.maxHistoryPerSession), + lastActive: Date.now(), + } + this.buffers.set(sessionId, entry) + } + return entry + } +} diff --git a/services/qq-bot/src/pipeline/plugins.ts b/services/qq-bot/src/pipeline/plugins.ts new file mode 100644 index 0000000000..64a03bf980 --- /dev/null +++ b/services/qq-bot/src/pipeline/plugins.ts @@ -0,0 +1,18 @@ +import type { QQMessageEvent } from '../types/event.js' +import type { ResponsePayload } from '../types/response.js' + +export interface ProcessPlugin { + name: string + handle: (event: QQMessageEvent) => Promise +} + +const plugins = new Map() + +export function registerProcessPlugin(plugin: ProcessPlugin): () => void { + plugins.set(plugin.name, plugin) + return () => plugins.delete(plugin.name) +} + +export function listProcessPlugins(): ProcessPlugin[] { + return [...plugins.values()] +} diff --git a/services/qq-bot/src/pipeline/process.ts b/services/qq-bot/src/pipeline/process.ts new file mode 100644 index 0000000000..8b701eff67 --- /dev/null +++ b/services/qq-bot/src/pipeline/process.ts @@ -0,0 +1,393 @@ +// src/pipeline/process.ts +// ───────────────────────────────────────────────────────────── +// ⑤ ProcessStage — 核心处理 +// +// 功能:将消息转发给 AIRI server,等待 LLM 响应后写入 context.response。 +// 设计依据: +// - LLM 调用完全委托 AIRI server,本阶段只做协议转换: +// QQMessageEvent → AIRI input:text 事件 → 等待 output:gen-ai:chat:message +// - 用 sessionId 过滤响应,避免并发会话串话。 +// - 持久监听 + pending Map:onEvent 只注册一次,每次 waitForReply +// 仅向 Map 注册 resolve,无竞态、无内存泄漏风险。 +// - sendWithRetry:检查 send() 返回值,连接断开时自动等待重连后重试。 +// - 内置命令(/help /status /clear)在 LLM 调用前前置处理, +// 命中则直接 respond 不消耗 AIRI 资源。 +// ───────────────────────────────────────────────────────────── + +import type { AiriClient } from '../airi-client.js' +import type { ProcessConfig } from '../config.js' +import type { ConversationRepo } from '../db/conversation-repo.js' +import type { StageResult } from '../types/context.js' +import type { QQMessageEvent } from '../types/event.js' + +import { randomUUID } from 'node:crypto' + +import { ContextUpdateStrategy } from '@proj-airi/server-sdk' + +import { createSilentResponse, createTextResponse } from '../types/response.js' +import { serializeChain } from '../utils/chain-serializer.js' +import { normalizeContent } from '../utils/normalize-content.js' +import { PipelineStage } from './stage.js' + +const COMMAND_SPLIT_RE = /\s+/u + +export class ProcessStage extends PipelineStage { + readonly name = 'ProcessStage' + + /** 等待 AIRI 响应的超时时间 (ms) */ + private readonly replyTimeoutMs: number + /** 发送重试次数 */ + private readonly sendMaxRetries: number + + /** correlationId → resolve 函数,持久监听用于分发响应 */ + private readonly pendingReplies = new Map void>() + + constructor( + private readonly config: ProcessConfig, + private readonly airiClient: AiriClient, + private readonly conversationRepo: ConversationRepo, + ) { + super() + this.initLogger() + this.replyTimeoutMs = config.replyTimeoutMs ?? 60_000 + this.sendMaxRetries = config.sendMaxRetries ?? 3 + this.registerOutputListener() + } + + // ─── 持久输出监听(构造时注册一次)──────────────────────────── + + /** + * 注册持久的 output:gen-ai:chat:message 监听器。 + * 收到响应后按 correlationId 分发给对应的 waitForReply Promise。 + */ + private registerOutputListener(): void { + this.airiClient.onEvent('output:gen-ai:chat:message', (event: any) => { + this.logger.debug('[AIRI raw output]', JSON.stringify(event)) + + const correlationId + = event.data?.['gen-ai:chat']?.input?.data?.overrides?.correlationId + ?? event.data?.['gen-ai:chat']?.input?.data?.overrides?.sessionId + const normalizedContent = normalizeContent(event.data?.message?.content) + + if (!correlationId) { + this.logger.warn('output:gen-ai:chat:message missing correlationId/sessionId, dropping') + return + } + + const resolve = this.pendingReplies.get(correlationId) + if (resolve) { + this.pendingReplies.delete(correlationId) + resolve(normalizedContent.trim()) + } + else { + this.logger.warn(`No pending reply for correlationId=${correlationId}, dropping`) + } + }) + } + + // ─── 流水线主逻辑 ───────────────────────────────────────────── + + async execute(event: QQMessageEvent): Promise { + // ─── 内置命令前置处理 ───────────────────────────────────── + const cmdResult = await this.handleCommand(event) + if (cmdResult) + return cmdResult + + // 未唤醒的消息不走 LLM + if (!event.context.isWakeUp) + return { action: 'continue' } + + // ─── 发送给 AIRI server(带重试)────────────────────────── + const { source, text } = event + const qqContext = source.type === 'group' + ? `消息来自QQ群 ${source.groupId}(${source.groupName ?? ''})` + : `消息来自QQ私聊` + + // 将结构化会话历史压平为人类可读的对话行,注入给 AIRI 作为额外上下文。 + // 这里不修改 event.text,仅通过 contextUpdates 追加,保证主输入与历史上下文解耦。 + const history = event.context.sessionHistory ?? [] + const historyLines = history + .map(record => serializeChain(record.chain, record.senderName)) + .filter(Boolean) + + const historyContext = historyLines.length > 0 + ? `以下是该群最近的聊天记录(供理解上下文用):\n${historyLines.join('\n')}` + : undefined + + const conversationHistory = event.context.conversationHistory ?? [] + const conversationLines = conversationHistory + .map(message => `${message.role}: ${message.content}`) + .filter(Boolean) + + const conversationContext = conversationLines.length > 0 + ? `以下是当前会话的历史对话(供理解上下文用):\n${conversationLines.join('\n')}` + : undefined + + const semanticHistory = event.context.semanticHistory ?? [] + const semanticLines = semanticHistory + .map(record => `[${new Date(record.createdAt).toLocaleString()}] ${record.senderName ?? 'Unknown'}: ${record.rawText ?? ''}`) + .filter(Boolean) + + const semanticContext = semanticLines.length > 0 + ? `以下是语义相关的历史消息(可能来自更早的对话):\n${semanticLines.join('\n')}` + : undefined + + const contextUpdates = [ + ...(qqContext + ? [{ + strategy: ContextUpdateStrategy.AppendSelf, + text: qqContext, + content: qqContext, + metadata: { qq: source }, + }] + : []), + ...(historyContext + ? [{ + strategy: ContextUpdateStrategy.AppendSelf, + text: historyContext, + content: historyContext, + }] + : []), + ...(conversationContext + ? [{ + strategy: ContextUpdateStrategy.AppendSelf, + text: conversationContext, + content: conversationContext, + }] + : []), + ...(semanticContext + ? [{ + strategy: ContextUpdateStrategy.AppendSelf, + text: semanticContext, + content: semanticLines.join('\n'), + }] + : []), + ] + + const correlationId = randomUUID() + + const payload = { + type: 'input:text' as const, + data: { + text, + textRaw: text, + overrides: { + messagePrefix: `(来自QQ用户 ${source.userName}): `, + sessionId: source.sessionId, + correlationId, + }, + contextUpdates: contextUpdates.length > 0 ? contextUpdates : undefined, + qq: source, + } as any, + } + + const sent = await this.sendWithRetry(payload) + if (!sent) { + this.logger.warn(`Failed to send to AIRI after ${this.sendMaxRetries} retries, sessionId=${source.sessionId}`) + event.context.response = createSilentResponse() + return { action: 'continue' } + } + + this.logger.debug(`Sent to AIRI: sessionId=${source.sessionId}, text="${text.slice(0, 50)}"`) + + // ─── 等待 AIRI 响应 ─────────────────────────────────────── + const reply = await this.waitForReply(correlationId) + + if (reply === null) { + this.logger.warn(`AIRI reply timeout (${this.replyTimeoutMs}ms): sessionId=${source.sessionId}`) + event.context.response = createSilentResponse() + return { action: 'continue' } + } + + this.logger.debug(`Got AIRI reply: "${reply.slice(0, 50)}"`) + event.context.response = createTextResponse(reply, event.id) + return { action: 'continue' } + } + + // ─── 内置命令 ──────────────────────────────────────────────── + + /** + * 处理内置命令。 + * @returns StageResult(命中时)或 null(未命中时) + */ + private async handleCommand(event: QQMessageEvent): Promise { + const commandConfig = this.config.commands + if (!commandConfig) + return null + + const { prefix, enabled } = commandConfig + const text = event.text.trim() + + if (!text.startsWith(prefix)) + return null + + const [cmd, ...args] = text.slice(prefix.length).split(COMMAND_SPLIT_RE) + const command = cmd?.toLowerCase() ?? '' + + if (!enabled.includes(command)) + return null + + switch (command) { + case 'help': + return { + action: 'respond', + payload: createTextResponse( + `可用命令:${enabled.map(c => prefix + c).join(' · ')}`, + event.id, + ), + } + case 'status': + return { + action: 'respond', + payload: createTextResponse( + `QQ OneBot 适配器运行中 ✓\nAIRI server 已连接`, + event.id, + ), + } + case 'new': { + const conversation = await this.conversationRepo.create(event.source.sessionId) + event.context.conversationId = conversation.conversationId + event.context.conversationHistory = [] + return { + action: 'respond', + payload: createTextResponse(`已创建新对话:${conversation.conversationId}`, event.id), + } + } + case 'switch': { + const targetConversationId = args[0] + if (!targetConversationId) { + return { + action: 'respond', + payload: createTextResponse('用法:/switch {conversationId}', event.id), + } + } + + const target = await this.conversationRepo.getById(targetConversationId) + if (!target || target.sessionId !== event.source.sessionId) { + return { + action: 'respond', + payload: createTextResponse('未找到该会话 ID,或该会话不属于当前 session。', event.id), + } + } + + await this.conversationRepo.switchTo(event.source.sessionId, targetConversationId) + event.context.conversationId = targetConversationId + try { + event.context.conversationHistory = target.content ? JSON.parse(target.content) : [] + } + catch { + event.context.conversationHistory = [] + } + + return { + action: 'respond', + payload: createTextResponse(`已切换到对话:${targetConversationId}`, event.id), + } + } + case 'history': { + const conversations = await this.conversationRepo.list(event.source.sessionId) + if (conversations.length === 0) { + return { + action: 'respond', + payload: createTextResponse('当前没有历史对话。', event.id), + } + } + + const currentConversationId = event.context.conversationId + const listText = conversations + .map((conversation, index) => { + const marker = conversation.conversationId === currentConversationId ? '*' : ' ' + const title = conversation.title ?? '(untitled)' + return `${index + 1}. [${marker}] ${conversation.conversationId} ${title}` + }) + .join('\n') + + return { + action: 'respond', + payload: createTextResponse(`会话列表(* 表示当前会话):\n${listText}`, event.id), + } + } + case 'clear': + if (event.context.conversationId) + await this.conversationRepo.delete(event.context.conversationId) + + { + const conversation = await this.conversationRepo.create(event.source.sessionId) + event.context.conversationId = conversation.conversationId + event.context.conversationHistory = [] + } + + event.context.extensions.proc_clearSession = true + return { + action: 'respond', + payload: createTextResponse('已清空当前会话并创建新对话 ✓', event.id), + } + default: + return null + } + } + + // ─── 带重试的发送 ────────────────────────────────────────── + + /** + * 等待 AIRI 连接就绪后发送,失败则重试。 + * + * 设计依据: + * - SDK 的 send() 在连接断开时静默返回 false(不报错), + * 必须检查返回值。 + * - 发送前调用 ensureConnected() 等待重连完成。 + * - 重试间隔 2 秒,给 SDK 足够的重连时间。 + */ + private async sendWithRetry(payload: any): Promise { + for (let attempt = 1; attempt <= this.sendMaxRetries; attempt++) { + try { + await this.airiClient.ensureConnected({ timeout: 10_000 }) + } + catch { + this.logger.warn(`AIRI not connected, attempt ${attempt}/${this.sendMaxRetries}`) + if (attempt < this.sendMaxRetries) + await new Promise(r => setTimeout(r, 2000)) + continue + } + + const ok = this.airiClient.send(payload) + if (ok) { + if (attempt > 1) + this.logger.info(`Send succeeded on attempt ${attempt}`) + return true + } + + this.logger.warn(`send() returned false, attempt ${attempt}/${this.sendMaxRetries}`) + if (attempt < this.sendMaxRetries) + await new Promise(r => setTimeout(r, 2000)) + } + + return false + } + + // ─── 等待 AIRI 响应 ────────────────────────────────────────── + + /** + * 向 pendingReplies Map 注册一个 resolve,等待持久监听器分发结果。 + * + * 设计依据: + * - 不自己 add/remove onEvent,避免竞态。 + * - timeout 后从 Map 删除自身,避免内存泄漏。 + * + * @param correlationId - 当前请求关联 ID + * @returns 响应文本(有内容时)或 null(超时时) + */ + private waitForReply(correlationId: string): Promise { + return new Promise((resolve) => { + const timer = setTimeout(() => { + this.pendingReplies.delete(correlationId) + resolve(null) + }, this.replyTimeoutMs) + + this.pendingReplies.set(correlationId, (text: string) => { + clearTimeout(timer) + resolve(text.length > 0 ? text : null) + }) + }) + } +} diff --git a/services/qq-bot/src/pipeline/rate-limit.ts b/services/qq-bot/src/pipeline/rate-limit.ts new file mode 100644 index 0000000000..c673d8e02b --- /dev/null +++ b/services/qq-bot/src/pipeline/rate-limit.ts @@ -0,0 +1,85 @@ +// src/pipeline/rate-limit.ts +// ───────────────────────────────────────────────────────────── +// ③ RateLimitStage:会话/用户/全局滑动窗口 + 冷却控制 +// ───────────────────────────────────────────────────────────── + +import type { RateLimitConfig } from '../config.js' +import type { StageResult } from '../types/context.js' +import type { QQMessageEvent } from '../types/event.js' + +import { createTextResponse } from '../types/response.js' +import { CooldownTracker, SlidingWindowRateLimiter } from '../utils/rate-limiter.js' +import { PipelineStage } from './stage.js' + +export class RateLimitStage extends PipelineStage { + readonly name = 'RateLimitStage' + + private readonly perSessionLimiter: SlidingWindowRateLimiter + private readonly perUserLimiter: SlidingWindowRateLimiter + private readonly globalLimiter: SlidingWindowRateLimiter + private readonly cooldownTracker: CooldownTracker + private cleanupTimer?: ReturnType + + constructor(private readonly config: RateLimitConfig) { + super() + this.initLogger() + + this.perSessionLimiter = new SlidingWindowRateLimiter(config.perSession.max, config.perSession.windowMs) + this.perUserLimiter = new SlidingWindowRateLimiter(config.perUser.max, config.perUser.windowMs) + this.globalLimiter = new SlidingWindowRateLimiter(config.global.max, config.global.windowMs) + this.cooldownTracker = new CooldownTracker(config.cooldownMs) + + this.cleanupTimer = setInterval(() => { + this.perSessionLimiter.cleanup() + this.perUserLimiter.cleanup() + this.globalLimiter.cleanup() + }, 5 * 60 * 1000) + + this.cleanupTimer.unref() + } + + async execute(event: QQMessageEvent): Promise { + const sessionKey = event.source.sessionId + const userKey = event.source.userId + const globalKey = 'global' + + if (this.cooldownTracker.isOnCooldown(sessionKey)) + return this.onLimited(event, 'cooldown') + + if (!this.perSessionLimiter.check(sessionKey)) + return this.onLimited(event, 'perSession') + + if (!this.perUserLimiter.check(userKey)) + return this.onLimited(event, 'perUser') + + if (!this.globalLimiter.check(globalKey)) + return this.onLimited(event, 'global') + + this.perSessionLimiter.record(sessionKey) + this.perUserLimiter.record(userKey) + this.globalLimiter.record(globalKey) + + event.context.rateLimitPassed = true + return { action: 'continue' } + } + + startCooldown(sessionKey: string): void { + this.cooldownTracker.startCooldown(sessionKey) + } + + dispose(): void { + if (this.cleanupTimer) + clearInterval(this.cleanupTimer) + } + + private onLimited(event: QQMessageEvent, dimension: string): StageResult { + this.logger.debug(`Rate limited by ${dimension} for event ${event.id}`) + if (this.config.onLimited === 'notify' && this.config.notifyMessage) { + return { + action: 'respond', + payload: createTextResponse(this.config.notifyMessage, event.id), + } + } + return { action: 'skip' } + } +} diff --git a/services/qq-bot/src/pipeline/respond.ts b/services/qq-bot/src/pipeline/respond.ts new file mode 100644 index 0000000000..7db0a469b2 --- /dev/null +++ b/services/qq-bot/src/pipeline/respond.ts @@ -0,0 +1,26 @@ +// src/pipeline/respond.ts +// ───────────────────────────────────────────────────────────── +// ⑦ RespondStage:触发 Dispatcher 发送,并回调限流冷却 +// ───────────────────────────────────────────────────────────── + +import type { StageResult } from '../types/context.js' +import type { QQMessageEvent } from '../types/event.js' + +import { PipelineStage } from './stage.js' + +export class RespondStage extends PipelineStage { + readonly name = 'RespondStage' + + constructor() { + super() + this.initLogger() + } + + async execute(event: QQMessageEvent): Promise { + const payload = event.context.response + if (!payload) + return { action: 'skip' } + + return { action: 'respond', payload } + } +} diff --git a/services/qq-bot/src/pipeline/runner.ts b/services/qq-bot/src/pipeline/runner.ts new file mode 100644 index 0000000000..56e51bf0c9 --- /dev/null +++ b/services/qq-bot/src/pipeline/runner.ts @@ -0,0 +1,234 @@ +// src/pipeline/runner.ts +// ───────────────────────────────────────────────────────────── +// 流水线执行引擎:Filter -> PassiveRecord -> Wake -> RateLimit -> ContextInject -> Conversation -> Process -> Decorate -> Respond +// ───────────────────────────────────────────────────────────── + +import type { AiriClient } from '../airi-client.js' +import type { BotConfig } from '../config.js' +import type { SemanticRetriever } from '../context/semantic-retriever.js' +import type { ConversationRepo } from '../db/conversation-repo.js' +import type { MessageHistoryRepo } from '../db/message-history-repo.js' +import type { ResponseDispatcher } from '../dispatcher/index.js' +import type { QQMessageEvent } from '../types/event.js' +import type { PipelineStage } from './stage.js' + +import { ContextCompressor } from '../context/compressor.js' +import { BotMessageTracker } from '../utils/bot-message-tracker.js' +import { createLogger } from '../utils/logger.js' +import { ConversationStage } from './conversation.js' +import { DecorateStage } from './decorate.js' +import { FilterStage } from './filter.js' +import { PassiveRecordStage } from './passive-record.js' +import { ProcessStage } from './process.js' +import { RateLimitStage } from './rate-limit.js' +import { RespondStage } from './respond.js' +import { ContextInjectStage } from './session.js' +import { WakeStage } from './wake.js' + +const logger = createLogger('runner') + +export class PipelineRunner { + private botQQ = '' + private readonly stages: PipelineStage[] + + private readonly passiveRecordStage: PassiveRecordStage + private readonly rateLimitStage: RateLimitStage + private readonly contextInjectStage: ContextInjectStage + private readonly conversationStage: ConversationStage + private readonly processStage: ProcessStage + + constructor( + config: BotConfig, + airiClient: AiriClient, + private readonly dispatcher: ResponseDispatcher, + private readonly messageHistoryRepo: MessageHistoryRepo, + conversationRepo: ConversationRepo, + semanticRetriever?: SemanticRetriever, + botMessageTracker?: BotMessageTracker, + ) { + const tracker = botMessageTracker ?? new BotMessageTracker() + + const sessionConfig = config.session ?? { + maxHistoryPerSession: 50, + contextWindow: 20, + timeoutMs: 30 * 60 * 1_000, + isolateByTopic: false, + } + const processConfig = config.process ?? { + replyTimeoutMs: 60_000, + sendMaxRetries: 3, + commands: { prefix: '/', enabled: ['help', 'status', 'new', 'switch', 'history', 'clear'] }, + } + const semanticRetrievalConfig = config.semanticRetrieval ?? { + enabled: true, + topK: 5, + } + const compressionConfig = config.compression ?? { + enabled: true, + threshold: 0.82, + strategy: 'llm-summary' as const, + truncateRounds: 2, + keepRecentRounds: 4, + maxContextWindow: 8192, + summaryPrompt: '基于完整对话历史,生成关键要点和进展的简洁摘要。', + } + + const compressor = compressionConfig.enabled + ? new ContextCompressor( + { + threshold: compressionConfig.threshold, + strategy: compressionConfig.strategy, + truncateRounds: compressionConfig.truncateRounds, + keepRecentRounds: compressionConfig.keepRecentRounds, + summaryPrompt: compressionConfig.summaryPrompt, + }, + airiClient, + ) + : undefined + + this.passiveRecordStage = new PassiveRecordStage({ + maxHistoryPerSession: sessionConfig.maxHistoryPerSession, + timeoutMs: sessionConfig.timeoutMs, + }, messageHistoryRepo, semanticRetrievalConfig.enabled ? semanticRetriever : undefined) + this.rateLimitStage = new RateLimitStage(config.rateLimit ?? { + perSession: { max: 10, windowMs: 60_000 }, + perUser: { max: 10, windowMs: 60_000 }, + global: { max: 60, windowMs: 60_000 }, + cooldownMs: 3_000, + onLimited: 'silent', + notifyMessage: '请稍后再试~', + }) + this.contextInjectStage = new ContextInjectStage( + sessionConfig, + this.passiveRecordStage, + messageHistoryRepo, + semanticRetrievalConfig.topK, + semanticRetrievalConfig.enabled ? semanticRetriever : undefined, + ) + this.conversationStage = new ConversationStage( + conversationRepo, + compressor, + compressionConfig.maxContextWindow, + ) + this.processStage = new ProcessStage(processConfig, airiClient, conversationRepo) + + this.stages = [ + new FilterStage(config.filter ?? { + blacklistUsers: [], + blacklistGroups: [], + whitelistMode: false, + whitelistGroups: [], + whitelistUsers: [], + ignoreSystemUsers: ['2854196310'], + ignoreEmptyMessages: true, + }), + this.passiveRecordStage, + new WakeStage(config.wake ?? { + keywords: [], + keywordMatchMode: 'contains', + randomWakeRate: 0, + alwaysWakeInPrivate: true, + }, tracker), + this.rateLimitStage, + this.contextInjectStage, + this.conversationStage, + this.processStage, + new DecorateStage(config.decorate ?? { + maxMessageLength: 4500, + splitStrategy: 'multi-message', + autoReply: true, + contentFilter: { enabled: false, replacements: {} }, + }), + new RespondStage(), + ] + } + + setBotQQ(botQQ: string): void { + this.botQQ = botQQ + } + + async run(event: QQMessageEvent): Promise { + event.context.extensions._botQQ = this.botQQ + + try { + for (const stage of this.stages) { + try { + const result = await stage.run(event) + + if (result.action === 'skip') + return + + if (result.action === 'respond') { + if (event.context.extensions.proc_clearSession) + this.clearSession(event.source.sessionId) + + await this.dispatcher.send(event, result.payload) + if (event.context.rateLimitPassed) + this.rateLimitStage.startCooldown(event.source.sessionId) + return + } + + if (stage === this.processStage && event.context.response?.kind === 'message') { + const assistantMessage = event.context.response.segments + .filter(segment => segment.type === 'text') + .map(segment => segment.data.text) + .join('') + .trim() + + if (assistantMessage.length > 0) + await this.conversationStage.afterProcess(event, event.text, assistantMessage) + } + + if (event.stopped) + return + } + catch (err) { + logger.error(`Stage failed: ${stage.name} (event=${event.id})`, err as Error) + return + } + } + + if (event.context.response) { + if (event.context.extensions.proc_clearSession) + this.clearSession(event.source.sessionId) + + await this.dispatcher.send(event, event.context.response) + if (event.context.rateLimitPassed) + this.rateLimitStage.startCooldown(event.source.sessionId) + } + else { + logger.debug(`No response generated for event ${event.id}`) + } + } + finally { + const release = event.context.extensions._conversationRelease as (() => void) | undefined + event.context.extensions._conversationRelease = undefined + release?.() + } + } + + clearSession(sessionId: string): void { + this.passiveRecordStage.clearSession(sessionId) + } + + async preheatPassiveRecords(sessionIds: string[]): Promise { + await this.passiveRecordStage.preheat(sessionIds) + } + + async listKnownSessionIds(): Promise { + return await this.messageHistoryRepo.listSessionIds() + } + + async pruneHistory(maxHistoryRows: number): Promise { + let totalChanges = 0 + const sessionIds = await this.messageHistoryRepo.listSessionIds() + for (const sessionId of sessionIds) + totalChanges += await this.messageHistoryRepo.prune(sessionId, maxHistoryRows) + + return totalChanges + } + + getPassiveRecordStage(): PassiveRecordStage { + return this.passiveRecordStage + } +} diff --git a/services/qq-bot/src/pipeline/session.ts b/services/qq-bot/src/pipeline/session.ts new file mode 100644 index 0000000000..af89199b94 --- /dev/null +++ b/services/qq-bot/src/pipeline/session.ts @@ -0,0 +1,58 @@ +// src/pipeline/session.ts +// ───────────────────────────────────────────────────────────── +// ⑤ ContextInjectStage:注入会话历史上下文 +// ───────────────────────────────────────────────────────────── + +import type { SessionConfig } from '../config.js' +import type { SemanticRetriever } from '../context/semantic-retriever.js' +import type { MessageHistoryRepo } from '../db/message-history-repo.js' +import type { StageResult } from '../types/context.js' +import type { QQMessageEvent } from '../types/event.js' +import type { PassiveRecordStage } from './passive-record.js' + +import { PipelineStage } from './stage.js' + +export class ContextInjectStage extends PipelineStage { + readonly name = 'ContextInjectStage' + + constructor( + private readonly config: SessionConfig, + private readonly passiveRecord: PassiveRecordStage, + private readonly messageHistoryRepo: MessageHistoryRepo, + private readonly semanticTopK: number, + private readonly semanticRetriever?: SemanticRetriever, + ) { + super() + this.initLogger() + } + + async execute(event: QQMessageEvent): Promise { + // 将被动记录阶段缓存的结构化历史注入上下文,供 ProcessStage 序列化后喂给 LLM。 + event.context.sessionHistory = this.passiveRecord.getRecent( + event.source.sessionId, + this.config.contextWindow, + ) + + if (this.semanticRetriever && event.context.isWakeUp) { + const dbRecent = await this.messageHistoryRepo.getRecent( + event.source.sessionId, + this.config.contextWindow, + ) + + const relevant = await this.semanticRetriever.findRelevant( + event.source.sessionId, + event.text, + this.semanticTopK, + dbRecent.map(row => row.id), + ) + + event.context.semanticHistory = relevant + } + + return { action: 'continue' } + } + + clearSession(sessionId: string): void { + this.passiveRecord.clearSession(sessionId) + } +} diff --git a/services/qq-bot/src/pipeline/stage.ts b/services/qq-bot/src/pipeline/stage.ts new file mode 100644 index 0000000000..6bdbb7ee4a --- /dev/null +++ b/services/qq-bot/src/pipeline/stage.ts @@ -0,0 +1,54 @@ +// ⚠️ 决策 ①:PipelineContext, WakeReason, StageResult 已迁移到 types/context.ts +// 打破 event.ts ↔ stage.ts 循环依赖 +import type { StageResult } from '../types/context.js' +import type { QQMessageEvent } from '../types/event.js' +import type { LoggerInstance } from '../utils/logger.js' + +import { performance } from 'node:perf_hooks' + +import { createLogger } from '../utils/logger.js' + +// 阶段抽象基类 +// 每个 Stage 单独定义自己的 config 接口(如 FilterConfig、WakeConfig), +// 由 Valibot schema 各段推断导出。基类不加泛型约束。 +export abstract class PipelineStage { + abstract readonly name: string // 阶段名(用于日志) + protected logger!: LoggerInstance // 由 initLogger() 延迟初始化 + + // ─── 构造函数 ─── + // 基类: constructor(protected config: unknown) + // 子类: constructor(config: FilterConfig) { super(config); this.initLogger() } + + // ─── 抽象方法(子类必须实现) ─── + abstract execute(event: QQMessageEvent): Promise + + // ─── 模板方法(Runner 调用此方法而非直接调 execute()) ─── + + /** + * 串联计时 + 错误捕获,Runner 调此方法而非直接调 execute()。 + * 自动输出 debug 级别耗时日志:[DEBUG] [FilterStage] execute took 2.3ms + */ + async run(event: QQMessageEvent): Promise { + if (!this.logger) + this.initLogger() + const start = performance.now() + try { + const result = await this.execute(event) + const durationMs = (performance.now() - start).toFixed(1) + this.logger.debug(`execute took ${durationMs}ms`, { action: result.action, eventId: event.id }) + return result + } + catch (err) { + const durationMs = (performance.now() - start).toFixed(1) + this.logger.error(`execute failed after ${durationMs}ms`, err as Error) + throw err // 由 Runner 统一 catch + } + } + + // ─── 受保护方法 ─── + + /** 延迟初始化 logger(首次 run 时自动调用,或子类构造函数中手动调) */ + protected initLogger(): void { + this.logger = createLogger(this.name) + } +} diff --git a/services/qq-bot/src/pipeline/wake.ts b/services/qq-bot/src/pipeline/wake.ts new file mode 100644 index 0000000000..f0534ab77e --- /dev/null +++ b/services/qq-bot/src/pipeline/wake.ts @@ -0,0 +1,97 @@ +// src/pipeline/wake.ts +// ───────────────────────────────────────────────────────────── +// ② WakeStage:唤醒判定(private > @bot > reply > keyword > random) +// ───────────────────────────────────────────────────────────── + +import type { WakeConfig } from '../config.js' +import type { StageResult, WakeReason } from '../types/context.js' +import type { QQMessageEvent } from '../types/event.js' +import type { ReplySegment } from '../types/message.js' +import type { BotMessageTracker } from '../utils/bot-message-tracker.js' + +import { findAtTarget, hasSegmentType, removeAtSegments } from '../types/message.js' +import { PipelineStage } from './stage.js' + +export class WakeStage extends PipelineStage { + readonly name = 'WakeStage' + private readonly regexKeywords: RegExp[] + + constructor( + private readonly config: WakeConfig, + private readonly botMessageTracker?: BotMessageTracker, + ) { + super() + this.initLogger() + this.regexKeywords = config.keywordMatchMode === 'regex' + ? config.keywords.map((keyword) => { + try { + return new RegExp(keyword, 'i') + } + catch { + this.logger.warn(`Invalid wake regex keyword skipped: ${keyword}`) + return /^$/ + } + }) + : [] + } + + async execute(event: QQMessageEvent): Promise { + const botQQ = event.context.extensions._botQQ ?? '' + const { source, text } = event + + let wakeReason: WakeReason | undefined + + if (source.type === 'private' && this.config.alwaysWakeInPrivate) + wakeReason = 'private' + + if (!wakeReason && botQQ && findAtTarget(event.chain, botQQ)) { + wakeReason = 'at' + event.chain = removeAtSegments(event.chain, botQQ) + } + + if (!wakeReason) { + const replySegment = event.chain.find( + (seg): seg is ReplySegment => seg.type === 'reply', + ) + if (replySegment && this.botMessageTracker?.isBotMessage(replySegment.data.id)) + wakeReason = 'reply' + } + + if (!wakeReason && this.matchKeyword(text)) + wakeReason = 'keyword' + + if (!wakeReason && hasSegmentType(event.chain, 'poke')) + wakeReason = 'poke' + + if (!wakeReason && source.type === 'group' && this.config.randomWakeRate > 0) { + if (Math.random() < this.config.randomWakeRate) + wakeReason = 'random' + } + + if (!wakeReason) + return { action: 'skip' } + + event.context.isWakeUp = true + event.context.wakeReason = wakeReason + return { action: 'continue' } + } + + private matchKeyword(text: string): boolean { + if (!text || this.config.keywords.length === 0) + return false + + const normalized = text.toLowerCase() + switch (this.config.keywordMatchMode) { + case 'prefix': + return this.config.keywords.some(keyword => normalized.startsWith(keyword.toLowerCase())) + case 'contains': + return this.config.keywords.some(keyword => normalized.includes(keyword.toLowerCase())) + case 'regex': + return this.regexKeywords.some(regex => regex.test(text)) + default: { + const _never: never = this.config.keywordMatchMode + return _never + } + } + } +} diff --git a/services/qq-bot/src/types/context.ts b/services/qq-bot/src/types/context.ts new file mode 100644 index 0000000000..139c00edf6 --- /dev/null +++ b/services/qq-bot/src/types/context.ts @@ -0,0 +1,142 @@ +// src/types/context.ts +// ───────────────────────────────────────────────────────────── +// 流水线上下文(PipelineContext)& 相关类型 +// +// 功能:定义流水线各阶段共享的上下文结构,以及阶段返回值、唤醒原因。 +// +// 设计依据(决策 ① — 打破循环依赖): +// 原依赖链:event.ts → PipelineContext → stage.ts → QQMessageEvent → event.ts 🔄 +// 解决:将 PipelineContext、WakeReason、StageResult 抽离到 types/context.ts, +// event.ts 和 stage.ts 都单向依赖 context.ts,环路消除。 +// +// 设计依据(决策 ② — createDefaultContext 替代 createEvent): +// NapLink 各事件类型无统一 NapLinkEventData,createEvent(data, type) 无法类型化。 +// 改为各 Normalizer 直接构造 QQMessageEvent,共享初始化由 createDefaultContext() 承担。 +// +// 依赖: +// types/message.ts → InputMessageSegment +// types/response.ts → ResponsePayload +// pipeline/extensions.ts → PipelineExtensions, createExtensions +// ───────────────────────────────────────────────────────────── + +import type { MessageHistoryRow } from '../db/message-history-repo.js' +import type { PipelineExtensions } from '../pipeline/extensions.js' +import type { PassiveRecord } from '../pipeline/passive-record.js' +import type { ResponsePayload } from './response.js' + +import { createExtensions } from '../pipeline/extensions.js' + +// ─── 唤醒原因 ──────────────────────────────────────────────── + +/** + * 唤醒原因字面量联合。 + * + * 功能:标识触发 bot 响应的具体条件,WakeStage 写入 context.wakeReason。 + * 设计依据: + * - 与 WakeStage 唤醒优先级一一对应(设计文档 §②): + * private(最高)> at > reply > keyword > random(最低) + * - 后续阶段可据此调整行为(如 ProcessStage 对 'random' + * 降低响应置信度或切换 prompt 策略)。 + */ +export type WakeReason = 'private' | 'at' | 'reply' | 'keyword' | 'poke' | 'random' + +// ─── 阶段返回值 ───────────────────────────────────────────── + +/** + * 流水线阶段返回值(Discriminated Union)。 + * + * 功能:PipelineStage.execute() 返回类型,Runner 据此控制流水线走向。 + * 设计依据: + * - 'continue': 继续下一阶段(最常见,如 FilterStage 放行) + * - 'skip': 跳过后续,不产生响应(如过滤噪声消息) + * - 'respond': 提前终止并立即发送响应(如命令直接回复) + * - Runner 用 switch(result.action) 穷举,新增 action 编译器强制覆盖。 + */ +export type StageResult + = | { action: 'continue' } + | { action: 'skip' } + | { action: 'respond', payload: ResponsePayload } + +export type OpenAIMessageRole = 'system' | 'user' | 'assistant' + +export interface OpenAIMessage { + role: OpenAIMessageRole + content: string +} + +// ─── 流水线上下文 ──────────────────────────────────────────── + +/** + * 流水线上下文 — 各阶段的共享读写数据区(黑板模式)。 + * + * 设计依据: + * 各阶段按职责写入,后续阶段按需读取,阶段间通过 context 解耦: + * WakeStage → isWakeUp, wakeReason + * RateLimitStage → rateLimitPassed + * SessionStage → sessionHistory + * ProcessStage → response + * DecorateStage → response(修饰) + * response 三态语义: + * undefined = 尚无阶段产生响应 + * kind:'silent' = 有意静默 + * kind:'message'/'forward' = 正常响应 + * extensions 承载顶层字段之外的阶段间共享数据 + * (替代被移除的 ResponsePayload.metadata,YAGNI 决策)。 + */ +export interface PipelineContext { + /** 是否触发了唤醒条件(WakeStage 写入) */ + isWakeUp: boolean + /** 唤醒原因(WakeStage 写入,仅 isWakeUp=true 时有值) */ + wakeReason?: WakeReason + /** 是否通过频率限制(RateLimitStage 写入) */ + rateLimitPassed: boolean + /** + * 当前会话最近 N 条消息历史(SessionStage 写入)。 + * 每个元素包含发送者信息 + 原始消息链,便于下游序列化为对话上下文。 + * ProcessStage 读取作为 LLM 上下文窗口。 + */ + sessionHistory: PassiveRecord[] + /** 当前活跃对话的 OpenAI 风格历史,由 ConversationStage 注入。 */ + conversationHistory?: OpenAIMessage[] + /** Phase 3b:语义检索返回的相关历史消息。 */ + semanticHistory?: MessageHistoryRow[] + /** 当前活跃对话 ID,由 ConversationStage 注入。 */ + conversationId?: string + /** + * 流水线响应载荷(ProcessStage / DecorateStage 写入)。 + * undefined = 尚无响应; kind:'silent' = 有意静默。 + * RespondStage / Dispatcher 消费。 + */ + response?: ResponsePayload + /** 扩展数据区,见 pipeline/extensions.ts */ + extensions: PipelineExtensions +} + +// ─── 工厂函数 ──────────────────────────────────────────────── + +/** + * 创建 PipelineContext 默认初始值。 + * + * 功能:Normalizer 构造 QQMessageEvent 时调用。 + * 设计依据(决策 ②): + * - 替代原 createEvent() — NapLink 各事件无统一输入类型, + * 改由各 Normalizer 直接构造事件,共享初始化走本函数。 + * - 集中定义默认值,避免各 Normalizer 各自内联导致不一致。 + * - 初始值:isWakeUp=false, rateLimitPassed=false, + * sessionHistory=[], response=undefined, extensions={} + * + * @returns 安全默认值的 PipelineContext + */ +export function createDefaultContext(): PipelineContext { + return { + isWakeUp: false, + wakeReason: undefined, + rateLimitPassed: false, + sessionHistory: [], + conversationHistory: undefined, + semanticHistory: undefined, + conversationId: undefined, + response: undefined, + extensions: createExtensions(), + } +} diff --git a/services/qq-bot/src/types/event.ts b/services/qq-bot/src/types/event.ts new file mode 100644 index 0000000000..c9e314356e --- /dev/null +++ b/services/qq-bot/src/types/event.ts @@ -0,0 +1,155 @@ +// src/types/event.ts +// ───────────────────────────────────────────────────────────── +// 统一事件模型(QQMessageEvent)类型定义 & 工具函数 +// +// 功能:定义从 NapLink 事件标准化后的统一消息事件结构, +// 作为流水线所有阶段的输入载体。 +// 设计依据: +// - 适配器内部使用统一事件模型,屏蔽 NapLink SDK 的层级化 +// 事件类型差异(GroupMessageEventData / PrivateMessageEventData 等)。 +// - source 独立接口,来源元数据与消息内容解耦。 +// - raw: unknown(决策 ②)— 各事件 data 类型各异,无法用 +// 单一具体类型覆盖,需要时 as 断言到具体类型。 +// - chain 使用自定义 InputMessageSegment[](决策 #5), +// 获得 discriminated union 的类型安全。 +// - context 引用 PipelineContext(types/context.ts,决策 ①), +// 打破 event.ts ↔ pipeline/stage.ts 循环依赖。 +// - 移除 createEvent()(决策 ②)— NapLink 无统一 NapLinkEventData +// 类型,改由各 Normalizer 函数直接构造 QQMessageEvent, +// 共享初始化通过 createDefaultContext() 实现。 +// ───────────────────────────────────────────────────────────── + +import type { PipelineContext } from './context.js' +import type { InputMessageSegment } from './message.js' + +// ─── 来源类型 ──────────────────────────────────────────────── + +/** + * 消息来源类型。 + * - 'private': 私聊(C2C) + * - 'group': 群聊 + * - 'guild': QQ 频道(Phase 5 预留) + * + * 与 OneBot V11 message_type 对齐,额外预留 guild。 + * 使用字面量联合(非 enum),与项目风格一致。 + */ +export type EventSourceType = 'private' | 'group' | 'guild' + +// ─── 来源信息 ──────────────────────────────────────────────── + +/** + * 消息来源元数据。 + * + * 设计依据: + * - platform: 'qq' 字面量,预留跨平台扩展。 + * - sessionId 格式 "qq:{type}:{groupId|userId}", + * 与 AstrBot unified_msg_origin 对齐,供 SessionStage / + * RateLimitStage 统一做会话隔离和限流。 + * - groupId/groupName 可选——私聊不携带,避免空字符串。 + * - userName 由 Normalizer 决定优先级:群名片(card) > 昵称(nickname)。 + */ +export interface EventSource { + /** 平台标识,固定 'qq'。预留跨平台扩展。 */ + platform: 'qq' + /** 消息来源类型 */ + type: EventSourceType + /** 发送者 QQ 号(字符串,避免大数精度丢失) */ + userId: string + /** 发送者显示名(群名片 > 昵称) */ + userName: string + /** 群号(仅群聊时存在) */ + groupId?: string + /** + * 群名称(仅群聊时存在)。 + * NapLink GroupMessageEventData 可能不含 group_name, + * Normalizer 尝试提取,缺失时置 undefined(非关键路径)。 + */ + groupName?: string + /** + * 统一会话 ID。格式 "qq:{type}:{groupId|userId}" + * 由 buildSessionId() 集中构造,确保格式一致性。 + */ + sessionId: string +} + +// ─── 统一事件模型 ──────────────────────────────────────────── + +/** + * QQ 消息事件 — 流水线的统一输入载体。 + * + * 设计依据: + * - Normalizer 构造完整实例,各阶段只读 source/chain/text, + * 可写 context 和 stopped(职责分离)。 + * - id = String(OneBot message_id),与 ReplySegment.data.id 格式一致。 + * - text 取 NapLink raw_message(非 chain 拼接),更准确。 + * - raw 保留 NapLink 原始 data(unknown),用于回退访问扩展字段。 + * - context 由 createDefaultContext() 初始化(黑板模式)。 + * - stopped 是事件级中止标志,与 StageResult 互补: + * Runner 依次检查 StageResult → event.stopped。 + */ +export interface QQMessageEvent { + /** 消息唯一 ID(= String(OneBot message_id)) */ + id: string + /** 收到时间戳(Unix 毫秒,Normalizer 取 Date.now()) */ + timestamp: number + /** 消息来源信息 */ + source: EventSource + /** + * NapLink 原始事件 data(保留用于回退)。 + * unknown 类型:不同事件 data 结构各异,需要时 as 断言: + * const raw = event.raw as GroupMessageEventData + */ + raw: unknown + /** + * 标准化消息链(输入侧,含 ReplySegment)。 + * Normalizer 从 NapLink data.message 逐段映射而来。 + * WakeStage 可能修改(如 removeAtSegments 去除 @bot 段)。 + */ + chain: InputMessageSegment[] + /** + * 纯文本(= NapLink data.raw_message)。 + * WakeStage 关键词匹配、FilterStage 空消息判断的快速路径。 + */ + text: string + /** + * 流水线上下文 — 各阶段共享读写区域。 + * 由 createDefaultContext() 初始化,定义在 types/context.ts(决策 ①)。 + */ + context: PipelineContext + /** + * 流水线中止标志(初始 false)。 + * 任意阶段可设 true,Runner 每阶段后检查。 + * 与 StageResult 互补:StageResult 是显式返回值, + * stopped 是紧急中止标志。 + */ + stopped: boolean +} + +// ─── 工具函数 ──────────────────────────────────────────────── + +/** + * 构造统一会话 ID。 + * + * 功能:格式化 sessionId 字符串 "qq:{type}:{id}"。 + * 设计依据: + * - 格式与 AstrBot unified_msg_origin 对齐。 + * - 群聊用 groupId(同群共享上下文),私聊用 userId(独立上下文)。 + * - 独立函数而非 Normalizer 内联——sessionId 格式是项目级约定, + * 集中定义避免 normalizeGroupMessage / normalizePrivateMessage + * 各自拼接导致不一致。 + * + * @param type - 消息来源类型 + * @param groupId - 群号(群聊/频道时) + * @param userId - 用户 QQ 号(私聊 fallback) + * @returns 格式化的 sessionId,如 "qq:group:123456" + */ +export function buildSessionId( + type: EventSourceType, + groupId: string | undefined, + userId: string, +): string { + // 群聊/频道以 groupId 为会话标识,私聊以 userId 为会话标识。 + // groupId 缺失时 fallback 到 userId(防御性,正常群聊不会缺失)。 + const id = type === 'private' ? userId : groupId ?? userId + return `qq:${type}:${id}` +} diff --git a/services/qq-bot/src/types/index.ts b/services/qq-bot/src/types/index.ts new file mode 100644 index 0000000000..90bdddcb65 --- /dev/null +++ b/services/qq-bot/src/types/index.ts @@ -0,0 +1,4 @@ +export * from './context.js' +export * from './event.js' +export * from './message.js' +export * from './response.js' diff --git a/services/qq-bot/src/types/message.ts b/services/qq-bot/src/types/message.ts new file mode 100644 index 0000000000..482cfab2a3 --- /dev/null +++ b/services/qq-bot/src/types/message.ts @@ -0,0 +1,232 @@ +// src/types/message.ts +// ───────────────────────────────────────────────────────────── +// 消息段(MessageSegment)类型定义 & 工具函数 +// +// 功能:定义 QQ Bot 内部流转的消息原子单元,并提供基础操作工具集。 +// 设计依据: +// - OneBot V11 协议的消息段模型为 { type, data },本文件在此基础上 +// 使用 TypeScript discriminated union 做类型收窄,使 Normalizer、 +// DecorateStage 等模块可通过 switch(seg.type) 获得完整的类型安全。 +// - NapLink SDK 的消息段是宽泛的 { type: string, data: Record }, +// 我们在内部流转(chain、ResponsePayload)中使用自定义的强类型定义, +// Normalizer 是唯一的 NapLink → 内部类型 转换点。 +// - 当前覆盖 9 种 QQ 常用消息段类型,后续可按需扩展。 +// ───────────────────────────────────────────────────────────── + +/** + * 所有支持的消息段类型字面量联合。 + * 用于泛型约束、运行时类型判断等场景。 + */ +export type MessageSegmentType + = | 'text' + | 'image' + | 'at' + | 'reply' + | 'face' + | 'file' + | 'voice' + | 'forward' + | 'poke' + +// ─── 具体消息段接口 ─────────────────────────────────────────── +// 每个接口以 type 字面量作为判别字段(discriminant), +// data 中只保留该类型必需/可选的字段,保持最小化。 + +/** 纯文本消息段 */ +export interface TextSegment { + type: 'text' + data: { text: string } +} + +/** + * 图片消息段 + * - file: 图片文件标识(可以是本地路径、URL 或 Base64,取决于 OneBot 实现端) + * - url: 图片的可访问 URL(收到消息时由实现端填充,发送时可选) + */ +export interface ImageSegment { + type: 'image' + data: { file: string, url?: string } +} + +/** + * @提及消息段 + * - qq: 被 @ 的用户 QQ 号;特殊值 'all' 表示 @全体成员 + */ +export interface AtSegment { + type: 'at' + data: { qq: string } +} + +/** + * 引用回复消息段 + * - id: 被引用的原消息 ID(OneBot message_id) + */ +export interface ReplySegment { + type: 'reply' + data: { id: string } +} + +/** + * QQ 表情消息段 + * - id: QQ 表情的数字 ID(对应 QQ 内置表情编号) + */ +export interface FaceSegment { + type: 'face' + data: { id: string } +} + +/** + * 文件消息段 + * - file: 文件标识 + * - name: 可选的文件显示名称 + */ +export interface FileSegment { + type: 'file' + data: { file: string, name?: string } +} + +/** 语音消息段 */ +export interface VoiceSegment { + type: 'voice' + data: { file: string } +} + +/** + * 合并转发消息段 + * - id: 转发消息的 resId(通过此 ID 可获取完整转发内容) + */ +export interface ForwardSegment { + type: 'forward' + data: { id: string } +} + +/** + * 戳一戳消息段 + * - type: 戳一戳的子类型(如 "poke"、"ShowLove" 等,由 QQ 定义) + * - id: 戳一戳的子类型 ID + * 注意:data.type 与外层的 type: 'poke' 不冲突,前者描述戳一戳的具体动作, + * 后者是 discriminated union 的判别字段。 + */ +export interface PokeSegment { + type: 'poke' + data: { type: string, id: string } +} + +// ─── 联合类型 ──────────────────────────────────────────────── + +/** + * 消息段联合类型(Discriminated Union)。 + * 在内部流水线中作为消息链 (MessageSegment[]) 的元素类型使用。 + * + * 使用方式: + * switch (seg.type) { + * case 'text': seg.data.text // TS 自动窄化为 TextSegment + * case 'image': seg.data.file // TS 自动窄化为 ImageSegment + * ... + * } + */ +export type MessageSegment + = | TextSegment + | ImageSegment + | AtSegment + | ReplySegment + | FaceSegment + | FileSegment + | VoiceSegment + | ForwardSegment + | PokeSegment + +// ─── 输入/输出侧联合类型(P2 新增) ───────────────────────── + +/** 输入侧消息段(含 ReplySegment),用于 event.chain、sessionHistory */ +export type InputMessageSegment = MessageSegment + +/** 输出侧消息段(不含 ReplySegment),用于 ResponsePayload.segments */ +export type OutputMessageSegment = Exclude + +// ─── 工具函数 ──────────────────────────────────────────────── +// 以下函数是消息链的基础操作工具集,供 Normalizer、WakeStage、 +// DecorateStage 等模块使用。 +// +// 设计原则: +// - 全部为纯函数,不修改传入的 chain(.filter()/.map() 天然返回新数组) +// - 空数组输入返回合理默认值(空字符串 / false / 空数组) +// - 签名与设计文档一致,便于各模块按文档约定直接调用 + +/** + * 拼接消息链中所有 TextSegment 的纯文本。 + * + * 功能:从混合类型的消息链中提取文本内容,返回拼接后的字符串。 + * 设计依据: + * - Normalizer 优先使用 NapLink 的 raw_message 作为 event.text, + * 本函数作为备用方案,用于需要从修改后的消息链重新提取文本的场景 + * (如 DecorateStage 对消息链做变换后重新拼接)。 + * - 不插入分隔符,因为 QQ 消息链中相邻 TextSegment 的文本 + * 本身已包含完整的空格和标点。 + * + * @param chain - 消息段数组 + * @returns 拼接后的纯文本字符串;空链返回 '' + */ +export function extractText(chain: MessageSegment[]): string { + return chain + .filter((seg): seg is TextSegment => seg.type === 'text') + .map(seg => seg.data.text) + .join('') +} + +/** + * 判断消息链是否包含指定类型的消息段。 + * + * 功能:通用的消息段类型检测,各流水线阶段均可使用。 + * 设计依据: + * - FilterStage 用于判断是否为纯表情消息(仅含 'face' 段) + * - WakeStage 用于判断是否包含 'reply' 段(回复 bot 唤醒条件) + * - 其他阶段按需使用,避免重复编写 .some() 逻辑 + * + * @param chain - 消息段数组 + * @param type - 要检测的消息段类型 + * @returns 存在该类型返回 true;空链返回 false + */ +export function hasSegmentType(chain: MessageSegment[], type: MessageSegmentType): boolean { + return chain.some(seg => seg.type === type) +} + +/** + * 判断消息链是否 @了指定的 bot。 + * + * 功能:检测消息链中是否存在指向 bot 的 AtSegment。 + * 设计依据: + * - WakeStage 的 @bot 唤醒条件依赖此函数。 + * - @全体成员(qq: 'all')不视为 @bot: + * 条件 seg.data.qq === botQQ 天然排除 'all', + * 因为合法 QQ 号不可能等于字符串 'all'。 + * - 不考虑 @bot 出现多次的情况——只要存在一个即返回 true。 + * + * @param chain - 消息段数组 + * @param botQQ - bot 的 QQ 号字符串 + * @returns 消息链中存在 @bot 返回 true;否则(含空链)返回 false + */ +export function findAtTarget(chain: InputMessageSegment[], botQQ: string): boolean { + return chain.some(seg => seg.type === 'at' && seg.data.qq === botQQ) +} + +/** + * 从消息链中移除所有 @bot 的消息段,返回新数组。 + * + * 功能:在 @bot 唤醒后,清理消息链中的 @bot 段, + * 使后续阶段(ProcessStage)只处理实际内容。 + * 设计依据: + * - WakeStage 判定 @bot 唤醒后调用此函数,将清理后的 chain + * 写回 event.chain,避免 LLM 收到无意义的 @段。 + * - 仅移除 @bot 的段,保留 @其他用户 和 @全体成员('all'), + * 因为这些信息对 LLM 理解群聊语境可能有价值。 + * - .filter() 天然返回新数组,不会修改原始 chain(纯函数保证)。 + * - 使用德摩根定律展开保留条件(ESLint de-morgan/no-negated-conjunction)。 + * + * @param chain - 消息段数组 + * @param botQQ - bot 的 QQ 号字符串 + * @returns 移除 @bot 段后的新消息段数组;空链返回 [] + */ +export function removeAtSegments(chain: InputMessageSegment[], botQQ: string): InputMessageSegment[] { + return chain.filter(seg => seg.type !== 'at' || seg.data.qq !== botQQ) +} diff --git a/services/qq-bot/src/types/response.ts b/services/qq-bot/src/types/response.ts new file mode 100644 index 0000000000..19fa7ccc6b --- /dev/null +++ b/services/qq-bot/src/types/response.ts @@ -0,0 +1,334 @@ +// src/types/response.ts +// ───────────────────────────────────────────────────────────── +// 响应载荷(ResponsePayload)类型定义 & 工厂函数 +// +// 功能:定义流水线输出的三种响应形态(消息、合并转发、静默), +// 并提供类型安全的构造入口,确保各分支字段在编译期互斥。 +// 设计依据: +// - 使用 kind 判别字段 + TypeScript discriminated union, +// switch(payload.kind) 自动窄化,编译期杜绝 segments/forward 混用。 +// - 输出侧消息段使用 OutputMessageSegment(不含 ReplySegment), +// ReplySegment 仅由 Dispatcher 在发送前根据 replyTo 注入, +// 保证引用回复的单一声明式入口。 +// - 工厂函数返回窄类型(MessageResponse / ForwardResponse / SilentResponse), +// 调用方无法构造非法组合;所有空值边界 fail-fast(throw Error)。 +// - 移除无消费方的 metadata 字段(YAGNI),阶段间共享数据 +// 由 PipelineContext.extensions(强类型)承担。 +// 接口: +// - 类型:ResponsePayload, MessageResponse, ForwardResponse, +// SilentResponse, ForwardNode +// - 工厂:createMessageResponse, createTextResponse, +// createForwardResponse, createForwardNode, createSilentResponse +// - 工具:mergeAdjacentText +// ───────────────────────────────────────────────────────────── + +import type { OutputMessageSegment } from './message.js' + +import { createLogger } from '../utils/logger.js' + +// ─── 惰性 Logger ───────────────────────────────────────────── +// 避免模块加载时 config 尚未就绪导致 createLogger 拿到默认配置。 +// 首次调用 getLogger() 时才实例化,此后复用同一实例。 + +let _logger: ReturnType | undefined +function getLogger() { + return (_logger ??= createLogger('response')) +} + +// ─── ResponsePayload(Discriminated Union) ─────────────────── +// 流水线输出的三种响应形态,以 kind 字面量作为判别字段。 +// +// 使用方式: +// switch (payload.kind) { +// case 'message': payload.segments // TS 窄化为 MessageResponse +// case 'forward': payload.forward // TS 窄化为 ForwardResponse +// case 'silent': /* no-op */ // TS 窄化为 SilentResponse +// } + +/** + * 响应载荷联合类型。 + * ProcessStage / 命令处理器通过工厂函数构造,Dispatcher 按 kind 分发。 + */ +export type ResponsePayload + = | MessageResponse + | ForwardResponse + | SilentResponse + +/** + * 消息响应 — 发送一条或多条消息段组成的消息。 + * - segments: 输出侧消息段数组(不含 ReplySegment) + * - replyTo: 可选的引用回复目标消息 ID,Dispatcher 统一转换为 ReplySegment + */ +export interface MessageResponse { + kind: 'message' + segments: OutputMessageSegment[] + replyTo?: string +} + +/** + * 合并转发响应 — 发送一组合并转发节点。 + * - forward: 转发节点数组,每个节点包含伪造的发送者信息和消息内容 + */ +export interface ForwardResponse { + kind: 'forward' + forward: ForwardNode[] +} + +/** + * 静默响应 — 流水线正常走完但不发送消息。 + * 适用场景:插件已处理、webhook 已触发等无需用户感知的后台操作。 + * 注意:需要用户反馈的命令(如 /clear)应用 createTextResponse,非 silent。 + */ +export interface SilentResponse { + kind: 'silent' +} + +// ─── ForwardNode ───────────────────────────────────────────── + +// TODO: nested forward — OneBot V11 支持 forward 节点的 content 中 +// 再嵌套 ForwardSegment,当前设计暂不支持,需在 ForwardNode 类型 +// 和 Dispatcher.sendForward() 中处理递归构造。 + +/** + * 合并转发节点。 + * - name: 显示的发送者昵称 + * - uin: 显示的发送者 QQ 号 + * - content: 节点消息内容(OutputMessageSegment[],不含 ReplySegment) + * - time: 可选的伪造发送时间(Unix 秒级),不填则由实现端填充当前时间 + */ +export interface ForwardNode { + name: string + uin: string + content: OutputMessageSegment[] + time?: number +} + +// ─── 工厂函数 ──────────────────────────────────────────────── +// 所有 ResponsePayload 的构造都应通过工厂函数,确保: +// - 空值边界 fail-fast(throw Error),不静默吞错 +// - ForwardNode.time 毫秒防呆(自动转换 + warn) +// - ForwardNode.content 统一为 OutputMessageSegment[] +// - replyTo 空字符串视为无效,不写入 +// 工厂函数返回窄类型,调用方无法构造非法组合。 + +/** + * 构造静默响应。 + * + * 功能:创建一个 kind='silent' 的响应载荷,表示不发送任何消息。 + * 设计依据: + * - 消除 null/undefined 的语义模糊:undefined = 没有阶段产生响应, + * silent = 有意选择不回复。Dispatcher 遇到 silent 直接 return。 + * - 适用场景:插件已处理、webhook 已触发等后台操作。 + * + * @returns SilentResponse(窄类型) + */ +export function createSilentResponse(): SilentResponse { + return { kind: 'silent' } +} + +/** + * 通用消息响应工厂。 + * + * 功能:构造 kind='message' 的响应载荷,所有消息分支的构造都应通过此函数。 + * 设计依据: + * - 空 segments 数组 = 上游 bug(LLM 返空 / 逻辑遗漏),fail-fast throw。 + * - replyTo 空字符串视为无效消息 ID,不写入(避免 Dispatcher 注入 + * { type: 'reply', data: { id: '' } },NapCat 行为未定义)。 + * - 返回窄类型 MessageResponse,赋值给 ResponsePayload 变量时自动 widen。 + * + * @param segments - 输出侧消息段数组(不含 ReplySegment) + * @param replyTo - 可选的引用回复目标消息 ID + * @returns MessageResponse(窄类型) + * @throws segments 为空数组时 throw Error + */ +export function createMessageResponse( + segments: OutputMessageSegment[], + replyTo?: string, +): MessageResponse { + if (segments.length === 0) + throw new Error('createMessageResponse: segments must not be empty') + + return { + kind: 'message', + segments, + ...(replyTo && { replyTo }), + } +} + +/** + * 纯文本响应快捷方式。 + * + * 功能:将纯文本字符串包装为 MessageResponse,内部委托 createMessageResponse。 + * 设计依据: + * - ProcessStage / 命令处理器最常见的输出是纯文本,提供便捷入口。 + * - 空字符串 = 上游 bug,fail-fast throw(与 createMessageResponse 一致)。 + * - 等价于 createMessageResponse([{ type: 'text', data: { text } }], replyTo)。 + * + * @param text - 响应文本内容 + * @param replyTo - 可选的引用回复目标消息 ID + * @returns MessageResponse(窄类型) + * @throws text 为空字符串时 throw Error + */ +export function createTextResponse( + text: string, + replyTo?: string, +): MessageResponse { + if (!text) + throw new Error('createTextResponse: text must not be empty') + + return createMessageResponse( + [{ type: 'text', data: { text } }], + replyTo, + ) +} + +/** + * 构造合并转发节点(字符串重载)。 + * + * @param name - 显示的发送者昵称 + * @param uin - 显示的发送者 QQ 号 + * @param content - 纯文本内容(内部转换为 [TextSegment]) + * @param time - 可选的伪造发送时间(Unix 秒级) + */ +export function createForwardNode( + name: string, + uin: string, + content: string, + time?: number, +): ForwardNode +/** + * 构造合并转发节点(消息段数组重载)。 + * + * @param name - 显示的发送者昵称 + * @param uin - 显示的发送者 QQ 号 + * @param content - 输出侧消息段数组 + * @param time - 可选的伪造发送时间(Unix 秒级) + */ +export function createForwardNode( + name: string, + uin: string, + content: OutputMessageSegment[], + time?: number, +): ForwardNode +/** + * 构造合并转发节点(实现体)。 + * + * 功能:创建 ForwardNode,统一 content 为 OutputMessageSegment[]。 + * 设计依据: + * - string 重载:内部转换为 [TextSegment],Dispatcher 不再需要做 + * typeof 分支判断,ForwardNode.content 始终是单一类型。 + * - 空 content 数组 = 上游 bug,fail-fast throw。 + * - time 毫秒防呆:JS Date.now() 返回毫秒,OneBot V11 time 为秒级, + * 传入 > 1e12 时自动 Math.floor(÷1000) + warn,不 throw(意图明确, + * 只是单位搞错)。 + * - time ≤ 0(Unix epoch 或负值)几乎 100% 是 bug,warn + 忽略。 + * + * @param name - 显示的发送者昵称 + * @param uin - 显示的发送者 QQ 号 + * @param content - 纯文本或输出侧消息段数组 + * @param time - 可选的伪造发送时间(Unix 秒级) + * @returns ForwardNode + * @throws content 为空数组时 throw Error + */ +export function createForwardNode( + name: string, + uin: string, + content: string | OutputMessageSegment[], + time?: number, +): ForwardNode { + const segments: OutputMessageSegment[] + = typeof content === 'string' + ? [{ type: 'text', data: { text: content } }] + : content + + if (segments.length === 0) + throw new Error('createForwardNode: content must not be empty') + + let resolvedTime = time + if (resolvedTime != null && resolvedTime > 1e12) { + getLogger().warn( + 'ForwardNode.time appears to be in milliseconds, auto-converting to seconds', + ) + resolvedTime = Math.floor(resolvedTime / 1000) + } + + if (resolvedTime != null && resolvedTime <= 0) { + getLogger().warn( + 'ForwardNode.time is <= 0 (Unix epoch or negative), ignoring', + ) + resolvedTime = undefined + } + + return { + name, + uin, + content: segments, + ...(resolvedTime != null && { time: resolvedTime }), + } +} + +/** + * 构造合并转发响应。 + * + * 功能:创建 kind='forward' 的响应载荷。 + * 设计依据: + * - 节点应通过 createForwardNode() 构造,确保 content 已统一为 + * OutputMessageSegment[]。 + * - 空节点数组 = 上游 bug,NapCat sendGroupForwardMessage 行为未定义, + * fail-fast throw。 + * + * @param nodes - 合并转发节点数组(应通过 createForwardNode 构造) + * @returns ForwardResponse(窄类型) + * @throws nodes 为空数组时 throw Error + */ +export function createForwardResponse( + nodes: ForwardNode[], +): ForwardResponse { + if (nodes.length === 0) + throw new Error('createForwardResponse: nodes must not be empty') + + return { kind: 'forward', forward: nodes } +} + +// ─── 工具函数 ──────────────────────────────────────────────── + +/** + * 合并相邻 TextSegment 为单个段。 + * + * 功能:遍历消息段数组,将连续的 TextSegment 合并(拼接 data.text), + * 非 text 段原样保留。 + * 设计依据: + * - LLM 返回文本经 DecorateStage 分割后可能产出连续 TextSegment, + * OneBot 实现端对连续 text 段的渲染行为未明确定义(可能拼接、 + * 可能换行、可能各实现不同)。 + * - DecorateStage 出口处调用此函数,确保 segments 到达 Dispatcher + * 时相邻 text 段已合并。 + * - 纯函数,返回新数组,不修改传入的 segments。 + * - 合并时创建新的 TextSegment 对象(不修改原对象),保持不可变性。 + * + * @param segments - 输出侧消息段数组 + * @returns 合并后的新数组;空数组输入 → 返回空数组 + */ +export function mergeAdjacentText( + segments: OutputMessageSegment[], +): OutputMessageSegment[] { + if (segments.length === 0) + return [] + + const result: OutputMessageSegment[] = [] + + for (const seg of segments) { + const prev = result.at(-1) + if (seg.type === 'text' && prev?.type === 'text') { + result[result.length - 1] = { + type: 'text', + data: { text: prev.data.text + seg.data.text }, + } + } + else { + result.push(seg) + } + } + + return result +} diff --git a/services/qq-bot/src/utils/async-mutex.ts b/services/qq-bot/src/utils/async-mutex.ts new file mode 100644 index 0000000000..7d944d1f90 --- /dev/null +++ b/services/qq-bot/src/utils/async-mutex.ts @@ -0,0 +1,24 @@ +/** + * 简单的 per-key 异步互斥锁。 + * 同一 key 的操作串行执行,不同 key 之间不阻塞。 + */ +export class KeyedMutex { + private readonly locks = new Map>() + + async acquire(key: string): Promise<() => void> { + while (this.locks.has(key)) { + await this.locks.get(key) + } + + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + this.locks.set(key, gate) + + return () => { + this.locks.delete(key) + release() + } + } +} diff --git a/services/qq-bot/src/utils/bot-message-tracker.ts b/services/qq-bot/src/utils/bot-message-tracker.ts new file mode 100644 index 0000000000..67ff6350c6 --- /dev/null +++ b/services/qq-bot/src/utils/bot-message-tracker.ts @@ -0,0 +1,26 @@ +/** + * 追踪 bot 发送的消息 ID,供 WakeStage 判断回复对象。 + * 使用 Set + FIFO 淘汰策略,避免无限增长。 + */ +export class BotMessageTracker { + private readonly sentIds = new Set() + private readonly maxSize: number + + constructor(maxSize = 5000) { + this.maxSize = maxSize + } + + track(messageId: string | number): void { + const id = String(messageId) + this.sentIds.add(id) + if (this.sentIds.size > this.maxSize) { + const first = this.sentIds.values().next().value + if (first) + this.sentIds.delete(first) + } + } + + isBotMessage(messageId: string | number): boolean { + return this.sentIds.has(String(messageId)) + } +} diff --git a/services/qq-bot/src/utils/chain-serializer.ts b/services/qq-bot/src/utils/chain-serializer.ts new file mode 100644 index 0000000000..9da081271a --- /dev/null +++ b/services/qq-bot/src/utils/chain-serializer.ts @@ -0,0 +1,43 @@ +// src/utils/chain-serializer.ts +// ───────────────────────────────────────────────────────────── +// 消息链序列化工具 +// +// 功能:将输入消息链转换为可读、单行的上下文文本,供 LLM 注入使用。 +// 设计: +// - 文本段保留原文,但将换行压平为空格,确保结果始终为单行。 +// - 非文本段映射为语义标签,降低上下文歧义。 +// - senderName 存在时输出 "{senderName}: {内容}",便于还原对话角色。 +// ───────────────────────────────────────────────────────────── + +import type { InputMessageSegment } from '../types/message.js' + +const SINGLE_LINE_BREAK_RE = /\r?\n/gu + +function toSingleLine(text: string): string { + return text.replace(SINGLE_LINE_BREAK_RE, ' ') +} + +export function serializeChain(chain: InputMessageSegment[], senderName?: string): string { + const content = chain + .map((segment) => { + switch (segment.type) { + case 'text': + return toSingleLine(segment.data.text) + case 'at': + return `@${segment.data.qq}` + case 'image': + return '[图片]' + case 'face': + return '[表情]' + case 'reply': + return `[回复:${segment.data.id}]` + default: + return `[${segment.type}]` + } + }) + .join('') + .trim() + + const normalizedSenderName = senderName ? toSingleLine(senderName).trim() : '' + return normalizedSenderName.length > 0 ? `${normalizedSenderName}: ${content}` : content +} diff --git a/services/qq-bot/src/utils/logger.ts b/services/qq-bot/src/utils/logger.ts new file mode 100644 index 0000000000..30c9d0dca0 --- /dev/null +++ b/services/qq-bot/src/utils/logger.ts @@ -0,0 +1,127 @@ +// src/utils/logger.ts +// ───────────────────────────────────────────────────────────── +// 统一日志模块 — 两阶段初始化 + 注册表 + 彩色输出 + +import process from 'node:process' +// +// 设计依据: +// - 两阶段初始化解决模块 import 时 config 尚未就绪的时序问题: +// createLogger('ns') 立即返回可用实例(默认 info), +// 启动时 initLoggers(config) 遍历注册表统一更新级别。 +// - 格式:[HH:mm:ss.SSS] [LEVEL] [namespace] message +// - 开发环境彩色输出(通过 NO_COLOR 环境变量控制) +// - 兼容 NapLink Logger 接口(通过 NapLinkLoggerAdapter 适配) +// ───────────────────────────────────────────────────────────── + +// ─── 级别定义 ─── + +const LOG_LEVELS = { debug: 0, info: 1, warn: 2, error: 3, off: 4 } as const +type LogLevel = keyof typeof LOG_LEVELS + +// ─── 彩色输出 ─── + +const USE_COLOR = !process.env.NO_COLOR && process.stdout?.isTTY + +const LEVEL_COLORS: Record = { + debug: '\x1B[90m', // gray + info: '\x1B[36m', // cyan + warn: '\x1B[33m', // yellow + error: '\x1B[31m', // red + off: '', +} +const RESET = '\x1B[0m' + +// ─── 全局注册表 ─── + +const registry = new Set() +let globalLevel: LogLevel = 'info' + +// ─── LoggerInstance 类 ─── + +export class LoggerInstance { + private level: LogLevel + + constructor( + public readonly namespace: string, + level?: LogLevel, + ) { + this.level = level ?? globalLevel + registry.add(this) + } + + setLevel(level: LogLevel): void { + this.level = level + } + + private shouldLog(level: LogLevel): boolean { + return LOG_LEVELS[level] >= LOG_LEVELS[this.level] + } + + private format(level: LogLevel, message: string): string { + const now = new Date() + const time = `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}.${pad3(now.getMilliseconds())}` + const tag = level.toUpperCase().padEnd(5) + return `[${time}] [${tag}] [${this.namespace}] ${message}` + } + + private output(level: LogLevel, message: string, args: unknown[]): void { + if (!this.shouldLog(level)) + return + const formatted = this.format(level, message) + const fn = level === 'error' + ? console.error + : level === 'warn' + ? console.warn + : console.info + if (USE_COLOR) { + fn(`${LEVEL_COLORS[level]}${formatted}${RESET}`, ...args) + } + else { + fn(formatted, ...args) + } + } + + debug(msg: string, ...args: unknown[]) { + this.output('debug', msg, args) + } + + info(msg: string, ...args: unknown[]) { + this.output('info', msg, args) + } + + warn(msg: string, ...args: unknown[]) { + this.output('warn', msg, args) + } + + error(msg: string, ...args: unknown[]) { + this.output('error', msg, args) + } +} + +function pad(n: number): string { + return String(n).padStart(2, '0') +} + +function pad3(n: number): string { + return String(n).padStart(3, '0') +} + +// ─── 公共 API ─── + +/** 创建带命名空间的 logger(立即可用,默认 info 级别) */ +export function createLogger(namespace: string): LoggerInstance { + return new LoggerInstance(namespace) +} + +/** + * 全局初始化:config 就绪后调用,刷新所有已创建实例的级别。 + * 支持多次调用(热重载场景)。 + */ +export function initLoggers(config: { logging?: { level?: LogLevel } }): void { + globalLevel = config.logging?.level ?? 'info' + for (const logger of registry) { + logger.setLevel(globalLevel) + } +} + +export type { LogLevel } diff --git a/services/qq-bot/src/utils/message-buffer.ts b/services/qq-bot/src/utils/message-buffer.ts new file mode 100644 index 0000000000..d8930ddb7c --- /dev/null +++ b/services/qq-bot/src/utils/message-buffer.ts @@ -0,0 +1,62 @@ +// src/utils/message-buffer.ts +// ───────────────────────────────────────────────────────────── +// 功能:泛型环形缓冲区,用于 SessionStage 维护 per-session 消息历史。 +// 设计依据: +// - 固定容量 + 覆写最旧元素:内存占用恒定,无需手动 GC。 +// - JS 数组 shift() 是 O(n),环形缓冲区可在 push 场景保持 O(1)。 +// - getRecent(n) 供 ProcessStage 做上下文窗口裁剪。 +// ───────────────────────────────────────────────────────────── + +export class MessageBuffer { + private readonly buffer: (T | undefined)[] + private head = 0 + private _size = 0 + + constructor(private readonly capacity: number) { + if (capacity <= 0) + throw new Error(`MessageBuffer capacity must be > 0, got ${capacity}`) + this.buffer = Array.from({ length: capacity }) + } + + /** 追加元素,满时覆写最旧元素。 */ + push(item: T): void { + this.buffer[this.head] = item + this.head = (this.head + 1) % this.capacity + if (this._size < this.capacity) + this._size++ + } + + /** 按时间顺序返回全部元素(最旧在前)。 */ + getAll(): T[] { + if (this._size === 0) + return [] + + const result: T[] = [] + const start = this._size < this.capacity ? 0 : this.head + for (let i = 0; i < this._size; i++) { + const item = this.buffer[(start + i) % this.capacity] + if (item !== undefined) + result.push(item) + } + return result + } + + /** 返回最近 n 条(不足则返回全部)。 */ + getRecent(n: number): T[] { + if (n <= 0) + return [] + const all = this.getAll() + return n >= all.length ? all : all.slice(-n) + } + + /** 清空缓冲区。 */ + clear(): void { + this.buffer.fill(undefined) + this.head = 0 + this._size = 0 + } + + get size(): number { + return this._size + } +} diff --git a/services/qq-bot/src/utils/naplink-logger-adapter.ts b/services/qq-bot/src/utils/naplink-logger-adapter.ts new file mode 100644 index 0000000000..72fcffd309 --- /dev/null +++ b/services/qq-bot/src/utils/naplink-logger-adapter.ts @@ -0,0 +1,41 @@ +// src/utils/naplink-logger-adapter.ts +// ───────────────────────────────────────────────────────────── +// 将内部 LoggerInstance 适配为 NapLink 的 Logger 接口。 +// 唯一差异:NapLink error() 第二参数是可选 Error 对象, +// 我们将其合并到 args 透传给内部 logger。 +// ───────────────────────────────────────────────────────────── + +import type { Logger as NapLinkLogger } from '@naplink/naplink' + +import type { LoggerInstance } from './logger.js' + +import { createLogger } from './logger.js' + +export class NapLinkLoggerAdapter implements NapLinkLogger { + private logger: LoggerInstance + + constructor(logger?: LoggerInstance) { + this.logger = logger ?? createLogger('naplink') + } + + debug(msg: string, ...meta: unknown[]): void { + this.logger.debug(msg, ...meta) + } + + info(msg: string, ...meta: unknown[]): void { + this.logger.info(msg, ...meta) + } + + warn(msg: string, ...meta: unknown[]): void { + this.logger.warn(msg, ...meta) + } + + error(msg: string, error?: Error, ...meta: unknown[]): void { + if (error) { + this.logger.error(`${msg} — ${error.message}`, error, ...meta) + } + else { + this.logger.error(msg, ...meta) + } + } +} diff --git a/services/qq-bot/src/utils/normalize-content.ts b/services/qq-bot/src/utils/normalize-content.ts new file mode 100644 index 0000000000..98ea295ef7 --- /dev/null +++ b/services/qq-bot/src/utils/normalize-content.ts @@ -0,0 +1,40 @@ +interface StructuredContentPart { + type?: string + text?: string + refusal?: string +} + +/** + * Normalizes AIRI structured content into plain text. + * + * Use when: + * - Handling `message.content` from AIRI chat output events + * - The upstream payload may be a string or structured parts array + * + * Expects: + * - Any unknown value coming from runtime event payloads + * + * Returns: + * - A best-effort plain string representation + */ +export function normalizeContent(content: unknown): string { + if (typeof content === 'string') + return content + + if (Array.isArray(content)) { + return content + .map((part) => { + if (!part || typeof part !== 'object') + return '' + + const candidate = part as StructuredContentPart + return candidate.text ?? candidate.refusal ?? '' + }) + .join('') + } + + if (content == null) + return '' + + return String(content) +} diff --git a/services/qq-bot/src/utils/rate-limiter.ts b/services/qq-bot/src/utils/rate-limiter.ts new file mode 100644 index 0000000000..74249f1a63 --- /dev/null +++ b/services/qq-bot/src/utils/rate-limiter.ts @@ -0,0 +1,80 @@ +// src/utils/rate-limiter.ts +// ───────────────────────────────────────────────────────────── +// 功能:滑动窗口限流 + 冷却追踪,供 RateLimitStage 使用。 +// ───────────────────────────────────────────────────────────── + +export class SlidingWindowRateLimiter { + private readonly windows = new Map() + + constructor( + private readonly max: number, + private readonly windowMs: number, + ) { + if (max <= 0) + throw new Error(`SlidingWindowRateLimiter max must be > 0, got ${max}`) + if (windowMs <= 0) + throw new Error(`SlidingWindowRateLimiter windowMs must be > 0, got ${windowMs}`) + } + + check(key: string): boolean { + const now = Date.now() + const timestamps = this.windows.get(key) + if (!timestamps) + return true + + const cutoff = now - this.windowMs + while (timestamps.length > 0 && timestamps[0]! <= cutoff) + timestamps.shift() + + return timestamps.length < this.max + } + + record(key: string): void { + const timestamps = this.windows.get(key) ?? [] + timestamps.push(Date.now()) + this.windows.set(key, timestamps) + } + + tryConsume(key: string): boolean { + if (!this.check(key)) + return false + this.record(key) + return true + } + + cleanup(): void { + const cutoff = Date.now() - this.windowMs + for (const [key, timestamps] of this.windows) { + while (timestamps.length > 0 && timestamps[0]! <= cutoff) + timestamps.shift() + if (timestamps.length === 0) + this.windows.delete(key) + } + } +} + +export class CooldownTracker { + private readonly cooldowns = new Map() + + constructor(private readonly cooldownMs: number) { + if (cooldownMs < 0) + throw new Error(`CooldownTracker cooldownMs must be >= 0, got ${cooldownMs}`) + } + + isOnCooldown(key: string): boolean { + const expiresAt = this.cooldowns.get(key) + if (expiresAt == null) + return false + if (Date.now() >= expiresAt) { + this.cooldowns.delete(key) + return false + } + return true + } + + startCooldown(key: string): void { + if (this.cooldownMs === 0) + return + this.cooldowns.set(key, Date.now() + this.cooldownMs) + } +} diff --git a/services/qq-bot/src/utils/token-estimator.ts b/services/qq-bot/src/utils/token-estimator.ts new file mode 100644 index 0000000000..1e56c34356 --- /dev/null +++ b/services/qq-bot/src/utils/token-estimator.ts @@ -0,0 +1,16 @@ +import type { OpenAIMessage } from '../types/context.js' + +import { encode } from 'gpt-tokenizer' + +const MESSAGE_OVERHEAD_TOKENS = 4 + +/** + * Estimate token usage for OpenAI-style chat messages locally. + */ +export function estimateTokens(messages: OpenAIMessage[]): number { + return messages.reduce((total, message) => { + const roleTokens = encode(message.role).length + const contentTokens = encode(message.content).length + return total + MESSAGE_OVERHEAD_TOKENS + roleTokens + contentTokens + }, 0) +} diff --git a/services/qq-bot/tsconfig.json b/services/qq-bot/tsconfig.json new file mode 100644 index 0000000000..6017531d62 --- /dev/null +++ b/services/qq-bot/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ESNext", + "lib": [ + "ESNext" + ], + "moduleDetection": "force", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": [ + "node" + ], + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true + }, + "include": [ + "src/**/*.ts" + ] +}