5 Commits

Author SHA1 Message Date
digua
c65b5238cc chore: 支持Linux打包 2026-04-07 22:28:17 +08:00
digua
e3d72bb5da docs: update 2026-04-07 22:18:15 +08:00
digua
2306b3dfd7 feat: 增加查询缓存以加速访问 2026-04-07 22:14:54 +08:00
digua
9b8595925d fix: 补全工具调用显示名称的 i18n 翻译 2026-04-07 22:07:06 +08:00
digua
fcca5773ca feat: 搜索工具自动携带上下文消息 2026-04-07 22:06:51 +08:00
27 changed files with 474 additions and 36 deletions

View File

@@ -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: '20'
- 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

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

View File

@@ -33,11 +33,27 @@ 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,27 @@ 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

@@ -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,8 @@ 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 +220,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 +263,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

@@ -22,6 +22,7 @@
"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:linux": "npm 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",

View File

@@ -61,7 +61,12 @@
"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"
},
"generating": "Generating response...",
"think": {

View File

@@ -210,6 +210,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",

View File

@@ -61,7 +61,12 @@
"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": "キーワード頻度分析"
},
"generating": "回答を作成中...",
"think": {

View File

@@ -210,6 +210,12 @@
"title": "AI コンテキスト制限",
"description": "会話ごとに保持する直近のやり取り数です1 往復 = ユーザーの質問 + AI の回答)。文脈が長くなりすぎて Token を消費するのを防ぎます"
},
"searchContext": {
"title": "検索コンテキストウィンドウ",
"description": "検索ヒット時に前後の会話コンテキストを自動的に含めることで、AI がメッセージの背景を理解しやすくなります。0 に設定するとコンテキストなし",
"before": "前",
"after": "後"
},
"exportFormat": {
"title": "会話エクスポート形式",
"description": "AI チャットをエクスポートする際のファイル形式",

View File

@@ -61,7 +61,12 @@
"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": "关键词频率分析"
},
"generating": "正在生成回复...",
"think": {

View File

@@ -210,6 +210,12 @@
"title": "AI上下文限制",
"description": "每次对话保留最近的对话轮数1轮 = 用户提问 + AI回复防止上下文过长消耗 Token"
},
"searchContext": {
"title": "搜索上下文窗口",
"description": "搜索命中消息时自动携带前后的对话上下文,帮助 AI 理解消息背景。设为 0 则不携带上下文",
"before": "前",
"after": "后"
},
"exportFormat": {
"title": "对话导出格式",
"description": "导出 AI 对话时使用的文件格式",

View File

@@ -61,7 +61,12 @@
"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": "關鍵詞頻率分析"
},
"generating": "正在產生回覆...",
"think": {

View File

@@ -210,6 +210,12 @@
"title": "AI 上下文限制",
"description": "每次對話只保留最近幾輪內容1 輪 = 使用者提問 + AI 回覆),避免上下文過長而消耗過多 Token"
},
"searchContext": {
"title": "搜尋上下文視窗",
"description": "搜尋命中訊息時自動攜帶前後的對話上下文,幫助 AI 理解訊息背景。設為 0 則不攜帶上下文",
"before": "前",
"after": "後"
},
"exportFormat": {
"title": "對話匯出格式",
"description": "匯出 AI 對話時使用的檔案格式",

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

@@ -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

@@ -46,6 +46,8 @@ 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[]>([])
@@ -105,6 +107,8 @@ export const usePromptStore = defineStore(
exportFormat: 'markdown' | 'txt'
sqlExportFormat: 'csv' | 'json'
enableAutoSkill: boolean
searchContextBefore: number
searchContextAfter: number
}>
) {
aiGlobalSettings.value = { ...aiGlobalSettings.value, ...settings }