9 Commits

Author SHA1 Message Date
digua
5adb1122d4 chore: 升级到node24 2026-04-08 00:23:00 +08:00
digua
c869383193 release: v0.14.3 2026-04-07 23:55:25 +08:00
digua
53208de60e feat: 实验室新增基础工具用于调试 2026-04-07 23:43:01 +08:00
digua
fa3282f625 feat: 移除旧版提示词 2026-04-07 23:43:01 +08:00
digua
a0155b5f68 chore: 支持Linux打包 2026-04-07 23:43:01 +08:00
digua
4929b49135 docs: update 2026-04-07 23:43:01 +08:00
digua
7919929b94 feat: 增加查询缓存以加速访问 2026-04-07 23:43:01 +08:00
digua
b8a3823cef fix: 补全工具调用显示名称的 i18n 翻译 2026-04-07 23:43:01 +08:00
digua
d49a094164 feat: 搜索工具自动携带上下文消息 2026-04-07 23:43:01 +08:00
42 changed files with 1277 additions and 1037 deletions

View File

@@ -32,7 +32,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
node-version: '24'
- name: Setup pnpm
uses: pnpm/action-setup@v4
@@ -99,7 +99,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
node-version: '24'
- name: Setup pnpm
uses: pnpm/action-setup@v4
@@ -143,8 +143,61 @@ jobs:
if-no-files-found: warn
retention-days: 1 # 只保留1天Release后会上传到GitHub Releases
build-linux:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: |
echo "node-linker=hoisted" >> .npmrc
pnpm install
- name: Build Electron app for Linux
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
APTABASE_APP_KEY: ${{ secrets.APTABASE_APP_KEY }}
run: pnpm build:linux
- name: Upload Linux artifacts
uses: actions/upload-artifact@v4
with:
name: ChatLab-linux
path: |
dist/*.AppImage
dist/*.deb
dist/*.tar.gz
dist/*.yml
dist/*.json
if-no-files-found: warn
retention-days: 1
release:
needs: [build-mac, build-win]
needs: [build-mac, build-win, build-linux]
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch'
@@ -170,6 +223,12 @@ jobs:
name: ChatLab-win
path: dist
- name: Download Linux artifacts
uses: actions/download-artifact@v4
with:
name: ChatLab-linux
path: dist
- name: List files
run: ls -la dist/
@@ -216,6 +275,8 @@ jobs:
echo "| Mac (Apple Silicon) | [ChatLab-${VERSION_NUMBER}-arm64.dmg](https://github.com/hellodigua/ChatLab/releases/download/v${VERSION_NUMBER}/ChatLab-${VERSION_NUMBER}-arm64.dmg) |"
echo "| Mac (Intel) | [ChatLab-${VERSION_NUMBER}-x64.dmg](https://github.com/hellodigua/ChatLab/releases/download/v${VERSION_NUMBER}/ChatLab-${VERSION_NUMBER}-x64.dmg) |"
echo "| Windows | [ChatLab-${VERSION_NUMBER}-setup.exe](https://github.com/hellodigua/ChatLab/releases/download/v${VERSION_NUMBER}/ChatLab-${VERSION_NUMBER}-setup.exe) |"
echo "| Linux (AppImage) | [ChatLab-${VERSION_NUMBER}.AppImage](https://github.com/hellodigua/ChatLab/releases/download/v${VERSION_NUMBER}/ChatLab-${VERSION_NUMBER}.AppImage) |"
echo "| Linux (deb) | [chatlab_${VERSION_NUMBER}_amd64.deb](https://github.com/hellodigua/ChatLab/releases/download/v${VERSION_NUMBER}/chatlab_${VERSION_NUMBER}_amd64.deb) |"
} > release_notes.md
echo "Generated release notes:"
@@ -231,6 +292,9 @@ jobs:
dist/*.exe
dist/*.dmg
dist/*.zip
dist/*.AppImage
dist/*.deb
dist/*.tar.gz
dist/*.yml
dist/*.blockmap
env:

View File

@@ -4,7 +4,7 @@
Rediscover your social memories with private, AI-powered analysis.
English | [简体中文](./README.zh-CN.md) | [繁體中文](./README.zh-TW.md) | [日本語](./README.ja-JP.md)
English | [简体中文](./docs/README.zh-CN.md) | [繁體中文](./docs/README.zh-TW.md) | [日本語](./docs/README.ja-JP.md)
[Official Website](https://chatlab.fun/) · [Download](https://chatlab.fun/?type=download) · [Documentation](https://chatlab.fun/usage/) · [Roadmap](https://chatlabfun.featurebase.app/roadmap) · [Issue Submission](https://github.com/hellodigua/ChatLab/issues)
@@ -92,7 +92,11 @@ electron-fix start
```
## Contributing
## Privacy Policy & User Agreement
Before using this software, please read the [Privacy Policy & User Agreement](./src/assets/docs/agreement_en.md).
## Community
Please follow these principles before submitting a Pull Request:
@@ -100,9 +104,11 @@ Please follow these principles before submitting a Pull Request:
- For new features, please submit an Issue for discussion first; **PRs submitted without prior discussion will be closed**.
- Keep one PR focused on one task; if changes are extensive, consider splitting them into multiple independent PRs.
## Privacy Policy & User Agreement
Thanks to all contributors:
Before using this software, please read the [Privacy Policy & User Agreement](./src/assets/docs/agreement_en.md).
<a href="https://github.com/hellodigua/ChatLab/graphs/contributors">
<img src="https://contrib.rocks/image?repo=hellodigua/ChatLab" />
</a>
## License

View File

@@ -1,10 +1,10 @@
<div align="center">
<img src="./public/images/chatlab.svg" alt="ChatLab" title="ChatLab" width="300" />
<img src="../public/images/chatlab.svg" alt="ChatLab" title="ChatLab" width="300" />
AI Agent でチャット履歴をローカル分析し、あなたのソーシャルな記憶を掘り起こす
[English](./README.md) | [简体中文](./README.zh-CN.md) | [繁體中文](./README.zh-TW.md) | 日本語
[English](../README.md) | [简体中文](./README.zh-CN.md) | [繁體中文](./README.zh-TW.md) | 日本語
[公式サイト](https://chatlab.fun/ja/) · [ダウンロードガイド](https://chatlab.fun/ja/?type=download) · [ドキュメント](https://chatlab.fun/ja/usage/) · [Roadmap](https://chatlabfun.featurebase.app/roadmap) · [Issue](https://github.com/hellodigua/ChatLab/issues)
@@ -33,7 +33,7 @@ ChatLab は、チャット履歴を深く理解するためのローカル完結
その他の画面は公式サイト [chatlab.fun](https://chatlab.fun/ja/) を参照してください。
![Preview Interface](/public/images/intro_en.png)
![Preview Interface](../public/images/intro_en.png)
## システムアーキテクチャ
@@ -82,7 +82,11 @@ npm install electron-fix -g
electron-fix start
```
## コントリビューション
## プライバシーポリシーと利用規約
利用前に [プライバシーポリシーと利用規約](../src/assets/docs/agreement_ja.md) を確認してください。
## コミュニティ
Pull Request を送る前に、次の方針を確認してください。
@@ -90,9 +94,11 @@ Pull Request を送る前に、次の方針を確認してください。
- 新機能は先に Issue で相談してください。**事前の議論がない PR はクローズされます**
- 1 つの PR は 1 つの目的に絞り、変更が大きい場合は分割を検討してください
## プライバシーポリシーと利用規約
ChatLab に貢献してくださったすべての方に感謝します!
利用前に [プライバシーポリシーと利用規約](./src/assets/docs/agreement_ja.md) を確認してください。
<a href="https://github.com/hellodigua/ChatLab/graphs/contributors">
<img src="https://contrib.rocks/image?repo=hellodigua/ChatLab" />
</a>
## License

View File

@@ -1,10 +1,10 @@
<div align="center">
<img src="./public/images/chatlab.svg" alt="ChatLab" title="ChatLab" width="300" />
<img src="../public/images/chatlab.svg" alt="ChatLab" title="ChatLab" width="300" />
本地化的聊天记录分析工具,通过 AI Agent 回顾你的社交记忆
[English](./README.md) | 简体中文 | [繁體中文](./README.zh-TW.md) | [日本語](./README.ja-JP.md)
[English](../README.md) | 简体中文 | [繁體中文](./README.zh-TW.md) | [日本語](./README.ja-JP.md)
[官网](https://chatlab.fun/cn/) · [下载指南](https://chatlab.fun/cn/?type=download) · [项目文档](https://chatlab.fun/cn/usage/) · [路线图](https://chatlabfun.featurebase.app/roadmap) · [问题提交](https://github.com/hellodigua/ChatLab/issues)
@@ -33,7 +33,7 @@ ChatLab 是一个专注于社交记录分析的本地化应用。通过 AI Agent
预览更多请前往官网 [chatlab.fun](https://chatlab.fun/cn/)
![预览界面](/public/images/intro_zh.png)
![预览界面](../public/images/intro_zh.png)
## 系统架构
@@ -82,7 +82,11 @@ npm install electron-fix -g
electron-fix start
```
## 贡献指南
## 隐私政策与用户协议
使用本软件前,请阅读 [隐私政策与用户协议](../src/assets/docs/agreement_zh.md)
## 社区
提交 Pull Request 前请遵循以下原则:
@@ -90,9 +94,11 @@ electron-fix start
- 对于新功能,请先提交 Issue 进行讨论,**未经讨论直接提交的 PR 会被关闭**
- 一个 PR 尽量只做一件事,若改动较大,请考虑拆分为多个独立的 PR
## 隐私政策与用户协议
感谢所有为 ChatLab 做出贡献的人!
使用本软件前,请阅读 [隐私政策与用户协议](./src/assets/docs/agreement_zh.md)
<a href="https://github.com/hellodigua/ChatLab/graphs/contributors">
<img src="https://contrib.rocks/image?repo=hellodigua/ChatLab" />
</a>
## License

View File

@@ -1,10 +1,10 @@
<div align="center">
<img src="./public/images/chatlab.svg" alt="ChatLab" title="ChatLab" width="300" />
<img src="../public/images/chatlab.svg" alt="ChatLab" title="ChatLab" width="300" />
在本機分析聊天記錄,透過 AI Agent 重新看見你的社交記憶
[English](./README.md) | [简体中文](./README.zh-CN.md) | 繁體中文 | [日本語](./README.ja-JP.md)
[English](../README.md) | [简体中文](./README.zh-CN.md) | 繁體中文 | [日本語](./README.ja-JP.md)
[官網](https://chatlab.fun/tw/) · [下載指南](https://chatlab.fun/tw/?type=download) · [使用文件](https://chatlab.fun/tw/usage/) · [Roadmap](https://chatlabfun.featurebase.app/roadmap) · [問題回報](https://github.com/hellodigua/ChatLab/issues)
@@ -33,7 +33,7 @@ ChatLab 是一款專注於社交記錄分析的本機應用。結合 AI Agent
更多畫面請前往官網 [chatlab.fun](https://chatlab.fun/tw/)
![預覽畫面](/public/images/intro_zh.png)
![預覽畫面](../public/images/intro_zh.png)
## 系統架構
@@ -82,7 +82,11 @@ npm install electron-fix -g
electron-fix start
```
## 貢獻指南
## 隱私權政策與使用者協議
使用本軟體前,請先閱讀 [隱私權政策與使用者協議](../src/assets/docs/agreement_zh_tw.md)
## 社群
提交 Pull Request 前請遵循以下原則:
@@ -90,9 +94,11 @@ electron-fix start
- 新功能請先提交 Issue 討論,**未經討論直接提交的 PR 會被關閉**
- 一個 PR 盡量只處理一件事;若改動較大,建議拆分成多個獨立 PR
## 隱私權政策與使用者協議
感謝所有為 ChatLab 做出貢獻的人!
使用本軟體前,請先閱讀 [隱私權政策與使用者協議](./src/assets/docs/agreement_zh_tw.md)
<a href="https://github.com/hellodigua/ChatLab/graphs/contributors">
<img src="https://contrib.rocks/image?repo=hellodigua/ChatLab" />
</a>
## License

View File

@@ -1,4 +1,32 @@
[
{
"version": "0.14.3",
"date": "2026-04-07",
"summary": "大幅度优化搜索与查询性能,搜索工具支持自动携带上下文消息,并支持 Linux 平台。",
"changes": [
{
"type": "feat",
"items": [
"实验室新增基础调试工具",
"搜索工具支持自动携带上下文消息",
"新增查询缓存以提升访问速度",
"移除旧版提示词系统"
]
},
{
"type": "fix",
"items": ["补全工具调用显示名称的 i18n 翻译"]
},
{
"type": "chore",
"items": ["支持 Linux 平台打包"]
},
{
"type": "docs",
"items": ["更新项目文档内容"]
}
]
},
{
"version": "0.14.2",
"date": "2026-04-07",

View File

@@ -1,4 +1,32 @@
[
{
"version": "0.14.3",
"date": "2026-04-07",
"summary": "This release adds core debugging tools for Lab, improves search context and query performance, fills missing i18n labels, removes legacy prompts, and adds Linux packaging support.",
"changes": [
{
"type": "feat",
"items": [
"Add core debugging tools in Lab.",
"Make the search tool carry conversation context automatically.",
"Add query caching to speed up repeated access.",
"Remove the legacy prompt system."
]
},
{
"type": "fix",
"items": ["Fill in missing i18n labels for tool-call display names."]
},
{
"type": "chore",
"items": ["Add Linux packaging support."]
},
{
"type": "docs",
"items": ["Update project documentation."]
}
]
},
{
"version": "0.14.2",
"date": "2026-04-07",

View File

@@ -1,4 +1,32 @@
[
{
"version": "0.14.3",
"date": "2026-04-07",
"summary": "今回の更新では、Lab の基本デバッグツールを追加し、検索コンテキストとクエリ性能を改善。i18n 表示文言の不足を補い、旧プロンプトを整理し、Linux パッケージングにも対応しました。",
"changes": [
{
"type": "feat",
"items": [
"Lab に基本的なデバッグツールを追加",
"検索ツールが会話コンテキストを自動で引き継ぐよう改善",
"クエリキャッシュを追加し、繰り返しアクセスを高速化",
"旧プロンプトシステムを削除"
]
},
{
"type": "fix",
"items": ["ツール呼び出し表示名の i18n 翻訳不足を修正"]
},
{
"type": "chore",
"items": ["Linux 向けパッケージングに対応"]
},
{
"type": "docs",
"items": ["プロジェクトドキュメントを更新"]
}
]
},
{
"version": "0.14.2",
"date": "2026-04-07",

View File

@@ -1,4 +1,32 @@
[
{
"version": "0.14.3",
"date": "2026-04-07",
"summary": "本次更新新增實驗室基礎除錯工具,優化搜尋上下文與查詢效能,補齊 i18n 顯示文案,並移除舊版提示詞與支援 Linux 打包。",
"changes": [
{
"type": "feat",
"items": [
"實驗室新增基礎除錯工具",
"搜尋工具可自動攜帶上下文訊息",
"新增查詢快取以加速重複存取",
"移除舊版提示詞系統"
]
},
{
"type": "fix",
"items": ["補齊工具呼叫顯示名稱的 i18n 翻譯"]
},
{
"type": "chore",
"items": ["新增 Linux 平台打包支援"]
},
{
"type": "docs",
"items": ["更新專案文件內容"]
}
]
},
{
"version": "0.14.2",
"date": "2026-04-07",

View File

@@ -70,7 +70,6 @@ linux:
target:
- AppImage
- deb
- rpm
- tar.gz
category: Utility

View File

@@ -33,11 +33,22 @@ export function createTool(context: ToolContext): AgentTool<typeof schema> {
params.sender_id
)
const contextBefore = context.searchContextBefore ?? 2
const contextAfter = context.searchContextAfter ?? 2
let finalMessages = result.messages
if ((contextBefore > 0 || contextAfter > 0) && result.messages.length > 0) {
const hitIds = result.messages.map((m) => m.id).filter((id): id is number => id != null)
if (hitIds.length > 0) {
finalMessages = await workerManager.getSearchMessageContext(sessionId, hitIds, contextBefore, contextAfter)
}
}
const data = {
total: result.total,
returned: result.messages.length,
returned: finalMessages.length,
timeRange: formatTimeRange(effectiveTimeFilter, locale),
rawMessages: result.messages,
rawMessages: finalMessages,
}
return {

View File

@@ -34,11 +34,22 @@ export function createTool(context: ToolContext): AgentTool<typeof schema> {
params.sender_id
)
const contextBefore = context.searchContextBefore ?? 2
const contextAfter = context.searchContextAfter ?? 2
let finalMessages = result.messages
if ((contextBefore > 0 || contextAfter > 0) && result.messages.length > 0) {
const hitIds = result.messages.map((m) => m.id).filter((id): id is number => id != null)
if (hitIds.length > 0) {
finalMessages = await workerManager.getSearchMessageContext(sessionId, hitIds, contextBefore, contextAfter)
}
}
const data = {
total: result.total,
returned: result.messages.length,
returned: finalMessages.length,
timeRange: formatTimeRange(effectiveTimeFilter, locale),
rawMessages: result.messages,
rawMessages: finalMessages,
}
return {

View File

@@ -53,4 +53,8 @@ export interface ToolContext {
locale?: string
/** 聊天记录预处理配置(全局) */
preprocessConfig?: PreprocessConfig
/** 搜索结果上下文:向前取多少条(默认 3 */
searchContextBefore?: number
/** 搜索结果上下文:向后取多少条(默认 3 */
searchContextAfter?: number
}

View File

@@ -350,7 +350,9 @@ export function deleteSession(sessionId: string): boolean {
if (fs.existsSync(shmPath)) {
fs.unlinkSync(shmPath)
}
deleteSessionCache(sessionId, getCacheDir())
const cacheDir = getCacheDir()
deleteSessionCache(sessionId, cacheDir)
deleteSessionCache(sessionId, path.join(cacheDir, 'query'))
return true
} catch (error) {
console.error('[Database] Failed to delete session:', error)

View File

@@ -21,6 +21,7 @@ import {
} from '@mariozechner/pi-ai'
import { t } from '../i18n'
import type { ToolContext } from '../ai/tools/types'
import { TOOL_REGISTRY } from '../ai/tools/definitions'
import { getDefaultRulesForLocale, mergeRulesForLocale } from '../ai/preprocessor/builtin-rules'
import type { IpcContext } from './types'
@@ -147,6 +148,29 @@ function formatAIError(error: unknown, provider?: llm.LLMProvider): string {
return friendlyMessage
}
/**
* 递归剥离对象中的 avatar/senderAvatar 字段base64 大数据)
* 用于工具测试场景,避免传输和序列化大量无用头像数据
*/
function stripAvatarFields(obj: unknown): void {
if (!obj || typeof obj !== 'object') return
if (Array.isArray(obj)) {
for (const item of obj) stripAvatarFields(item)
return
}
const record = obj as Record<string, unknown>
for (const key of Object.keys(record)) {
if ((key === 'avatar' || key === 'senderAvatar') && typeof record[key] === 'string') {
const val = record[key] as string
if (val.length > 200) {
record[key] = '[stripped]'
}
} else if (typeof record[key] === 'object' && record[key] !== null) {
stripAvatarFields(record[key])
}
}
}
export function registerAIHandlers({ win }: IpcContext): void {
console.log('[IPC] Registering AI handlers...')
@@ -820,6 +844,94 @@ export function registerAIHandlers({ win }: IpcContext): void {
}
})
// ==================== 工具测试 API实验室 - 基础工具) ====================
const activeToolTests = new Map<string, AbortController>()
ipcMain.handle('ai:getToolCatalog', async () => {
try {
return TOOL_REGISTRY.map((entry) => {
const dummyContext: ToolContext = { sessionId: '__catalog__' }
const tool = entry.factory(dummyContext)
const descKey = `ai.tools.${entry.name}.desc`
const translated = t(descKey)
return {
name: entry.name,
category: entry.category,
description: translated !== descKey ? translated : (tool.description ?? ''),
parameters: tool.parameters ?? {},
}
})
} catch (error) {
console.error('Failed to get tool catalog:', error)
return []
}
})
ipcMain.handle(
'ai:executeTool',
async (_, testId: string, toolName: string, params: Record<string, unknown>, sessionId: string) => {
const MAX_RESULT_CHARS = 500_000
const abortController = new AbortController()
activeToolTests.set(testId, abortController)
try {
const entry = TOOL_REGISTRY.find((e) => e.name === toolName)
if (!entry) {
return { success: false, error: `Tool not found: ${toolName}` }
}
const context: ToolContext = { sessionId }
const tool = entry.factory(context)
const startTime = Date.now()
const result = await tool.execute(`test_${Date.now()}`, params)
const elapsed = Date.now() - startTime
if (abortController.signal.aborted) {
return { success: false, error: 'cancelled' }
}
let details = result.details as Record<string, unknown> | undefined
let truncated = false
if (details) {
stripAvatarFields(details)
const raw = JSON.stringify(details)
if (raw.length > MAX_RESULT_CHARS) {
truncated = true
details = { _truncated: true, _originalSize: raw.length, _preview: raw.slice(0, MAX_RESULT_CHARS) }
}
}
return {
success: true,
elapsed,
content: result.content,
details,
truncated,
}
} catch (error) {
if (abortController.signal.aborted) {
return { success: false, error: 'cancelled' }
}
console.error(`Failed to execute tool ${toolName}:`, error)
return { success: false, error: String(error) }
} finally {
activeToolTests.delete(testId)
}
}
)
ipcMain.handle('ai:cancelToolTest', async (_, testId: string) => {
const controller = activeToolTests.get(testId)
if (controller) {
controller.abort()
activeToolTests.delete(testId)
return { success: true }
}
return { success: false }
})
// ==================== AI Agent API ====================
/**

View File

@@ -636,7 +636,9 @@ export function registerChatHandlers(ctx: IpcContext): void {
*/
ipcMain.handle('chat:updateMemberAliases', async (_, sessionId: string, memberId: number, aliases: string[]) => {
try {
return await worker.updateMemberAliases(sessionId, memberId, aliases)
const result = await worker.updateMemberAliases(sessionId, memberId, aliases)
if (result) worker.invalidateAnalysisCache(sessionId).catch(() => {})
return result
} catch (error) {
console.error('Failed to update member alias:', error)
return false
@@ -651,7 +653,9 @@ export function registerChatHandlers(ctx: IpcContext): void {
// 先关闭数据库连接
await worker.closeDatabase(sessionId)
// 执行删除
return await worker.deleteMember(sessionId, memberId)
const result = await worker.deleteMember(sessionId, memberId)
if (result) worker.invalidateAnalysisCache(sessionId).catch(() => {})
return result
} catch (error) {
console.error('Failed to delete member:', error)
return false
@@ -1029,6 +1033,8 @@ export function registerChatHandlers(ctx: IpcContext): void {
} catch (e) {
console.error('[IpcMain] Failed to incrementally generate session index:', e)
}
// 数据变更后清除分析缓存
worker.invalidateAnalysisCache(sessionId).catch(() => {})
}
return result

View File

@@ -9,8 +9,10 @@
* 4. 返回结果
*/
import * as path from 'path'
import { parentPort, workerData } from 'worker_threads'
import { initDbDir, closeDatabase, closeAllDatabases } from './core'
import { initDbDir, closeDatabase, closeAllDatabases, getCacheDir } from './core'
import { getCache, setCache, deleteSessionCache } from '../database/sessionCache'
import {
getAvailableYears,
getMemberActivity,
@@ -34,6 +36,7 @@ import {
searchMessages,
deepSearchMessages,
getMessageContext,
getSearchMessageContext,
getRecentMessages,
getAllRecentMessages,
getConversationBetween,
@@ -72,6 +75,57 @@ import { streamImport, streamParseFileInfo, analyzeIncrementalImport, incrementa
// 初始化数据库目录
initDbDir(workerData.dbDir, workerData.cacheDir)
// ==================== 分析结果缓存 ====================
const ANALYSIS_CACHE_PREFIX = 'analysis:'
function getQueryCacheDir(): string {
const cacheDir = getCacheDir()
return cacheDir ? path.join(cacheDir, 'query') : ''
}
const CACHEABLE_QUERIES = new Set([
'getAvailableYears',
'getMemberActivity',
'getHourlyActivity',
'getDailyActivity',
'getWeekdayActivity',
'getMonthlyActivity',
'getYearlyActivity',
'getMessageLengthDistribution',
'getMessageTypeDistribution',
'getTimeRange',
'getCatchphraseAnalysis',
'getMentionAnalysis',
'getMentionGraph',
'getLaughAnalysis',
'getClusterGraph',
'getWordFrequency',
])
function buildAnalysisCacheKey(type: string, payload: any): string {
const parts = [ANALYSIS_CACHE_PREFIX + type]
// 标准 filter 对象(大多数分析查询)
const filter = payload.filter || payload.timeFilter
if (filter) {
if (filter.startTs !== undefined) parts.push(`s${filter.startTs}`)
if (filter.endTs !== undefined) parts.push(`e${filter.endTs}`)
if (filter.memberId !== undefined && filter.memberId !== null) {
parts.push(`m${filter.memberId}`)
}
}
// 顶层 memberId如 getWordFrequency 直接传 memberId
if (payload.memberId !== undefined && payload.memberId !== null) parts.push(`m${payload.memberId}`)
if (payload.keywords) parts.push(`k${JSON.stringify(payload.keywords)}`)
if (payload.options) parts.push(`o${JSON.stringify(payload.options)}`)
// getWordFrequency 特有参数
if (payload.locale) parts.push(`l${payload.locale}`)
if (payload.topN) parts.push(`n${payload.topN}`)
if (payload.minLength) parts.push(`ml${payload.minLength}`)
if (payload.posTags) parts.push(`pt${JSON.stringify(payload.posTags)}`)
return parts.join(':')
}
// ==================== 消息处理 ====================
interface WorkerMessage {
@@ -124,6 +178,7 @@ const syncHandlers: Record<string, (payload: any) => any> = {
// AI 查询
searchMessages: (p) => searchMessages(p.sessionId, p.keywords, p.filter, p.limit, p.offset, p.senderId),
getMessageContext: (p) => getMessageContext(p.sessionId, p.messageIds, p.contextSize),
getSearchMessageContext: (p) => getSearchMessageContext(p.sessionId, p.messageIds, p.contextBefore, p.contextAfter),
getRecentMessages: (p) => getRecentMessages(p.sessionId, p.filter, p.limit),
getAllRecentMessages: (p) => getAllRecentMessages(p.sessionId, p.filter, p.limit),
getConversationBetween: (p) => getConversationBetween(p.sessionId, p.memberId1, p.memberId2, p.filter, p.limit),
@@ -164,6 +219,15 @@ const syncHandlers: Record<string, (payload: any) => any> = {
// 深度搜索LIKE 子串匹配)
deepSearchMessages: (p) => deepSearchMessages(p.sessionId, p.keywords, p.filter, p.limit, p.offset, p.senderId),
// 缓存管理
invalidateAnalysisCache: (p) => {
const queryCacheDir = getQueryCacheDir()
if (queryCacheDir && p.sessionId) {
deleteSessionCache(p.sessionId, queryCacheDir)
}
return true
},
}
// 异步消息处理器(流式操作)
@@ -198,6 +262,21 @@ parentPort?.on('message', async (message: WorkerMessage) => {
throw new Error(`Unknown message type: ${type}`)
}
// 可缓存查询先查缓存miss 后执行并写回
const queryCacheDir = getQueryCacheDir()
if (queryCacheDir && CACHEABLE_QUERIES.has(type) && payload.sessionId) {
const cacheKey = buildAnalysisCacheKey(type, payload)
const cached = getCache(payload.sessionId, cacheKey, queryCacheDir)
if (cached !== null) {
parentPort?.postMessage({ id, success: true, result: cached })
return
}
const result = syncHandler(payload)
setCache(payload.sessionId, cacheKey, result, queryCacheDir)
parentPort?.postMessage({ id, success: true, result })
return
}
const result = syncHandler(payload)
parentPort?.postMessage({ id, success: true, result })
} catch (error) {

View File

@@ -46,6 +46,7 @@ export {
searchMessages,
deepSearchMessages,
getMessageContext,
getSearchMessageContext,
getRecentMessages,
getAllRecentMessages,
getConversationBetween,

View File

@@ -526,6 +526,115 @@ export function getMessageContext(
return rows.map(sanitizeMessageRow)
}
/**
* 获取搜索结果的上下文消息(会话感知 + 区间合并去重)
* 用于 search_messages / deep_search_messages 自动扩展上下文。
* 当存在会话索引时,上下文不跨会话边界;否则按 message.id 顺序取前后 N 条。
*
* @param sessionId 数据库会话 ID
* @param messageIds 搜索命中的消息 ID 列表
* @param contextBefore 每条命中消息向前取多少条上下文
* @param contextAfter 每条命中消息向后取多少条上下文
*/
export function getSearchMessageContext(
sessionId: string,
messageIds: number[],
contextBefore: number = 2,
contextAfter: number = 2
): MessageResult[] {
ensureAvatarColumn(sessionId)
const db = openDatabase(sessionId)
if (!db) return []
if (messageIds.length === 0) return []
const contextIds = new Set<number>()
const hasSessionData =
(db.prepare('SELECT 1 FROM message_context LIMIT 1').get() as { 1: number } | undefined) !== undefined
for (const messageId of messageIds) {
contextIds.add(messageId)
if (hasSessionData) {
const sessionRow = db.prepare('SELECT session_id FROM message_context WHERE message_id = ?').get(messageId) as
| { session_id: number }
| undefined
if (sessionRow) {
if (contextBefore > 0) {
const rows = db
.prepare(
`SELECT mc.message_id as id
FROM message_context mc
WHERE mc.session_id = ? AND mc.message_id < ?
ORDER BY mc.message_id DESC
LIMIT ?`
)
.all(sessionRow.session_id, messageId, contextBefore) as { id: number }[]
rows.forEach((r) => contextIds.add(r.id))
}
if (contextAfter > 0) {
const rows = db
.prepare(
`SELECT mc.message_id as id
FROM message_context mc
WHERE mc.session_id = ? AND mc.message_id > ?
ORDER BY mc.message_id ASC
LIMIT ?`
)
.all(sessionRow.session_id, messageId, contextAfter) as { id: number }[]
rows.forEach((r) => contextIds.add(r.id))
}
continue
}
}
// Fallback: no session data or message not indexed — use simple id-based context
if (contextBefore > 0) {
const rows = db
.prepare('SELECT id FROM message WHERE id < ? ORDER BY id DESC LIMIT ?')
.all(messageId, contextBefore) as { id: number }[]
rows.forEach((r) => contextIds.add(r.id))
}
if (contextAfter > 0) {
const rows = db
.prepare('SELECT id FROM message WHERE id > ? ORDER BY id ASC LIMIT ?')
.all(messageId, contextAfter) as { id: number }[]
rows.forEach((r) => contextIds.add(r.id))
}
}
if (contextIds.size === 0) return []
const idList = Array.from(contextIds)
const placeholders = idList.map(() => '?').join(', ')
const sql = `
SELECT
msg.id,
m.id as senderId,
COALESCE(m.group_nickname, m.account_name, m.platform_id) as senderName,
m.platform_id as senderPlatformId,
m.aliases,
m.avatar,
msg.content,
msg.ts as timestamp,
msg.type,
msg.reply_to_message_id,
reply_msg.content as replyToContent,
COALESCE(reply_m.group_nickname, reply_m.account_name, reply_m.platform_id) as replyToSenderName
FROM message msg
JOIN member m ON msg.sender_id = m.id
LEFT JOIN message reply_msg ON msg.reply_to_message_id = reply_msg.platform_message_id
LEFT JOIN member reply_m ON reply_msg.sender_id = reply_m.id
WHERE msg.id IN (${placeholders})
ORDER BY msg.ts ASC, msg.id ASC
`
const rows = db.prepare(sql).all(...idList) as DbMessageRow[]
return rows.map(sanitizeMessageRow)
}
/**
* 获取指定消息之前的 N 条消息(用于向上无限滚动)
* @param sessionId 会话 ID

View File

@@ -253,6 +253,16 @@ export async function pluginCompute<TOutput = any>(fnString: string, input: any)
return sendToWorker('pluginCompute', { fnString, input }, 120000)
}
// ==================== 缓存管理 ====================
/**
* 清除指定 session 的所有分析结果缓存
* 在数据变更(增量导入、成员删除/别名更新)后调用
*/
export async function invalidateAnalysisCache(sessionId: string): Promise<boolean> {
return sendToWorker('invalidateAnalysisCache', { sessionId })
}
// ==================== 导出的异步 API ====================
export async function getAvailableYears(sessionId: string): Promise<number[]> {
@@ -494,6 +504,18 @@ export async function getMessageContext(
return sendToWorker('getMessageContext', { sessionId, messageIds, contextSize })
}
/**
* 获取搜索结果的上下文消息(会话感知 + 区间合并去重)
*/
export async function getSearchMessageContext(
sessionId: string,
messageIds: number[],
contextBefore?: number,
contextAfter?: number
): Promise<SearchMessageResult[]> {
return sendToWorker('getSearchMessageContext', { sessionId, messageIds, contextBefore, contextAfter })
}
/**
* 获取最近消息(用于概览性问题)
*/

View File

@@ -113,6 +113,24 @@ export interface DesensitizeRule {
locales: string[]
}
/** 工具目录条目(实验室 - 基础工具) */
export interface ToolCatalogEntry {
name: string
category: 'core' | 'analysis'
description: string
parameters: Record<string, unknown>
}
/** 工具执行结果 */
export interface ToolExecuteResult {
success: boolean
elapsed?: number
content?: Array<{ type: string; text: string }>
details?: Record<string, unknown>
error?: string
truncated?: boolean
}
/** 聊天记录预处理配置 */
export interface PreprocessConfig {
dataCleaning: boolean
@@ -501,6 +519,23 @@ export const aiApi = {
mergeDesensitizeRules: (existingRules: DesensitizeRule[], locale: string): Promise<DesensitizeRule[]> => {
return ipcRenderer.invoke('ai:mergeDesensitizeRules', existingRules, locale)
},
getToolCatalog: (): Promise<ToolCatalogEntry[]> => {
return ipcRenderer.invoke('ai:getToolCatalog')
},
executeTool: (
testId: string,
toolName: string,
params: Record<string, unknown>,
sessionId: string
): Promise<ToolExecuteResult> => {
return ipcRenderer.invoke('ai:executeTool', testId, toolName, params, sessionId)
},
cancelToolTest: (testId: string): Promise<{ success: boolean }> => {
return ipcRenderer.invoke('ai:cancelToolTest', testId)
},
}
// ==================== LLM API ====================

View File

@@ -377,6 +377,9 @@ interface AiApi {
showAiLogFile: () => Promise<{ success: boolean; path?: string; error?: string }>
getDefaultDesensitizeRules: (locale: string) => Promise<DesensitizeRule[]>
mergeDesensitizeRules: (existingRules: DesensitizeRule[], locale: string) => Promise<DesensitizeRule[]>
getToolCatalog: () => Promise<ToolCatalogEntry[]>
executeTool: (testId: string, toolName: string, params: Record<string, unknown>, sessionId: string) => Promise<ToolExecuteResult>
cancelToolTest: (testId: string) => Promise<{ success: boolean }>
// 自定义筛选(支持分页)
filterMessagesWithContext: (
sessionId: string,
@@ -632,6 +635,24 @@ interface DesensitizeRule {
locales: string[]
}
/** 工具目录条目(实验室 - 基础工具) */
interface ToolCatalogEntry {
name: string
category: 'core' | 'analysis'
description: string
parameters: Record<string, unknown>
}
/** 工具执行结果 */
interface ToolExecuteResult {
success: boolean
elapsed?: number
content?: Array<{ type: string; text: string }>
details?: Record<string, unknown>
error?: string
truncated?: boolean
}
/** 聊天记录预处理配置 */
interface PreprocessConfig {
dataCleaning: boolean
@@ -1053,6 +1074,8 @@ export {
ToolContext,
DesensitizeRule,
PreprocessConfig,
ToolCatalogEntry,
ToolExecuteResult,
TokenUsage,
CacheDirectoryInfo,
CacheInfo,

View File

@@ -1,6 +1,6 @@
{
"name": "ChatLab",
"version": "0.14.2",
"version": "0.14.3",
"description": "本地化的聊天记录分析工具,通过 SQL 和 AI Agent 回顾你的社交记忆",
"repository": {
"type": "git",
@@ -8,6 +8,11 @@
},
"author": "",
"main": "./out/main/index.js",
"packageManager": "pnpm@9.15.9",
"engines": {
"node": ">=24 <25",
"pnpm": ">=9 <10"
},
"pnpm": {
"onlyBuiltDependencies": [
"better-sqlite3",
@@ -20,11 +25,12 @@
"format": "prettier --write .",
"lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
"build": "electron-vite build",
"build:mac": "npm run build && electron-builder --mac --config electron-builder.yml -p never",
"build:win": "npm run build && electron-builder --win --config electron-builder.yml -p never",
"build:mac": "pnpm run build && electron-builder --mac --config electron-builder.yml -p never",
"build:win": "pnpm run build && electron-builder --win --config electron-builder.yml -p never",
"build:linux": "pnpm run build && electron-builder --linux --config electron-builder.yml -p never",
"type-check:web": "vue-tsc --noEmit -p tsconfig.web.json",
"type-check:node": "tsc --noEmit -p tsconfig.node.json",
"type-check:all": "npm run type-check:web && npm run type-check:node",
"type-check:all": "pnpm run type-check:web && pnpm run type-check:node",
"type-check": "vue-tsc --noEmit -p tsconfig.web.json",
"test:agent-context": "node --experimental-strip-types --test electron/main/ai/context/sessionLog.test.mjs",
"test:e2e:launcher": "node --test tests/e2e/helpers/app-launcher.test.js",
@@ -76,6 +82,7 @@
"tailwindcss": "^4.0.0",
"vite": "^6.3.5",
"vue": "^3.5.25",
"vue-router": "^4.6.3"
"vue-router": "^4.6.3",
"vue-tsc": "^3.1.1"
}
}

69
pnpm-lock.yaml generated
View File

@@ -138,6 +138,9 @@ importers:
vue-router:
specifier: ^4.6.3
version: 4.6.4(vue@3.5.27(typescript@5.9.3))
vue-tsc:
specifier: ^3.1.1
version: 3.2.6(typescript@5.9.3)
packages:
@@ -1845,6 +1848,15 @@ packages:
vite: ^5.0.0 || ^6.0.0
vue: ^3.2.25
'@volar/language-core@2.4.28':
resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==}
'@volar/source-map@2.4.28':
resolution: {integrity: sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==}
'@volar/typescript@2.4.28':
resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==}
'@vue/compiler-core@3.5.27':
resolution: {integrity: sha512-gnSBQjZA+//qDZen+6a2EdHqJ68Z7uybrMf3SPjEGgG4dicklwDVmMC1AeIHxtLVPT7sn6sH1KOO+tS6gwOUeQ==}
@@ -1886,6 +1898,9 @@ packages:
typescript:
optional: true
'@vue/language-core@3.2.6':
resolution: {integrity: sha512-xYYYX3/aVup576tP/23sEUpgiEnujrENaoNRbaozC1/MA9I6EGFQRJb4xrt/MmUCAGlxTKL2RmT8JLTPqagCkg==}
'@vue/reactivity@3.5.27':
resolution: {integrity: sha512-vvorxn2KXfJ0nBEnj4GYshSgsyMNFnIQah/wczXlsNXt+ijhugmW+PpJ2cNPe4V6jpnBcs0MhCODKllWG+nvoQ==}
@@ -2036,6 +2051,9 @@ packages:
ajv@8.18.0:
resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==}
alien-signals@3.1.2:
resolution: {integrity: sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw==}
ansi-regex@5.0.1:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
@@ -3669,6 +3687,9 @@ packages:
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
muggle-string@0.4.1:
resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
nanoid@3.3.11:
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@@ -3848,6 +3869,9 @@ packages:
partial-json@0.1.7:
resolution: {integrity: sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==}
path-browserify@1.0.1:
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
path-exists@4.0.0:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'}
@@ -4738,6 +4762,9 @@ packages:
yaml:
optional: true
vscode-uri@3.1.0:
resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
vue-component-type-helpers@3.2.4:
resolution: {integrity: sha512-05lR16HeZDcDpB23ku5b5f1fBOoHqFnMiKRr2CiEvbG5Ux4Yi0McmQBOET0dR0nxDXosxyVqv67q6CzS3AK8rw==}
@@ -4775,6 +4802,12 @@ packages:
peerDependencies:
vue: ^3.5.0
vue-tsc@3.2.6:
resolution: {integrity: sha512-gYW/kWI0XrwGzd0PKc7tVB/qpdeAkIZLNZb10/InizkQjHjnT8weZ/vBarZoj4kHKbUTZT/bAVgoOr8x4NsQ/Q==}
hasBin: true
peerDependencies:
typescript: '>=5.0.0'
vue@3.5.27:
resolution: {integrity: sha512-aJ/UtoEyFySPBGarREmN4z6qNKpbEguYHMmXSiOGk69czc+zhs0NF6tEFrY8TZKAl8N/LYAkd4JHVd5E/AsSmw==}
peerDependencies:
@@ -7252,6 +7285,18 @@ snapshots:
vite: 6.4.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2)
vue: 3.5.27(typescript@5.9.3)
'@volar/language-core@2.4.28':
dependencies:
'@volar/source-map': 2.4.28
'@volar/source-map@2.4.28': {}
'@volar/typescript@2.4.28':
dependencies:
'@volar/language-core': 2.4.28
path-browserify: 1.0.1
vscode-uri: 3.1.0
'@vue/compiler-core@3.5.27':
dependencies:
'@babel/parser': 7.29.0
@@ -7324,6 +7369,16 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@vue/language-core@3.2.6':
dependencies:
'@volar/language-core': 2.4.28
'@vue/compiler-dom': 3.5.27
'@vue/shared': 3.5.27
alien-signals: 3.1.2
muggle-string: 0.4.1
path-browserify: 1.0.1
picomatch: 4.0.3
'@vue/reactivity@3.5.27':
dependencies:
'@vue/shared': 3.5.27
@@ -7457,6 +7512,8 @@ snapshots:
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
alien-signals@3.1.2: {}
ansi-regex@5.0.1: {}
ansi-regex@6.2.2: {}
@@ -9262,6 +9319,8 @@ snapshots:
ms@2.1.3: {}
muggle-string@0.4.1: {}
nanoid@3.3.11: {}
napi-build-utils@2.0.0: {}
@@ -9442,6 +9501,8 @@ snapshots:
partial-json@0.1.7: {}
path-browserify@1.0.1: {}
path-exists@4.0.0: {}
path-is-absolute@1.0.1: {}
@@ -10348,6 +10409,8 @@ snapshots:
lightningcss: 1.31.1
yaml: 2.8.2
vscode-uri@3.1.0: {}
vue-component-type-helpers@3.2.4: {}
vue-demi@0.14.10(vue@3.5.27(typescript@5.9.3)):
@@ -10391,6 +10454,12 @@ snapshots:
'@vue/devtools-api': 6.6.4
vue: 3.5.27(typescript@5.9.3)
vue-tsc@3.2.6(typescript@5.9.3):
dependencies:
'@volar/typescript': 2.4.28
'@vue/language-core': 3.2.6
typescript: 5.9.3
vue@3.5.27(typescript@5.9.3):
dependencies:
'@vue/compiler-dom': 3.5.27

View File

@@ -5,9 +5,12 @@ import { useRoute } from 'vue-router'
import { SubTabs } from '@/components/UI'
import { ChatExplorer } from '../AIChat'
import SQLLabTab from './SQLLabTab.vue'
import ToolTestTab from './ToolTestTab.vue'
import { useSettingsStore } from '@/stores/settings'
const { t } = useI18n()
const route = useRoute()
const settingsStore = useSettingsStore()
// Props
const props = defineProps<{
@@ -19,9 +22,14 @@ const props = defineProps<{
}>()
const subTabs = computed(() => {
// 实验室模式下只保留 SQL 实验室子 Tab一级导航由外层页面承载。
if (props.mode === 'sql-only') {
return [{ id: 'sql-lab', label: t('ai.tab.sqlLab'), icon: 'i-heroicons-command-line' }]
const tabs = [
{ id: 'sql-lab', label: t('ai.tab.sqlLab'), icon: 'i-heroicons-command-line' },
]
if (settingsStore.debugMode) {
tabs.push({ id: 'tool-test', label: t('ai.lab.basicTools'), icon: 'i-heroicons-wrench-screwdriver' })
}
return tabs
}
return [
@@ -32,12 +40,13 @@ const subTabs = computed(() => {
const activeSubTab = ref(props.mode === 'sql-only' ? 'sql-lab' : (route.query.aiSubTab as string) || 'chat-explorer')
// 悬浮任务条返回时会通过 query 指定目标子页,这里同步一次,确保能直接回到对话流。
watch(
() => route.query.aiSubTab,
(nextTab) => {
if (props.mode === 'sql-only') {
activeSubTab.value = 'sql-lab'
if (nextTab === 'sql-lab' || (nextTab === 'tool-test' && settingsStore.debugMode)) {
activeSubTab.value = nextTab
}
return
}
@@ -47,6 +56,15 @@ watch(
}
)
watch(
() => settingsStore.debugMode,
(enabled) => {
if (!enabled && activeSubTab.value === 'tool-test') {
activeSubTab.value = 'sql-lab'
}
}
)
// ChatExplorer 组件引用
const chatExplorerRef = ref<InstanceType<typeof ChatExplorer> | null>(null)
@@ -79,6 +97,12 @@ defineExpose({
:time-filter="timeFilter"
:chat-type="chatType"
/>
<!-- 基础工具测试 -->
<ToolTestTab
v-else-if="activeSubTab === 'tool-test'"
class="h-full"
:session-id="props.sessionId"
/>
<!-- SQL 实验室 -->
<SQLLabTab v-else class="h-full" :session-id="props.sessionId" :chat-type="props.chatType" />
</Transition>

View File

@@ -0,0 +1,308 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const props = defineProps<{
sessionId: string
}>()
interface ToolCatalogEntry {
name: string
category: 'core' | 'analysis'
description: string
parameters: Record<string, unknown>
}
interface ParamField {
name: string
type: string
description: string
required: boolean
defaultValue?: unknown
enumValues?: string[]
isArray?: boolean
arrayItemType?: string
}
const catalog = ref<ToolCatalogEntry[]>([])
const selectedToolName = ref('')
const paramValues = ref<Record<string, string>>({})
const isExecuting = ref(false)
const currentTestId = ref<string | null>(null)
const resultJson = ref<string | null>(null)
const resultError = ref<string | null>(null)
const resultTruncated = ref(false)
const elapsed = ref<number | null>(null)
function toolLabel(name: string): string {
const key = `ai.chat.message.tools.${name}`
const translated = t(key)
return translated !== key ? translated : name
}
const coreTools = computed(() => catalog.value.filter((t) => t.category === 'core'))
const analysisTools = computed(() => catalog.value.filter((t) => t.category === 'analysis'))
const selectedTool = computed(() => catalog.value.find((t) => t.name === selectedToolName.value))
const paramFields = computed<ParamField[]>(() => {
const tool = selectedTool.value
if (!tool?.parameters) return []
const params = tool.parameters as {
properties?: Record<string, Record<string, unknown>>
required?: string[]
}
if (!params.properties) return []
const requiredSet = new Set(params.required ?? [])
return Object.entries(params.properties).map(([name, schema]) => {
const field: ParamField = {
name,
type: (schema.type as string) ?? 'string',
description: (schema.description as string) ?? '',
required: requiredSet.has(name),
defaultValue: schema.default,
}
if (schema.enum) {
field.enumValues = schema.enum as string[]
}
if (schema.type === 'array') {
field.isArray = true
const items = schema.items as Record<string, unknown> | undefined
field.arrayItemType = (items?.type as string) ?? 'string'
}
return field
})
})
watch(selectedToolName, () => {
paramValues.value = {}
resultJson.value = null
resultError.value = null
elapsed.value = null
})
onMounted(async () => {
try {
catalog.value = await window.aiApi.getToolCatalog()
if (catalog.value.length > 0) {
selectedToolName.value = catalog.value[0].name
}
} catch (e) {
console.error('Failed to load tool catalog:', e)
}
})
function buildParams(): Record<string, unknown> {
const result: Record<string, unknown> = {}
for (const field of paramFields.value) {
const raw = paramValues.value[field.name]
if (raw === undefined || raw === '') continue
if (field.type === 'number') {
const num = Number(raw)
if (!isNaN(num)) result[field.name] = num
} else if (field.isArray) {
result[field.name] = raw
.split(',')
.map((s) => s.trim())
.filter(Boolean)
} else {
result[field.name] = raw
}
}
return result
}
async function execute() {
if (!selectedToolName.value || isExecuting.value) return
isExecuting.value = true
resultJson.value = null
resultError.value = null
resultTruncated.value = false
elapsed.value = null
const testId = `tool_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
currentTestId.value = testId
try {
const params = buildParams()
const res = await window.aiApi.executeTool(testId, selectedToolName.value, params, props.sessionId)
if (res.error === 'cancelled') return
if (res.success) {
elapsed.value = res.elapsed ?? null
resultTruncated.value = !!res.truncated
resultJson.value = res.truncated
? (res.details as Record<string, unknown>)?._preview as string ?? ''
: JSON.stringify(res.details ?? res.content, null, 2)
} else {
resultError.value = res.error ?? 'Unknown error'
}
} catch (e) {
resultError.value = String(e)
} finally {
isExecuting.value = false
currentTestId.value = null
}
}
async function cancel() {
if (currentTestId.value) {
await window.aiApi.cancelToolTest(currentTestId.value)
isExecuting.value = false
currentTestId.value = null
}
}
</script>
<template>
<div class="flex h-full">
<!-- Left Sidebar: Tool List -->
<div class="w-56 shrink-0 overflow-y-auto border-r border-gray-200 bg-gray-50/50 dark:border-gray-700 dark:bg-gray-800/50">
<!-- Core Tools Group -->
<div v-if="coreTools.length > 0" class="py-2">
<div class="px-3 py-1.5 text-[11px] font-semibold tracking-wide text-gray-400 uppercase dark:text-gray-500">
{{ t('ai.lab.toolTest.coreTools') }}
</div>
<button
v-for="tool in coreTools"
:key="tool.name"
class="flex w-full flex-col px-3 py-1.5 text-left transition-colors"
:class="[
selectedToolName === tool.name
? 'bg-primary-50 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300'
: 'text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700/50',
]"
:title="tool.description"
@click="selectedToolName = tool.name"
>
<span class="truncate text-xs font-medium">{{ toolLabel(tool.name) }}</span>
<span class="truncate text-[10px] opacity-50">{{ tool.name }}</span>
</button>
</div>
<!-- Analysis Tools Group -->
<div v-if="analysisTools.length > 0" class="border-t border-gray-200 py-2 dark:border-gray-700">
<div class="px-3 py-1.5 text-[11px] font-semibold tracking-wide text-gray-400 uppercase dark:text-gray-500">
{{ t('ai.lab.toolTest.analysisTools') }}
</div>
<button
v-for="tool in analysisTools"
:key="tool.name"
class="flex w-full flex-col px-3 py-1.5 text-left transition-colors"
:class="[
selectedToolName === tool.name
? 'bg-primary-50 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300'
: 'text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700/50',
]"
:title="tool.description"
@click="selectedToolName = tool.name"
>
<span class="truncate text-xs font-medium">{{ toolLabel(tool.name) }}</span>
<span class="truncate text-[10px] opacity-50">{{ tool.name }}</span>
</button>
</div>
</div>
<!-- Right Content: Parameters + Result -->
<div class="flex flex-1 flex-col gap-4 overflow-y-auto p-4">
<!-- Tool Description -->
<div v-if="selectedTool" class="rounded-lg border border-primary-200 bg-primary-50/50 px-4 py-3 dark:border-primary-800 dark:bg-primary-900/20">
<div class="text-sm font-medium text-primary-700 dark:text-primary-300">
{{ toolLabel(selectedTool.name) }}
</div>
<div class="mt-1 text-xs text-primary-600/70 dark:text-primary-400/70">
{{ selectedTool.description }}
</div>
</div>
<!-- Parameters Form -->
<div v-if="paramFields.length > 0" class="flex flex-col gap-3">
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('ai.lab.toolTest.parameters') }}
</h3>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
<div v-for="field in paramFields" :key="field.name" class="flex flex-col gap-1">
<label class="text-xs font-medium text-gray-600 dark:text-gray-400">
{{ field.name }}
<span v-if="field.required" class="text-red-500">*</span>
<span v-if="field.type === 'number'" class="ml-1 text-gray-400">(number)</span>
<span v-if="field.isArray" class="ml-1 text-gray-400">({{ field.arrayItemType }}[])</span>
</label>
<USelectMenu
v-if="field.enumValues"
v-model="paramValues[field.name]"
:items="field.enumValues"
:placeholder="field.description"
size="sm"
/>
<UInput
v-else
v-model="paramValues[field.name]"
:placeholder="
field.isArray
? t('ai.lab.toolTest.arrayPlaceholder')
: field.defaultValue !== undefined
? String(field.defaultValue)
: field.description
"
:type="field.type === 'number' ? 'number' : 'text'"
size="sm"
/>
</div>
</div>
</div>
<!-- Execute / Cancel Button -->
<div class="flex items-center gap-3">
<UButton
v-if="!isExecuting"
color="primary"
:disabled="!selectedToolName"
icon="i-heroicons-play"
@click="execute"
>
{{ t('ai.lab.toolTest.execute') }}
</UButton>
<UButton
v-else
color="error"
icon="i-heroicons-stop"
@click="cancel"
>
{{ t('ai.lab.toolTest.cancel') }}
</UButton>
<UIcon v-if="isExecuting" name="i-heroicons-arrow-path" class="h-4 w-4 animate-spin text-gray-400" />
<span v-if="elapsed !== null" class="text-xs text-gray-500 dark:text-gray-400">
{{ t('ai.lab.toolTest.elapsed', { ms: elapsed }) }}
</span>
</div>
<!-- Result -->
<div v-if="resultJson || resultError" class="flex-1 min-h-0">
<h3 class="mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('ai.lab.toolTest.result') }}
</h3>
<div
v-if="resultError"
class="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-800 dark:bg-red-900/20 dark:text-red-400"
>
{{ resultError }}
</div>
<template v-else>
<div
v-if="resultTruncated"
class="mb-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700 dark:border-amber-800 dark:bg-amber-900/20 dark:text-amber-400"
>
{{ t('ai.lab.toolTest.truncated') }}
</div>
<pre
class="max-h-[60vh] overflow-auto rounded-lg border border-gray-200 bg-gray-50 p-3 text-xs leading-relaxed text-gray-800 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200"
>{{ resultJson }}</pre>
</template>
</div>
</div>
</div>
</template>

View File

@@ -1,249 +0,0 @@
/**
* AI 提示词统一配置
*
* 本文件集中管理所有 AI 提示词相关的配置:
* - 内置预设定义(统一版本,不再区分群聊/私聊)
* - 默认系统提示词
* - 锁定部分说明(用于前端预览)
*
* 注意:群聊/私聊的差异化内容(如成员查询策略)由后端 agent.ts 根据运行时 chatType 自动处理。
*/
import type { PromptPreset } from '@/types/ai'
// ==================== 类型定义 ====================
export type LocaleType = 'zh-CN' | 'en-US' | 'zh-TW' | 'ja-JP'
// ==================== 国际化内容配置 ====================
const i18nContent = {
'zh-CN': {
presetName: '默认分析助手',
systemPrompt: `你是一个专业但风格轻松的聊天记录分析助手。
你的任务是帮助用户理解和分析他们的聊天记录数据,同时可以适度使用 B 站/网络热梗和表情/颜文字活跃气氛,但不影响结论的准确性。
## 回答要求
1. 基于工具返回的数据回答,不要编造信息
2. 如果数据不足以回答问题,请说明
3. 回答要简洁明了,使用 Markdown 格式
4. 可以引用具体的发言作为证据
5. 对于统计数据,可以适当总结趋势和特点
6. 可以适度加入 B 站/网络热梗、表情/颜文字(强度适中)
7. 玩梗不得影响事实准确与结论清晰,避免低俗或冒犯性表达`,
lockedSection: {
chatContext: {
group: '群聊',
private: '对话',
},
ownerNoteTemplate: (displayName: string, chatContext: string) =>
`当前用户身份:
- 用户在${chatContext}中的身份是「${displayName}
- 当用户提到"我"、"我的"时,指的就是「${displayName}
- 查询"我"的发言时,使用 sender_id 参数筛选该成员
`,
memberNote: {
group: `成员查询策略:
- 当用户提到特定群成员(如"张三说过什么"、"小明的发言"等)时,应先调用 get_members 获取成员列表
- 群成员有三种名称accountName原始昵称、groupNickname群昵称、aliases用户自定义别名
- 通过 get_members 的 search 参数可以模糊搜索这三种名称
- 找到成员后,使用其 id 字段作为 search_messages 的 sender_id 参数来获取该成员的发言`,
private: `成员查询策略:
- 私聊只有两个人,可以直接获取成员列表
- 当用户提到"对方"、"他/她"时,通过 get_members 获取另一方信息`,
},
currentDatePrefix: '当前日期是',
timeParamsTemplate: (year: number, prevYear: number) =>
`时间参数:按用户提到的精度组合 year/month/day/hour
- "10月" → year: ${year}, month: 10
- "10月1号" → year: ${year}, month: 10, day: 1
- "10月1号下午3点" → year: ${year}, month: 10, day: 1, hour: 15
未指定年份默认${year}年,若该月份未到则用${prevYear}`,
conclusion: '根据用户的问题,选择合适的工具获取数据,然后基于数据给出回答。',
},
},
'en-US': {
presetName: 'Default Analysis Assistant',
systemPrompt: `You are a professional chat analysis assistant.
Your task is to help users understand and analyze their chat records.
## Response Requirements
1. Answer based on data returned by tools, do not fabricate information
2. If data is insufficient to answer the question, explain
3. Keep answers concise and clear, use Markdown format
4. Quote specific messages as evidence when possible
5. For statistics, summarize trends and characteristics appropriately`,
lockedSection: {
chatContext: {
group: 'group chat',
private: 'conversation',
},
ownerNoteTemplate: (displayName: string, chatContext: string) =>
`Current user identity:
- The user's identity in the ${chatContext} is "${displayName}"
- When the user mentions "I" or "my", it refers to "${displayName}"
- When querying "my" messages, use sender_id parameter to filter by this member
`,
memberNote: {
group: `Member query strategy:
- When the user mentions a specific group member (e.g., "what did John say", "Mary's messages"), first call get_members to get the member list
- Group members have three name types: accountName (original nickname), groupNickname (group nickname), aliases (user-defined aliases)
- Use the search parameter of get_members to fuzzy search across all three name types
- After finding the member, use their id field as the sender_id parameter for search_messages to get their messages`,
private: `Member query strategy:
- Private chats have only two people, you can directly get the member list
- When the user mentions "the other person" or "he/she", use get_members to get the other party's information`,
},
currentDatePrefix: 'The current date is',
timeParamsTemplate: (year: number, prevYear: number) =>
`Time parameters: Combine year/month/day/hour based on user's specified precision
- "October" → year: ${year}, month: 10
- "October 1st" → year: ${year}, month: 10, day: 1
- "October 1st 3pm" → year: ${year}, month: 10, day: 1, hour: 15
Default to ${year} if year not specified, use ${prevYear} if the month hasn't arrived yet`,
conclusion:
"Based on the user's question, select appropriate tools to retrieve data, then provide an answer based on the data.",
},
},
}
// ==================== 预设 ID 常量 ====================
/** 默认预设ID */
export const DEFAULT_PRESET_ID = 'builtin-default'
/** @deprecated 使用 DEFAULT_PRESET_ID 代替 */
export const DEFAULT_GROUP_PRESET_ID = DEFAULT_PRESET_ID
/** @deprecated 使用 DEFAULT_PRESET_ID 代替 */
export const DEFAULT_PRIVATE_PRESET_ID = DEFAULT_PRESET_ID
// ==================== 默认提示词内容 ====================
/**
* 获取默认系统提示词
* @param locale 语言设置
*/
export function getDefaultSystemPrompt(locale: LocaleType = 'zh-CN'): string {
const content = i18nContent[locale] || i18nContent['zh-CN']
return content.systemPrompt
}
/** @deprecated 使用 getDefaultSystemPrompt 代替 */
export function getDefaultRoleDefinition(locale: LocaleType = 'zh-CN'): string {
return getDefaultSystemPrompt(locale)
}
/** @deprecated responseRules 已合并到 systemPrompt */
export function getDefaultResponseRules(_locale: LocaleType = 'zh-CN'): string {
return ''
}
/**
* 获取内置预设名称
* @param locale 语言设置
*/
export function getBuiltinPresetName(locale: LocaleType = 'zh-CN'): string {
const content = i18nContent[locale] || i18nContent['zh-CN']
return content.presetName
}
// ==================== 内置预设定义 ====================
/**
* 获取内置预设列表
* @param locale 语言设置
*/
export function getBuiltinPresets(locale: LocaleType = 'zh-CN'): PromptPreset[] {
const now = Date.now()
const BUILTIN_DEFAULT: PromptPreset = {
id: DEFAULT_PRESET_ID,
name: getBuiltinPresetName(locale),
systemPrompt: getDefaultSystemPrompt(locale),
isBuiltIn: true,
createdAt: now,
updatedAt: now,
}
return [BUILTIN_DEFAULT]
}
/** 所有内置预设(原始版本,用于重置)- 默认中文 */
export const BUILTIN_PRESETS: PromptPreset[] = getBuiltinPresets('zh-CN')
/**
* 获取内置预设的原始版本(用于重置)
* @param presetId 预设ID
* @param locale 语言设置
*/
export function getOriginalBuiltinPreset(presetId: string, locale: LocaleType = 'zh-CN'): PromptPreset | undefined {
const presets = getBuiltinPresets(locale)
return presets.find((p) => p.id === presetId)
}
// ==================== 锁定部分预览(仅用于前端展示) ====================
/** Owner 信息(用于前端预览) */
export interface OwnerInfoPreview {
displayName: string
}
/**
* 获取锁定部分的提示词预览
* 注意:实际执行时由主进程 agent.ts 生成,包含动态日期和差异化内容
*
* @param chatType 聊天类型(用于展示对应的成员策略)
* @param ownerInfo Owner 信息(可选,用于预览时显示)
* @param locale 语言设置
*/
export function getLockedPromptSectionPreview(
chatType: 'group' | 'private' = 'group',
ownerInfo?: OwnerInfoPreview,
locale: LocaleType = 'zh-CN'
): string {
const contentKey = locale.startsWith('zh') ? 'zh-CN' : 'en-US'
const content = i18nContent[contentKey] || i18nContent['zh-CN']
const now = new Date()
const dateLocale = locale.startsWith('zh') ? 'zh-CN' : locale === 'ja-JP' ? 'ja-JP' : 'en-US'
const currentDate = now.toLocaleDateString(dateLocale, {
year: 'numeric',
month: 'long',
day: 'numeric',
weekday: 'long',
})
const chatContext = content.lockedSection.chatContext[chatType]
const ownerNote = ownerInfo ? content.lockedSection.ownerNoteTemplate(ownerInfo.displayName, chatContext) : ''
const memberNote = content.lockedSection.memberNote[chatType]
const year = now.getFullYear()
const prevYear = year - 1
return `${content.lockedSection.currentDatePrefix} ${currentDate}
${ownerNote}
${memberNote}
${content.lockedSection.timeParamsTemplate(year, prevYear)}
${content.lockedSection.conclusion}`
}
/**
* 构建完整提示词预览(用于前端展示)
* @param systemPrompt 系统提示词
* @param chatType 聊天类型(用于展示对应的锁定部分)
* @param ownerInfo Owner 信息(可选)
* @param locale 语言设置
*/
export function buildPromptPreview(
systemPrompt: string,
chatType: 'group' | 'private' = 'group',
ownerInfo?: OwnerInfoPreview,
locale: LocaleType = 'zh-CN'
): string {
const lockedSection = getLockedPromptSectionPreview(chatType, ownerInfo, locale)
return `${systemPrompt}
${lockedSection}`
}

View File

@@ -61,7 +61,23 @@
"get_members": "Get Members",
"get_member_name_history": "Get Nickname History",
"get_conversation_between": "Get Conversation",
"get_message_context": "Get Message Context"
"get_message_context": "Get Message Context",
"search_sessions": "Search Sessions",
"get_session_messages": "Get Session Messages",
"get_session_summaries": "Get Session Summaries",
"response_time_analysis": "Response Time Analysis",
"keyword_frequency": "Keyword Frequency",
"message_type_breakdown": "Message Type Breakdown",
"peak_chat_hours_by_member": "Peak Hours by Member",
"member_activity_trend": "Member Activity Trend",
"silent_members": "Silent Members",
"reply_interaction_ranking": "Reply Interaction Ranking",
"mutual_interaction_pairs": "Mutual Interaction Pairs",
"member_message_length_stats": "Message Length Stats",
"daily_active_members": "Daily Active Members",
"conversation_initiator_stats": "Conversation Initiators",
"activity_heatmap": "Activity Heatmap",
"unanswered_messages": "Unanswered Messages"
},
"generating": "Generating response...",
"think": {
@@ -408,5 +424,21 @@
"deleteSuccess": "Skill deleted",
"deleteFailed": "Failed to delete"
}
},
"lab": {
"basicTools": "Basic Tools",
"toolTest": {
"selectTool": "Select Tool",
"selectToolPlaceholder": "Select a tool...",
"coreTools": "Core Tools",
"analysisTools": "Analysis Tools",
"parameters": "Parameters",
"arrayPlaceholder": "Separate values with commas",
"execute": "Execute",
"cancel": "Cancel",
"elapsed": "Elapsed {ms}ms",
"result": "Result",
"truncated": "Result too large, truncated. Try reducing the limit parameter for complete data."
}
}
}

View File

@@ -6,7 +6,6 @@
"aiConfig": "Chat Model",
"aiRAG": "Vector Model",
"aiPrompt": "Chat Config",
"aiPreset": "Legacy Prompts",
"aiPreprocess": "Preprocess",
"dataManage": "Data Management",
"storage": "Storage",
@@ -210,6 +209,12 @@
"title": "Context Limit",
"description": "Number of recent conversation rounds to keep (1 round = User + AI). Prevents excessive token usage."
},
"searchContext": {
"title": "Search Context Window",
"description": "Automatically include surrounding messages for each search hit to help AI understand the context. Set to 0 to disable",
"before": "Before",
"after": "After"
},
"exportFormat": {
"title": "Export Format",
"description": "File format for exporting AI conversations",
@@ -268,27 +273,6 @@
"noDescription": "No description",
"fetchingContent": "Loading content...",
"fetchError": "Failed to load content"
},
"legacyPrompt": {
"title": "Legacy Prompts",
"description": "The old prompt system no longer affects AI conversations. This section is kept only for viewing and copying legacy configs during the transition.",
"copyJson": "Copy JSON",
"emptyTitle": "No legacy prompt data found",
"emptyDescription": "There is no legacy prompt config available to view on this device.",
"activePreset": "Previously Active Preset",
"notConfigured": "Not recorded",
"customPresetCount": "Custom Presets",
"remotePresetCount": "Imported Remote Presets",
"parseError": "Failed to parse legacy prompt data, but you can still copy the raw JSON first.",
"customPresetList": "Custom Presets",
"noPromptContent": "No prompt content recorded",
"rawJsonTitle": "Raw Config JSON",
"rawJsonHint": "View and copy only",
"copySuccess": "JSON copied to clipboard",
"copyFailed": "Copy failed",
"groupOnly": "Group Only",
"privateOnly": "Private Only",
"common": "Common"
}
},
"about": {

View File

@@ -61,7 +61,23 @@
"get_members": "メンバー一覧を取得",
"get_member_name_history": "ニックネーム履歴を取得",
"get_conversation_between": "やり取り履歴を取得",
"get_message_context": "コンテキストを取得"
"get_message_context": "コンテキストを取得",
"search_sessions": "セッションを検索",
"get_session_messages": "セッションメッセージを取得",
"get_session_summaries": "セッション要約を取得",
"response_time_analysis": "応答時間分析",
"keyword_frequency": "キーワード頻度分析",
"message_type_breakdown": "メッセージ種別分布",
"peak_chat_hours_by_member": "メンバー活動時間帯",
"member_activity_trend": "メンバー活動傾向",
"silent_members": "休眠メンバー検出",
"reply_interaction_ranking": "返信インタラクションランキング",
"mutual_interaction_pairs": "頻繁なインタラクションペア",
"member_message_length_stats": "メッセージ長統計",
"daily_active_members": "日別アクティブ人数",
"conversation_initiator_stats": "話題開始者統計",
"activity_heatmap": "活動ヒートマップ",
"unanswered_messages": "未返信メッセージ"
},
"generating": "回答を作成中...",
"think": {
@@ -372,5 +388,21 @@
"active": {
"label": "手動呼び出しスキル:{name}"
}
},
"lab": {
"basicTools": "基本ツール",
"toolTest": {
"selectTool": "ツールを選択",
"selectToolPlaceholder": "ツールを選択してください...",
"coreTools": "コアツール",
"analysisTools": "分析ツール",
"parameters": "パラメータ",
"arrayPlaceholder": "カンマで区切って入力",
"execute": "実行",
"cancel": "キャンセル",
"elapsed": "所要時間 {ms}ms",
"result": "実行結果",
"truncated": "結果が大きすぎるため、切り捨てて表示しています。limit パラメータを小さくしてください。"
}
}
}

View File

@@ -6,7 +6,6 @@
"aiConfig": "チャットモデル",
"aiRAG": "埋め込みモデル",
"aiPrompt": "チャット設定",
"aiPreset": "旧版プロンプト",
"aiPreprocess": "前処理",
"dataManage": "データ管理",
"storage": "ストレージ管理",
@@ -210,6 +209,12 @@
"title": "AI コンテキスト制限",
"description": "会話ごとに保持する直近のやり取り数です1 往復 = ユーザーの質問 + AI の回答)。文脈が長くなりすぎて Token を消費するのを防ぎます"
},
"searchContext": {
"title": "検索コンテキストウィンドウ",
"description": "検索ヒット時に前後の会話コンテキストを自動的に含めることで、AI がメッセージの背景を理解しやすくなります。0 に設定するとコンテキストなし",
"before": "前",
"after": "後"
},
"exportFormat": {
"title": "会話エクスポート形式",
"description": "AI チャットをエクスポートする際のファイル形式",
@@ -268,27 +273,6 @@
"noDescription": "説明がありません",
"fetchingContent": "コンテンツを読み込み中...",
"fetchError": "コンテンツの読み込みに失敗しました"
},
"legacyPrompt": {
"title": "旧版プロンプト",
"description": "旧プロンプトシステムはすでに AI 会話の実行には使われません。移行期間中に旧設定を確認・コピーするためだけの入口です。",
"copyJson": "JSON をコピー",
"emptyTitle": "旧版プロンプトデータが見つかりません",
"emptyDescription": "この端末には確認できる旧版プロンプト設定がありません。",
"activePreset": "以前の有効プリセット",
"notConfigured": "記録なし",
"customPresetCount": "カスタムプリセット数",
"remotePresetCount": "リモート導入数",
"parseError": "旧版プロンプトデータの解析に失敗しましたが、先に生の JSON をコピーできます。",
"customPresetList": "カスタムプリセット",
"noPromptContent": "プロンプト内容の記録がありません",
"rawJsonTitle": "元の設定 JSON",
"rawJsonHint": "閲覧とコピー専用",
"copySuccess": "JSON をクリップボードにコピーしました",
"copyFailed": "コピーに失敗しました",
"groupOnly": "グループのみ",
"privateOnly": "個人のみ",
"common": "共通"
}
},
"about": {

View File

@@ -61,7 +61,23 @@
"get_members": "获取成员列表",
"get_member_name_history": "获取昵称历史",
"get_conversation_between": "获取对话记录",
"get_message_context": "获取上下文"
"get_message_context": "获取上下文",
"search_sessions": "搜索会话",
"get_session_messages": "获取会话消息",
"get_session_summaries": "获取会话摘要",
"response_time_analysis": "回复时间分析",
"keyword_frequency": "关键词频率分析",
"message_type_breakdown": "消息类型分布",
"peak_chat_hours_by_member": "成员活跃时段",
"member_activity_trend": "成员活跃趋势",
"silent_members": "沉默成员检测",
"reply_interaction_ranking": "回复互动排行",
"mutual_interaction_pairs": "互动频繁成员对",
"member_message_length_stats": "消息长度统计",
"daily_active_members": "每日活跃人数",
"conversation_initiator_stats": "话题发起者统计",
"activity_heatmap": "活跃度热力图",
"unanswered_messages": "未回复消息"
},
"generating": "正在生成回复...",
"think": {
@@ -408,5 +424,21 @@
"deleteSuccess": "技能已删除",
"deleteFailed": "删除失败"
}
},
"lab": {
"basicTools": "基础工具",
"toolTest": {
"selectTool": "选择工具",
"selectToolPlaceholder": "请选择一个工具...",
"coreTools": "核心工具",
"analysisTools": "分析工具",
"parameters": "参数",
"arrayPlaceholder": "多个值用逗号分隔",
"execute": "执行",
"cancel": "取消",
"elapsed": "耗时 {ms}ms",
"result": "执行结果",
"truncated": "结果过大,已截断显示。建议减小 limit 参数以获取完整数据。"
}
}
}

View File

@@ -6,7 +6,6 @@
"aiConfig": "对话模型",
"aiRAG": "向量模型",
"aiPrompt": "对话配置",
"aiPreset": "旧版提示词",
"aiPreprocess": "预处理",
"dataManage": "数据管理",
"storage": "存储管理",
@@ -210,6 +209,12 @@
"title": "AI上下文限制",
"description": "每次对话保留最近的对话轮数1轮 = 用户提问 + AI回复防止上下文过长消耗 Token"
},
"searchContext": {
"title": "搜索上下文窗口",
"description": "搜索命中消息时自动携带前后的对话上下文,帮助 AI 理解消息背景。设为 0 则不携带上下文",
"before": "前",
"after": "后"
},
"exportFormat": {
"title": "对话导出格式",
"description": "导出 AI 对话时使用的文件格式",
@@ -268,27 +273,6 @@
"noDescription": "暂无描述",
"fetchingContent": "正在加载内容...",
"fetchError": "加载内容失败"
},
"legacyPrompt": {
"title": "旧版提示词",
"description": "提示词系统已不再参与 AI 对话运行。这里仅保留旧配置的查看与复制入口,方便你在过渡期手动迁移。",
"copyJson": "复制 JSON",
"emptyTitle": "未发现旧版提示词数据",
"emptyDescription": "当前设备上没有可查看的旧版提示词配置。",
"activePreset": "历史激活预设",
"notConfigured": "未记录",
"customPresetCount": "自定义预设数",
"remotePresetCount": "远程导入数",
"parseError": "旧版提示词数据解析失败,但你仍然可以先复制原始 JSON。",
"customPresetList": "自定义预设",
"noPromptContent": "未记录提示词内容",
"rawJsonTitle": "原始配置 JSON",
"rawJsonHint": "仅供查看与复制",
"copySuccess": "JSON 已复制到剪贴板",
"copyFailed": "复制失败",
"groupOnly": "仅群聊",
"privateOnly": "仅私聊",
"common": "通用"
}
},
"about": {

View File

@@ -61,7 +61,23 @@
"get_members": "取得成員列表",
"get_member_name_history": "取得暱稱歷史",
"get_conversation_between": "取得對話紀錄",
"get_message_context": "取得上下文"
"get_message_context": "取得上下文",
"search_sessions": "搜尋會話",
"get_session_messages": "取得會話訊息",
"get_session_summaries": "取得會話摘要",
"response_time_analysis": "回覆時間分析",
"keyword_frequency": "關鍵詞頻率分析",
"message_type_breakdown": "訊息類型分佈",
"peak_chat_hours_by_member": "成員活躍時段",
"member_activity_trend": "成員活躍趨勢",
"silent_members": "沉默成員偵測",
"reply_interaction_ranking": "回覆互動排行",
"mutual_interaction_pairs": "互動頻繁成員對",
"member_message_length_stats": "訊息長度統計",
"daily_active_members": "每日活躍人數",
"conversation_initiator_stats": "話題發起者統計",
"activity_heatmap": "活躍度熱力圖",
"unanswered_messages": "未回覆訊息"
},
"generating": "正在產生回覆...",
"think": {
@@ -372,5 +388,21 @@
"active": {
"label": "主動呼叫技能:{name}"
}
},
"lab": {
"basicTools": "基礎工具",
"toolTest": {
"selectTool": "選擇工具",
"selectToolPlaceholder": "請選擇一個工具...",
"coreTools": "核心工具",
"analysisTools": "分析工具",
"parameters": "參數",
"arrayPlaceholder": "多個值用逗號分隔",
"execute": "執行",
"cancel": "取消",
"elapsed": "耗時 {ms}ms",
"result": "執行結果",
"truncated": "結果過大,已截斷顯示。建議減小 limit 參數以獲取完整資料。"
}
}
}

View File

@@ -6,7 +6,6 @@
"aiConfig": "對話模型",
"aiRAG": "向量模型",
"aiPrompt": "聊天設定",
"aiPreset": "舊版提示詞",
"aiPreprocess": "前處理",
"dataManage": "資料管理",
"storage": "儲存管理",
@@ -210,6 +209,12 @@
"title": "AI 上下文限制",
"description": "每次對話只保留最近幾輪內容1 輪 = 使用者提問 + AI 回覆),避免上下文過長而消耗過多 Token"
},
"searchContext": {
"title": "搜尋上下文視窗",
"description": "搜尋命中訊息時自動攜帶前後的對話上下文,幫助 AI 理解訊息背景。設為 0 則不攜帶上下文",
"before": "前",
"after": "後"
},
"exportFormat": {
"title": "對話匯出格式",
"description": "匯出 AI 對話時使用的檔案格式",
@@ -268,27 +273,6 @@
"noDescription": "暫無描述",
"fetchingContent": "正在載入內容...",
"fetchError": "載入內容失敗"
},
"legacyPrompt": {
"title": "舊版提示詞",
"description": "提示詞系統已不再參與 AI 對話執行。這裡僅保留舊設定的查看與複製入口,方便你在過渡期手動遷移。",
"copyJson": "複製 JSON",
"emptyTitle": "未發現舊版提示詞資料",
"emptyDescription": "目前這台裝置上沒有可查看的舊版提示詞設定。",
"activePreset": "歷史啟用預設",
"notConfigured": "未記錄",
"customPresetCount": "自訂預設數",
"remotePresetCount": "遠端匯入數",
"parseError": "舊版提示詞資料解析失敗,但你仍可先複製原始 JSON。",
"customPresetList": "自訂預設",
"noPromptContent": "未記錄提示詞內容",
"rawJsonTitle": "原始設定 JSON",
"rawJsonHint": "僅供查看與複製",
"copySuccess": "JSON 已複製到剪貼簿",
"copyFailed": "複製失敗",
"groupOnly": "僅群聊",
"privateOnly": "僅私聊",
"common": "通用"
}
},
"about": {

View File

@@ -71,6 +71,24 @@ const enableAutoSkill = computed({
emit('config-changed')
},
})
const searchContextBefore = computed({
get: () => aiGlobalSettings.value.searchContextBefore ?? 3,
set: (val: number) => {
const clampedVal = Math.max(0, Math.min(20, val ?? 3))
promptStore.updateAIGlobalSettings({ searchContextBefore: clampedVal })
emit('config-changed')
},
})
const searchContextAfter = computed({
get: () => aiGlobalSettings.value.searchContextAfter ?? 3,
set: (val: number) => {
const clampedVal = Math.max(0, Math.min(20, val ?? 3))
promptStore.updateAIGlobalSettings({ searchContextAfter: clampedVal })
emit('config-changed')
},
})
</script>
<template>
@@ -107,6 +125,32 @@ const enableAutoSkill = computed({
</div>
<UInputNumber v-model="globalMaxHistoryRounds" :min="1" :max="50" class="w-30" />
</div>
<!-- 搜索上下文窗口 -->
<div>
<div class="mb-2">
<p class="text-sm font-medium text-gray-900 dark:text-white">
{{ t('settings.aiPrompt.searchContext.title') }}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('settings.aiPrompt.searchContext.description') }}
</p>
</div>
<div class="flex items-center gap-4">
<div class="flex items-center gap-2">
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ t('settings.aiPrompt.searchContext.before') }}
</span>
<UInputNumber v-model="searchContextBefore" :min="0" :max="20" class="w-24" />
</div>
<div class="flex items-center gap-2">
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ t('settings.aiPrompt.searchContext.after') }}
</span>
<UInputNumber v-model="searchContextAfter" :min="0" :max="20" class="w-24" />
</div>
</div>
</div>
</div>
</div>

View File

@@ -1,206 +0,0 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useToast } from '@/composables/useToast'
interface LegacyPromptStoreData {
customPromptPresets?: Array<{
id?: string
name?: string
systemPrompt?: string
applicableTo?: 'common' | 'group' | 'private'
}>
builtinPresetOverrides?: Record<string, { name?: string; systemPrompt?: string }>
fetchedRemotePresetIds?: string[]
aiPromptSettings?: { activePresetId?: string }
activeGroupPresetId?: string
activePrivatePresetId?: string
}
const { t } = useI18n()
const toast = useToast()
const rawPromptStore = ref<LegacyPromptStoreData | null>(null)
const rawPromptText = ref('')
const parseError = ref('')
/**
* 旧版提示词已经不再参与运行,这里只保留原始数据查看与复制能力。
*/
function loadLegacyPromptStore() {
const raw = localStorage.getItem('prompt')
rawPromptText.value = raw || ''
parseError.value = ''
rawPromptStore.value = null
if (!raw) return
try {
rawPromptStore.value = JSON.parse(raw) as LegacyPromptStoreData
} catch (error) {
parseError.value = String(error)
}
}
const hasLegacyPromptStore = computed(() => rawPromptText.value.trim().length > 0)
const customPromptPresets = computed(() => {
return Array.isArray(rawPromptStore.value?.customPromptPresets) ? rawPromptStore.value!.customPromptPresets : []
})
const remotePresetIds = computed(() => {
return Array.isArray(rawPromptStore.value?.fetchedRemotePresetIds) ? rawPromptStore.value!.fetchedRemotePresetIds : []
})
const activePresetId = computed(() => {
const settings = rawPromptStore.value?.aiPromptSettings
return (
settings?.activePresetId ||
rawPromptStore.value?.activeGroupPresetId ||
rawPromptStore.value?.activePrivatePresetId ||
''
)
})
const formattedPromptStoreJson = computed(() => {
if (!rawPromptText.value) return ''
if (!rawPromptStore.value) return rawPromptText.value
return JSON.stringify(rawPromptStore.value, null, 2)
})
function getApplicableLabel(applicableTo?: 'common' | 'group' | 'private'): string {
if (applicableTo === 'group') return t('settings.aiPrompt.legacyPrompt.groupOnly')
if (applicableTo === 'private') return t('settings.aiPrompt.legacyPrompt.privateOnly')
return t('settings.aiPrompt.legacyPrompt.common')
}
async function handleCopyJson() {
if (!formattedPromptStoreJson.value) return
try {
await navigator.clipboard.writeText(formattedPromptStoreJson.value)
toast.success(t('settings.aiPrompt.legacyPrompt.copySuccess'))
} catch (error) {
toast.fail(t('settings.aiPrompt.legacyPrompt.copyFailed'), { description: String(error) })
}
}
onMounted(() => {
loadLegacyPromptStore()
})
</script>
<template>
<div class="space-y-6">
<div class="rounded-xl border border-amber-200 bg-amber-50/80 p-4 dark:border-amber-900/60 dark:bg-amber-950/20">
<div class="flex items-start gap-3">
<div
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-amber-100 text-amber-600 dark:bg-amber-900/40 dark:text-amber-300"
>
<UIcon name="i-heroicons-archive-box" class="h-4.5 w-4.5" />
</div>
<div class="min-w-0">
<h4 class="text-sm font-semibold text-amber-900 dark:text-amber-100">
{{ t('settings.aiPrompt.legacyPrompt.title') }}
</h4>
<p class="mt-1 text-sm leading-6 text-amber-800 dark:text-amber-200">
{{ t('settings.aiPrompt.legacyPrompt.description') }}
</p>
</div>
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<UButton color="primary" size="xs" :disabled="!hasLegacyPromptStore" @click="handleCopyJson">
<UIcon name="i-heroicons-document-duplicate" class="mr-1 h-3.5 w-3.5" />
{{ t('settings.aiPrompt.legacyPrompt.copyJson') }}
</UButton>
</div>
<div
v-if="!hasLegacyPromptStore"
class="rounded-xl border border-gray-200 bg-gray-50 p-5 dark:border-gray-700 dark:bg-gray-800/50"
>
<p class="text-sm font-medium text-gray-700 dark:text-gray-200">
{{ t('settings.aiPrompt.legacyPrompt.emptyTitle') }}
</p>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t('settings.aiPrompt.legacyPrompt.emptyDescription') }}
</p>
</div>
<template v-else>
<!-- 这里只保留最必要的旧数据摘要避免遗留信息继续分散注意力 -->
<div class="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
<div class="rounded-xl border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-900">
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('settings.aiPrompt.legacyPrompt.activePreset') }}</p>
<p class="mt-2 text-sm font-medium text-gray-900 dark:text-white">
{{ activePresetId || t('settings.aiPrompt.legacyPrompt.notConfigured') }}
</p>
</div>
<div class="rounded-xl border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-900">
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('settings.aiPrompt.legacyPrompt.customPresetCount') }}
</p>
<p class="mt-2 text-sm font-medium text-gray-900 dark:text-white">{{ customPromptPresets.length }}</p>
</div>
<div class="rounded-xl border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-900">
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('settings.aiPrompt.legacyPrompt.remotePresetCount') }}
</p>
<p class="mt-2 text-sm font-medium text-gray-900 dark:text-white">{{ remotePresetIds.length }}</p>
</div>
</div>
<div
v-if="parseError"
class="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-900/60 dark:bg-red-950/20"
>
<p class="text-sm font-medium text-red-700 dark:text-red-300">
{{ t('settings.aiPrompt.legacyPrompt.parseError') }}
</p>
<p class="mt-1 break-all text-xs text-red-600 dark:text-red-400">{{ parseError }}</p>
</div>
<div v-if="customPromptPresets.length > 0" class="space-y-3">
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">
{{ t('settings.aiPrompt.legacyPrompt.customPresetList') }}
</h4>
<div class="space-y-2">
<div
v-for="preset in customPromptPresets"
:key="preset.id || preset.name || preset.systemPrompt"
class="rounded-xl border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-900"
>
<div class="flex items-center gap-2">
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ preset.name || '-' }}</p>
<UBadge color="gray" variant="soft" size="xs">
{{ getApplicableLabel(preset.applicableTo) }}
</UBadge>
</div>
<p class="mt-2 line-clamp-3 whitespace-pre-wrap text-xs leading-6 text-gray-500 dark:text-gray-400">
{{ preset.systemPrompt || t('settings.aiPrompt.legacyPrompt.noPromptContent') }}
</p>
</div>
</div>
</div>
<div class="space-y-3">
<div class="flex items-center justify-between">
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">
{{ t('settings.aiPrompt.legacyPrompt.rawJsonTitle') }}
</h4>
<span class="text-xs text-gray-400 dark:text-gray-500">
{{ t('settings.aiPrompt.legacyPrompt.rawJsonHint') }}
</span>
</div>
<div class="rounded-xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/70">
<pre
class="max-h-[360px] overflow-auto whitespace-pre-wrap break-all text-xs leading-6 text-gray-600 dark:text-gray-300"
>{{ formattedPromptStoreJson }}</pre
>
</div>
</div>
</template>
</div>
</template>

View File

@@ -3,7 +3,6 @@ import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import AIModelConfigTab from './AI/AIModelConfigTab.vue'
import AIPromptConfigTab from './AI/AIPromptConfigTab.vue'
import AIPromptPresetTab from './AI/AIPromptPresetTab.vue'
import AIPreprocessTab from './AI/AIPreprocessTab.vue'
// TODO: 向量模型暂时隐藏,待功能完善后恢复
// import RAGConfigTab from './AI/RAGConfigTab.vue'
@@ -24,7 +23,6 @@ const navItems = computed(() => [
// { id: 'rag', label: t('settings.tabs.aiRAG') },
{ id: 'chat', label: t('settings.tabs.aiPrompt') },
{ id: 'preprocess', label: t('settings.tabs.aiPreprocess') },
{ id: 'preset', label: t('settings.tabs.aiPreset') },
])
// 使用二级导航滚动联动 composable
@@ -91,14 +89,6 @@ void aiModelConfigRef.value
<div :ref="(el) => setSectionRef('preprocess', el as HTMLElement)">
<AIPreprocessTab />
</div>
<!-- 分隔线 -->
<div class="border-t border-gray-200 dark:border-gray-700" />
<!-- 旧版提示词查看 -->
<div :ref="(el) => setSectionRef('preset', el as HTMLElement)">
<AIPromptPresetTab />
</div>
</div>
</div>
</div>

View File

@@ -804,6 +804,8 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
: undefined,
mentionedMembers: currentMentionedMembers.length > 0 ? currentMentionedMembers : undefined,
preprocessConfig: serializablePreprocessConfig,
searchContextBefore: aiGlobalSettings.value.searchContextBefore,
searchContextAfter: aiGlobalSettings.value.searchContextAfter,
}
const { requestId: agentReqId, promise: agentPromise } = window.agentApi.runStream(

View File

@@ -1,44 +1,13 @@
import { defineStore, storeToRefs } from 'pinia'
import { ref, computed } from 'vue'
import type { PromptPreset, AIPromptSettings } from '@/types/ai'
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { KeywordTemplate } from '@/types/analysis'
import { DEFAULT_PRESET_ID, getBuiltinPresets, getOriginalBuiltinPreset, type LocaleType } from '@/config/prompts'
import { useSettingsStore } from './settings'
// 远程预设配置 URL 基础地址
const REMOTE_PRESET_BASE_URL = 'https://chatlab.fun'
/**
* 远程预设的原始数据结构(从 JSON 获取)
*/
export interface RemotePresetData {
id: string
name: string
/** Markdown 文件绝对路径(如 /cn/system-prompt/xxx.md */
path: string
/** 简短描述(索引中提供,用于列表展示) */
description?: string
/** 系统提示词(从 Markdown 文件解析后填充) */
systemPrompt?: string
/** 适用场景common(通用)、group(仅群聊)、private(仅私聊) */
chatType?: 'common' | 'group' | 'private'
}
/**
* AI 配置、提示词和关键词模板相关的全局状态
* AI 配置与关键词模板相关的全局状态
*/
export const usePromptStore = defineStore(
'prompt',
() => {
// 获取当前语言设置
const settingsStore = useSettingsStore()
const { locale } = storeToRefs(settingsStore)
const customPromptPresets = ref<PromptPreset[]>([])
const builtinPresetOverrides = ref<Record<string, { name?: string; systemPrompt?: string; updatedAt?: number }>>({})
const aiPromptSettings = ref<AIPromptSettings>({
activePresetId: DEFAULT_PRESET_ID,
})
const aiConfigVersion = ref(0)
const aiGlobalSettings = ref({
maxMessagesPerRequest: 1000,
@@ -46,47 +15,11 @@ export const usePromptStore = defineStore(
exportFormat: 'markdown' as 'markdown' | 'txt',
sqlExportFormat: 'csv' as 'csv' | 'json',
enableAutoSkill: true,
searchContextBefore: 2,
searchContextAfter: 2,
})
const customKeywordTemplates = ref<KeywordTemplate[]>([])
const deletedPresetTemplateIds = ref<string[]>([])
/** 已同步的远程预设 ID 列表(避免重复添加) */
const fetchedRemotePresetIds = ref<string[]>([])
/** 当前语言的内置预设列表(响应式) */
const builtinPresets = computed(() => getBuiltinPresets(locale.value as LocaleType))
/** 获取所有提示词预设(内置 + 覆盖 + 自定义) */
const allPromptPresets = computed(() => {
const mergedBuiltins = builtinPresets.value.map((preset) => {
const override = builtinPresetOverrides.value[preset.id]
if (override) {
return { ...preset, ...override }
}
return preset
})
return [...mergedBuiltins, ...customPromptPresets.value]
})
/** 当前激活的预设 */
const activePreset = computed(() => {
const preset = allPromptPresets.value.find((p) => p.id === aiPromptSettings.value.activePresetId)
return preset || builtinPresets.value.find((p) => p.id === DEFAULT_PRESET_ID)!
})
/**
* 获取适用于指定聊天类型的预设列表
* @param chatType 聊天类型
*/
function getPresetsForChatType(chatType: 'group' | 'private'): PromptPreset[] {
return allPromptPresets.value.filter((preset) => {
// 内置预设始终适用
if (preset.isBuiltIn) return true
// 未设置 applicableTo 或 common 适用于所有类型
if (!preset.applicableTo || preset.applicableTo === 'common') return true
// 检查是否匹配当前类型
return preset.applicableTo === chatType
})
}
/**
* 通知外部 AI 配置已经被修改
@@ -105,6 +38,8 @@ export const usePromptStore = defineStore(
exportFormat: 'markdown' | 'txt'
sqlExportFormat: 'csv' | 'json'
enableAutoSkill: boolean
searchContextBefore: number
searchContextAfter: number
}>
) {
aiGlobalSettings.value = { ...aiGlobalSettings.value, ...settings }
@@ -150,306 +85,12 @@ export const usePromptStore = defineStore(
}
}
/**
* 添加新的提示词预设
*/
function addPromptPreset(preset: {
name: string
systemPrompt: string
applicableTo?: 'common' | 'group' | 'private'
}) {
const newPreset: PromptPreset = {
id: `custom-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
name: preset.name,
systemPrompt: preset.systemPrompt,
isBuiltIn: false,
applicableTo: preset.applicableTo || 'common',
createdAt: Date.now(),
updatedAt: Date.now(),
}
customPromptPresets.value.push(newPreset)
return newPreset.id
}
/**
* 更新提示词预设(含内置覆盖)
*/
function updatePromptPreset(
presetId: string,
updates: {
name?: string
systemPrompt?: string
applicableTo?: 'common' | 'group' | 'private'
}
) {
const isBuiltin = builtinPresets.value.some((p) => p.id === presetId)
if (isBuiltin) {
builtinPresetOverrides.value[presetId] = {
...builtinPresetOverrides.value[presetId],
name: updates.name,
systemPrompt: updates.systemPrompt,
updatedAt: Date.now(),
}
return
}
const index = customPromptPresets.value.findIndex((p) => p.id === presetId)
if (index !== -1) {
customPromptPresets.value[index] = {
...customPromptPresets.value[index],
...updates,
updatedAt: Date.now(),
}
}
}
/**
* 重置内置预设为初始状态
*/
function resetBuiltinPreset(presetId: string): boolean {
const original = getOriginalBuiltinPreset(presetId, locale.value as LocaleType)
if (!original) return false
delete builtinPresetOverrides.value[presetId]
return true
}
/**
* 判断内置预设是否被自定义过
*/
function isBuiltinPresetModified(presetId: string): boolean {
return !!builtinPresetOverrides.value[presetId]
}
/**
* 删除提示词预设(自定义)
*/
function removePromptPreset(presetId: string) {
const index = customPromptPresets.value.findIndex((p) => p.id === presetId)
if (index !== -1) {
customPromptPresets.value.splice(index, 1)
// 如果删除的是当前激活的预设,切换回默认
if (aiPromptSettings.value.activePresetId === presetId) {
aiPromptSettings.value.activePresetId = DEFAULT_PRESET_ID
}
// 如果是从远程导入的预设,同时从已导入列表中移除,以便用户可以重新导入
const remoteIndex = fetchedRemotePresetIds.value.indexOf(presetId)
if (remoteIndex !== -1) {
fetchedRemotePresetIds.value.splice(remoteIndex, 1)
}
}
}
/**
* 复制指定提示词预设
*/
function duplicatePromptPreset(presetId: string) {
const source = allPromptPresets.value.find((p) => p.id === presetId)
if (source) {
const copySuffix = locale.value.startsWith('zh') ? '(副本)' : '(Copy)'
return addPromptPreset({
name: `${source.name} ${copySuffix}`,
systemPrompt: source.systemPrompt,
})
}
return null
}
/**
* 设置当前激活的预设
*/
function setActivePreset(presetId: string) {
const preset = allPromptPresets.value.find((p) => p.id === presetId)
if (preset) {
aiPromptSettings.value.activePresetId = presetId
notifyAIConfigChanged()
}
}
/**
* 获取当前激活的预设
* @param _chatType 已弃用,保留参数兼容旧代码
*/
function getActivePresetForChatType(_chatType?: 'group' | 'private'): PromptPreset {
return activePreset.value
}
/**
* 解析 Markdown 文件内容为完整的系统提示词
* 旧格式使用 `---` 分隔角色定义和回答要求,现统一为单一字段
*/
function parseMarkdownContent(content: string): { systemPrompt: string } {
return { systemPrompt: content.trim() }
}
/**
* 从远程获取预设索引列表(不下载 Markdown 内容,节省流量)
* @param locale 当前语言设置 (如 'zh-CN', 'en-US')
* @returns 远程预设索引列表,获取失败返回空数组
*/
async function fetchRemotePresets(locale: string): Promise<RemotePresetData[]> {
const langPathMap: Record<string, string> = { 'zh-CN': 'cn', 'zh-TW': 'tw', 'en-US': 'en', 'ja-JP': 'ja' }
const langPath = langPathMap[locale] ?? 'en'
const indexUrl = `${REMOTE_PRESET_BASE_URL}/${langPath}/system-prompt.json`
try {
const result = await window.api.app.fetchRemoteConfig(indexUrl)
if (!result.success || !result.data) {
return []
}
const presetIndex = result.data as RemotePresetData[]
if (!Array.isArray(presetIndex)) {
return []
}
// 过滤有效的索引项(必须有 id、name、path
return presetIndex.filter((p) => p.id && p.name && p.path)
} catch {
return []
}
}
/**
* 按需下载单个预设的 Markdown 内容
* @param preset 预设索引数据
* @returns 包含完整内容的预设数据,失败返回 null
*/
async function fetchPresetContent(
preset: RemotePresetData
): Promise<(RemotePresetData & { systemPrompt: string }) | null> {
if (preset.systemPrompt) {
return preset as RemotePresetData & { systemPrompt: string }
}
const mdUrl = `${REMOTE_PRESET_BASE_URL}${preset.path}`
try {
const mdResult = await window.api.app.fetchRemoteConfig(mdUrl)
if (!mdResult.success || typeof mdResult.data !== 'string') {
return null
}
const { systemPrompt } = parseMarkdownContent(mdResult.data)
if (!systemPrompt) {
return null
}
return { ...preset, systemPrompt }
} catch {
return null
}
}
/**
* 添加远程预设到自定义预设列表
* @param preset 远程预设数据
* @returns 是否添加成功
*/
function addRemotePreset(preset: RemotePresetData): boolean {
if (fetchedRemotePresetIds.value.includes(preset.id)) {
return false
}
const now = Date.now()
const applicableTo = preset.chatType || 'common'
const newPreset: PromptPreset = {
id: preset.id,
name: preset.name,
systemPrompt: preset.systemPrompt || '',
isBuiltIn: false,
applicableTo,
createdAt: now,
updatedAt: now,
}
customPromptPresets.value.push(newPreset)
fetchedRemotePresetIds.value.push(preset.id)
return true
}
/**
* 判断远程预设是否已添加
* @param presetId 预设 ID
*/
function isRemotePresetAdded(presetId: string): boolean {
return fetchedRemotePresetIds.value.includes(presetId)
}
// ==================== 数据迁移(兼容旧版本) ====================
/**
* 迁移旧版本的预设数据
* 将群聊/私聊分离的预设合并为统一预设
*/
function migrateOldPresets() {
// 检查是否存在旧版本数据结构
const oldSettings = aiPromptSettings.value as unknown as {
activeGroupPresetId?: string
activePrivatePresetId?: string
activePresetId?: string
}
// 如果存在旧字段,进行迁移
if (oldSettings.activeGroupPresetId && !oldSettings.activePresetId) {
const oldGroupId = oldSettings.activeGroupPresetId
if (oldGroupId === 'builtin-group-default' || oldGroupId === 'builtin-private-default') {
aiPromptSettings.value.activePresetId = DEFAULT_PRESET_ID
} else {
aiPromptSettings.value.activePresetId = oldGroupId
}
delete (aiPromptSettings.value as Record<string, unknown>).activeGroupPresetId
delete (aiPromptSettings.value as Record<string, unknown>).activePrivatePresetId
}
for (const preset of customPromptPresets.value) {
const oldPreset = preset as PromptPreset & { chatType?: string }
if (oldPreset.chatType) {
delete oldPreset.chatType
}
}
// 迁移旧 roleDefinition + responseRules → systemPrompt
for (const preset of customPromptPresets.value) {
const legacy = preset as unknown as { roleDefinition?: string; responseRules?: string; systemPrompt?: string }
if (legacy.roleDefinition && !legacy.systemPrompt) {
preset.systemPrompt = legacy.responseRules
? `${legacy.roleDefinition}\n\n## 回答要求\n${legacy.responseRules}`
: legacy.roleDefinition
delete (preset as Record<string, unknown>).roleDefinition
delete (preset as Record<string, unknown>).responseRules
}
}
// 迁移 builtinPresetOverrides 中的旧字段
for (const [id, override] of Object.entries(builtinPresetOverrides.value)) {
const legacy = override as unknown as { roleDefinition?: string; responseRules?: string; systemPrompt?: string }
if (legacy.roleDefinition && !legacy.systemPrompt) {
override.systemPrompt = legacy.responseRules
? `${legacy.roleDefinition}\n\n## 回答要求\n${legacy.responseRules}`
: legacy.roleDefinition
delete (override as Record<string, unknown>).roleDefinition
delete (override as Record<string, unknown>).responseRules
builtinPresetOverrides.value[id] = override
}
}
}
// 初始化时执行迁移
migrateOldPresets()
return {
// state
customPromptPresets,
builtinPresetOverrides,
aiPromptSettings,
aiConfigVersion,
aiGlobalSettings,
customKeywordTemplates,
deletedPresetTemplateIds,
fetchedRemotePresetIds,
// getters
allPromptPresets,
activePreset,
// actions
notifyAIConfigChanged,
updateAIGlobalSettings,
@@ -457,33 +98,12 @@ export const usePromptStore = defineStore(
updateCustomKeywordTemplate,
removeCustomKeywordTemplate,
addDeletedPresetTemplateId,
addPromptPreset,
updatePromptPreset,
resetBuiltinPreset,
isBuiltinPresetModified,
removePromptPreset,
duplicatePromptPreset,
setActivePreset,
getActivePresetForChatType,
getPresetsForChatType,
fetchRemotePresets,
fetchPresetContent,
addRemotePreset,
isRemotePresetAdded,
}
},
{
persist: [
{
pick: [
'customKeywordTemplates',
'deletedPresetTemplateIds',
'aiGlobalSettings',
'customPromptPresets',
'builtinPresetOverrides',
'aiPromptSettings',
'fetchedRemotePresetIds',
],
pick: ['customKeywordTemplates', 'deletedPresetTemplateIds', 'aiGlobalSettings'],
storage: localStorage,
},
],

View File

@@ -1,48 +0,0 @@
/**
* ChatLab AI 相关类型定义
* 包含提示词预设、AI 配置
*/
// ==================== AI 提示词预设 ====================
/**
* 预设适用的聊天类型
* - 'group': 仅群聊
* - 'private': 仅私聊
* - 'common': 通用(群聊和私聊都适用)
*/
export type PresetApplicableType = 'group' | 'private' | 'common'
/**
* AI 提示词预设
*
* applicableTo 表示预设适用的场景:
* - 'common' 表示群聊和私聊都适用(默认)
* - 'group' 表示仅群聊
* - 'private' 表示仅私聊
*
* 后端会根据运行时的 chatType 自动处理差异化内容(如成员查询策略)。
*/
export interface PromptPreset {
id: string
name: string // 预设名称
systemPrompt: string // 系统提示词(角色定义 + 回答要求,统一为单一字段)
isBuiltIn: boolean // 是否内置(内置不可删除)
applicableTo?: PresetApplicableType // 适用场景,默认 'common'
createdAt: number
updatedAt: number
}
/**
* AI 提示词配置(激活的预设)
*/
export interface AIPromptSettings {
activePresetId: string // 当前激活的预设ID
}
// ==================== 兼容旧版本 ====================
/**
* @deprecated 使用 PresetApplicableType 代替
*/
export type PromptPresetChatType = 'group' | 'private'

View File

@@ -10,6 +10,7 @@
],
"compilerOptions": {
"composite": true,
"types": ["electron-vite/node"]
"types": ["electron-vite/node"],
"moduleResolution": "bundler"
}
}