From f14c18d68f19315a7063968f157df9c4b7474d0f Mon Sep 17 00:00:00 2001 From: digua Date: Sun, 25 Jan 2026 17:51:12 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E5=90=91=E9=87=8F?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E9=85=8D=E7=BD=AE=E5=92=8C=E7=9B=B8=E5=85=B3?= =?UTF-8?q?=E6=A3=80=E7=B4=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/main/ai/rag/chunking/index.ts | 8 + electron/main/ai/rag/chunking/session.ts | 390 +++++++++++++++++ electron/main/ai/rag/chunking/types.ts | 84 ++++ electron/main/ai/rag/config.ts | 342 +++++++++++++++ electron/main/ai/rag/embedding/index.ts | 161 ++++++++ .../ai/rag/embedding/openai-compatible.ts | 144 +++++++ electron/main/ai/rag/embedding/types.ts | 6 + electron/main/ai/rag/index.ts | 78 ++++ electron/main/ai/rag/pipeline/index.ts | 7 + electron/main/ai/rag/pipeline/semantic.ts | 256 ++++++++++++ electron/main/ai/rag/pipeline/types.ts | 7 + electron/main/ai/rag/store/index.ts | 112 +++++ electron/main/ai/rag/store/memory.ts | 266 ++++++++++++ electron/main/ai/rag/store/sqlite.ts | 226 ++++++++++ electron/main/ai/rag/store/types.ts | 7 + electron/main/ai/rag/types.ts | 391 ++++++++++++++++++ electron/main/ai/tools/index.ts | 13 +- electron/main/ai/tools/registry.ts | 135 ++++++ electron/main/ipc/ai.ts | 191 +++++++++ electron/preload/index.d.ts | 93 +++++ electron/preload/index.ts | 163 ++++++++ package.json | 1 + pnpm-lock.yaml | 355 ++++++++++++++++ .../settings/AI/EmbeddingConfigEditModal.vue | 304 ++++++++++++++ .../common/settings/AI/RAGConfigTab.vue | 253 ++++++++++++ .../common/settings/AISettingsTab.vue | 10 + src/composables/useAIChat.ts | 2 + src/i18n/locales/en-US/settings.json | 36 ++ src/i18n/locales/zh-CN/settings.json | 36 ++ src/stores/embedding.ts | 192 +++++++++ 30 files changed, 4268 insertions(+), 1 deletion(-) create mode 100644 electron/main/ai/rag/chunking/index.ts create mode 100644 electron/main/ai/rag/chunking/session.ts create mode 100644 electron/main/ai/rag/chunking/types.ts create mode 100644 electron/main/ai/rag/config.ts create mode 100644 electron/main/ai/rag/embedding/index.ts create mode 100644 electron/main/ai/rag/embedding/openai-compatible.ts create mode 100644 electron/main/ai/rag/embedding/types.ts create mode 100644 electron/main/ai/rag/index.ts create mode 100644 electron/main/ai/rag/pipeline/index.ts create mode 100644 electron/main/ai/rag/pipeline/semantic.ts create mode 100644 electron/main/ai/rag/pipeline/types.ts create mode 100644 electron/main/ai/rag/store/index.ts create mode 100644 electron/main/ai/rag/store/memory.ts create mode 100644 electron/main/ai/rag/store/sqlite.ts create mode 100644 electron/main/ai/rag/store/types.ts create mode 100644 electron/main/ai/rag/types.ts create mode 100644 src/components/common/settings/AI/EmbeddingConfigEditModal.vue create mode 100644 src/components/common/settings/AI/RAGConfigTab.vue create mode 100644 src/stores/embedding.ts diff --git a/electron/main/ai/rag/chunking/index.ts b/electron/main/ai/rag/chunking/index.ts new file mode 100644 index 0000000..628fbf8 --- /dev/null +++ b/electron/main/ai/rag/chunking/index.ts @@ -0,0 +1,8 @@ +/** + * 切片管理器 + */ + +export { getSessionChunks, getSessionChunk, formatSessionChunk } from './session' +export type { Chunk, ChunkMetadata, ChunkingOptions, SessionMessage, SessionInfo } from './types' +export { INVALID_MESSAGE_TYPES, INVALID_TEXT_PATTERNS } from './types' + diff --git a/electron/main/ai/rag/chunking/session.ts b/electron/main/ai/rag/chunking/session.ts new file mode 100644 index 0000000..37dc52e --- /dev/null +++ b/electron/main/ai/rag/chunking/session.ts @@ -0,0 +1,390 @@ +/** + * 会话级切片实现 + * + * 利用现有的 chat_session 表,将会话作为切片单位 + */ + +import Database from 'better-sqlite3' +import type { Chunk, ChunkMetadata } from './types' +import type { SessionMessage, SessionInfo, ChunkingOptions } from './types' +import { INVALID_MESSAGE_TYPES, INVALID_TEXT_PATTERNS } from './types' +import { aiLogger as logger } from '../../logger' + +/** + * 切片长度限制 + * 大多数 Embedding 模型的上下文限制在 8192 tokens + * 中文约 1.5-2 字符/token,英文约 4 字符/token + * 保守估计:使用 2000 字符作为单个切片的最大长度 + */ +const MAX_CHUNK_CHARS = 2000 + +/** + * 子切片重叠字符数(保持上下文连贯性) + */ +const CHUNK_OVERLAP_CHARS = 200 + +/** + * 格式化时间范围 + */ +function formatTimeRange(startTs: number, endTs: number): string { + const startDate = new Date(startTs) + const endDate = new Date(endTs) + + const formatDate = (d: Date) => { + const year = d.getFullYear() + const month = String(d.getMonth() + 1).padStart(2, '0') + const day = String(d.getDate()).padStart(2, '0') + const hour = String(d.getHours()).padStart(2, '0') + const minute = String(d.getMinutes()).padStart(2, '0') + return `${year}-${month}-${day} ${hour}:${minute}` + } + + return `${formatDate(startDate)} ~ ${formatDate(endDate)}` +} + +/** + * 过滤有效的文本消息 + * ⭐ 提高 Embedding 质量的关键步骤 + */ +function filterValidMessages(messages: SessionMessage[]): SessionMessage[] { + return messages.filter((m) => { + // 过滤无效消息类型 + if (m.type !== undefined && INVALID_MESSAGE_TYPES.includes(m.type as (typeof INVALID_MESSAGE_TYPES)[number])) { + return false + } + + // 过滤空内容 + if (!m.content || m.content.trim().length === 0) { + return false + } + + // 过滤占位符文本 + const content = m.content.trim() + for (const pattern of INVALID_TEXT_PATTERNS) { + if (content.includes(pattern)) { + return false + } + } + + return true + }) +} + +/** + * 格式化会话切片内容 + * + * 示例输出: + * "[2024-01-15 14:30 ~ 15:20] 参与者:张三、李四 + * 张三: 今天天气不错 + * 李四: 是的,很适合出门 + * 张三: 下午一起去公园?" + */ +export function formatSessionChunk( + session: SessionInfo, + messages: SessionMessage[], + filterInvalid: boolean = true +): string { + const timeRange = formatTimeRange(session.startTs, session.endTs) + + // ⭐ 过滤无效消息,只保留有语义价值的文本 + const validMessages = filterInvalid ? filterValidMessages(messages) : messages + + // 如果过滤后没有有效消息,返回空字符串 + if (validMessages.length === 0) { + return '' + } + + const participants = [...new Set(validMessages.map((m) => m.senderName))].join('、') + + const content = validMessages.map((m) => `${m.senderName}: ${m.content}`).join('\n') + + return `[${timeRange}] 参与者:${participants}\n${content}` +} + +/** + * 将超长内容拆分为多个子切片 + * + * @param content 原始内容 + * @param maxChars 单个切片最大字符数 + * @param overlapChars 重叠字符数 + * @returns 子切片数组 + */ +function splitIntoSubChunks( + content: string, + maxChars: number = MAX_CHUNK_CHARS, + overlapChars: number = CHUNK_OVERLAP_CHARS +): string[] { + if (content.length <= maxChars) { + return [content] + } + + const chunks: string[] = [] + const lines = content.split('\n') + + let currentChunk = '' + let overlapBuffer = '' // 用于保存重叠部分 + + for (const line of lines) { + // 如果单行就超过限制,需要进一步拆分 + if (line.length > maxChars) { + // 先保存当前累积的内容 + if (currentChunk) { + chunks.push(currentChunk) + // 取最后几行作为重叠 + const lines = currentChunk.split('\n') + overlapBuffer = lines.slice(-3).join('\n').slice(-overlapChars) + currentChunk = '' + } + + // 按字符拆分超长行 + let remaining = line + while (remaining.length > 0) { + const part = remaining.slice(0, maxChars - overlapBuffer.length) + chunks.push(overlapBuffer + part) + overlapBuffer = part.slice(-overlapChars) + remaining = remaining.slice(maxChars - overlapBuffer.length) + } + continue + } + + // 检查添加这行后是否会超过限制 + const newLength = currentChunk.length + (currentChunk ? 1 : 0) + line.length + + if (newLength > maxChars) { + // 保存当前切片 + if (currentChunk) { + chunks.push(currentChunk) + // 取最后几行作为重叠 + const lines = currentChunk.split('\n') + overlapBuffer = lines.slice(-3).join('\n').slice(-overlapChars) + } + // 开始新切片(带重叠) + currentChunk = overlapBuffer ? overlapBuffer + '\n' + line : line + } else { + // 继续累积 + currentChunk = currentChunk ? currentChunk + '\n' + line : line + } + } + + // 保存最后一个切片 + if (currentChunk) { + chunks.push(currentChunk) + } + + return chunks +} + +/** + * 从数据库获取会话列表 + */ +function getSessionsFromDb( + db: Database.Database, + options: ChunkingOptions +): SessionInfo[] { + const { limit = 50, timeFilter } = options + + let sql = ` + SELECT + id, + start_ts as startTs, + end_ts as endTs, + message_count as messageCount + FROM chat_session + ` + + const params: (number | undefined)[] = [] + + // 时间过滤 + if (timeFilter) { + sql += ' WHERE start_ts >= ? AND end_ts <= ?' + params.push(timeFilter.startTs, timeFilter.endTs) + } + + // 按时间倒序,取最近的会话 + sql += ' ORDER BY start_ts DESC LIMIT ?' + params.push(limit) + + const sessions = db.prepare(sql).all(...params) as SessionInfo[] + + // 按时间正序返回(便于阅读) + return sessions.reverse() +} + +/** + * 获取会话的消息 + */ +function getSessionMessagesFromDb( + db: Database.Database, + sessionId: number, + limit: number = 500 +): SessionMessage[] { + const sql = ` + SELECT + m.id, + COALESCE(mb.group_nickname, mb.account_name, mb.platform_id) as senderName, + m.content, + m.ts as timestamp, + m.type + FROM message_context mc + JOIN message m ON m.id = mc.message_id + JOIN member mb ON mb.id = m.sender_id + WHERE mc.session_id = ? + ORDER BY m.ts ASC + LIMIT ? + ` + + return db.prepare(sql).all(sessionId, limit) as SessionMessage[] +} + +/** + * 获取会话级切片 + * + * @param dbPath 数据库路径 + * @param options 切片选项 + * @returns 切片列表 + */ +export function getSessionChunks(dbPath: string, options: ChunkingOptions = {}): Chunk[] { + const { filterInvalid = true, maxChunkChars = MAX_CHUNK_CHARS } = options + + let db: Database.Database | null = null + + try { + db = new Database(dbPath, { readonly: true }) + + // 1. 获取会话列表 + const sessions = getSessionsFromDb(db, options) + + if (sessions.length === 0) { + return [] + } + + // 2. 为每个会话生成切片 + const chunks: Chunk[] = [] + + for (const session of sessions) { + // 获取会话消息 + const messages = getSessionMessagesFromDb(db, session.id) + + // 格式化切片内容 + const content = formatSessionChunk(session, messages, filterInvalid) + + // 跳过空内容的切片 + if (!content) { + continue + } + + // 计算参与者(使用过滤后的消息) + const validMessages = filterInvalid ? filterValidMessages(messages) : messages + const participants = [...new Set(validMessages.map((m) => m.senderName))] + + const baseMetadata: ChunkMetadata = { + sessionId: session.id, + startTs: session.startTs, + endTs: session.endTs, + messageCount: validMessages.length, + participants, + } + + // ⭐ 检查是否需要拆分为子切片 + if (content.length <= maxChunkChars) { + // 内容在限制内,直接添加 + chunks.push({ + id: `session_${session.id}`, + type: 'session', + content, + metadata: baseMetadata, + }) + } else { + // 内容超长,拆分为子切片 + const subChunks = splitIntoSubChunks(content, maxChunkChars) + + for (let i = 0; i < subChunks.length; i++) { + chunks.push({ + id: `session_${session.id}_part${i + 1}`, + type: 'session', + content: subChunks[i], + metadata: { + ...baseMetadata, + subChunkIndex: i, + totalSubChunks: subChunks.length, + }, + }) + } + } + } + + return chunks + } catch (error) { + logger.error('[Chunking] 获取会话切片失败:', error) + return [] + } finally { + if (db) { + db.close() + } + } +} + +/** + * 获取单个会话的切片 + */ +export function getSessionChunk(dbPath: string, sessionId: number): Chunk | null { + let db: Database.Database | null = null + + try { + db = new Database(dbPath, { readonly: true }) + + // 获取会话信息 + const session = db + .prepare( + ` + SELECT + id, + start_ts as startTs, + end_ts as endTs, + message_count as messageCount + FROM chat_session + WHERE id = ? + ` + ) + .get(sessionId) as SessionInfo | undefined + + if (!session) { + return null + } + + // 获取消息 + const messages = getSessionMessagesFromDb(db, sessionId) + + // 格式化内容 + const content = formatSessionChunk(session, messages, true) + + if (!content) { + return null + } + + // 参与者 + const validMessages = filterValidMessages(messages) + const participants = [...new Set(validMessages.map((m) => m.senderName))] + + return { + id: `session_${session.id}`, + type: 'session', + content, + metadata: { + sessionId: session.id, + startTs: session.startTs, + endTs: session.endTs, + messageCount: validMessages.length, + participants, + }, + } + } catch (error) { + logger.error('[Chunking] 获取单个会话切片失败:', error) + return null + } finally { + if (db) { + db.close() + } + } +} + diff --git a/electron/main/ai/rag/chunking/types.ts b/electron/main/ai/rag/chunking/types.ts new file mode 100644 index 0000000..8731b908 --- /dev/null +++ b/electron/main/ai/rag/chunking/types.ts @@ -0,0 +1,84 @@ +/** + * 切片相关类型定义 + * 从 ../types.ts 重导出,方便引用 + */ + +export type { Chunk, ChunkMetadata } from '../types' + +/** + * 需要过滤的无效消息类型 + * 这些消息没有语义价值,会降低 Embedding 质量 + */ +export const INVALID_MESSAGE_TYPES = [ + 0, // 系统消息 + 3, // 图片 + 4, // 语音 + 5, // 视频 + 6, // 文件 + 7, // 位置 + 8, // 名片 + 10, // 撤回消息 + 11, // 红包 + 12, // 转账 +] as const + +/** + * 需要过滤的占位符文本 + */ +export const INVALID_TEXT_PATTERNS = [ + '[图片]', + '[语音]', + '[视频]', + '[文件]', + '[表情]', + '[动画表情]', + '[位置]', + '[名片]', + '[红包]', + '[转账]', + '[撤回消息]', + '撤回了一条消息', + '你撤回了一条消息', +] + +/** + * 会话消息(从数据库查询) + */ +export interface SessionMessage { + id: number + senderName: string + content: string | null + timestamp: number + type?: number +} + +/** + * 会话基本信息 + */ +export interface SessionInfo { + id: number + startTs: number + endTs: number + messageCount: number +} + +/** + * 切片选项 + */ +export interface ChunkingOptions { + /** 最大切片数量 */ + limit?: number + + /** 时间过滤 */ + timeFilter?: { + startTs: number + endTs: number + } + + /** 是否过滤无效消息 */ + filterInvalid?: boolean + + /** 单个切片最大字符数(超过会拆分为子切片) */ + maxChunkChars?: number +} + diff --git a/electron/main/ai/rag/config.ts b/electron/main/ai/rag/config.ts new file mode 100644 index 0000000..85c8b60 --- /dev/null +++ b/electron/main/ai/rag/config.ts @@ -0,0 +1,342 @@ +/** + * RAG 配置管理 + * 支持多 Embedding 配置模式(参考 LLM 配置) + */ + +import { app } from 'electron' +import * as fs from 'fs' +import * as path from 'path' +import { randomUUID } from 'crypto' +import { + DEFAULT_RAG_CONFIG, + DEFAULT_EMBEDDING_CONFIG_STORE, + MAX_EMBEDDING_CONFIG_COUNT, + type RAGConfig, + type EmbeddingServiceConfig, + type EmbeddingConfigStore, +} from './types' +import { aiLogger as logger } from '../logger' + +// ==================== 路径管理 ==================== + +/** + * 获取 RAG 配置文件路径 + */ +function getConfigPath(): string { + const userDataPath = app.getPath('userData') + return path.join(userDataPath, 'data', 'ai', 'rag-config.json') +} + +/** + * 获取 Embedding 配置文件路径 + */ +function getEmbeddingConfigPath(): string { + const userDataPath = app.getPath('userData') + return path.join(userDataPath, 'data', 'ai', 'embedding-config.json') +} + +/** + * 获取向量存储目录 + */ +export function getVectorStoreDir(): string { + const userDataPath = app.getPath('userData') + const dir = path.join(userDataPath, 'data', 'ai', 'vectors') + + // 确保目录存在 + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }) + } + + return dir +} + +// ==================== Embedding 多配置管理 ==================== + +/** + * 加载 Embedding 配置存储 + */ +export function loadEmbeddingConfigStore(): EmbeddingConfigStore { + const configPath = getEmbeddingConfigPath() + + if (!fs.existsSync(configPath)) { + return { ...DEFAULT_EMBEDDING_CONFIG_STORE } + } + + try { + const content = fs.readFileSync(configPath, 'utf-8') + const data = JSON.parse(content) as EmbeddingConfigStore + + // 确保有默认值 + return { + configs: data.configs || [], + activeConfigId: data.activeConfigId || null, + enabled: data.enabled ?? false, + } + } catch (error) { + logger.error('RAG', '加载 Embedding 配置失败', error) + return { ...DEFAULT_EMBEDDING_CONFIG_STORE } + } +} + +/** + * 保存 Embedding 配置存储 + */ +export function saveEmbeddingConfigStore(store: EmbeddingConfigStore): void { + const configPath = getEmbeddingConfigPath() + const dir = path.dirname(configPath) + + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }) + } + + fs.writeFileSync(configPath, JSON.stringify(store, null, 2), 'utf-8') + logger.info('RAG', 'Embedding 配置已保存') +} + +/** + * 获取所有 Embedding 配置 + */ +export function getAllEmbeddingConfigs(): EmbeddingServiceConfig[] { + return loadEmbeddingConfigStore().configs +} + +/** + * 获取当前激活的 Embedding 配置 + */ +export function getActiveEmbeddingConfig(): EmbeddingServiceConfig | null { + const store = loadEmbeddingConfigStore() + if (!store.activeConfigId || !store.enabled) return null + return store.configs.find((c) => c.id === store.activeConfigId) || null +} + +/** + * 获取单个 Embedding 配置 + */ +export function getEmbeddingConfigById(id: string): EmbeddingServiceConfig | null { + const store = loadEmbeddingConfigStore() + return store.configs.find((c) => c.id === id) || null +} + +/** + * 添加新 Embedding 配置 + */ +export function addEmbeddingConfig( + config: Omit +): { + success: boolean + config?: EmbeddingServiceConfig + error?: string +} { + const store = loadEmbeddingConfigStore() + + if (store.configs.length >= MAX_EMBEDDING_CONFIG_COUNT) { + return { success: false, error: `最多只能添加 ${MAX_EMBEDDING_CONFIG_COUNT} 个配置` } + } + + const now = Date.now() + const newConfig: EmbeddingServiceConfig = { + ...config, + id: randomUUID(), + createdAt: now, + updatedAt: now, + } + + store.configs.push(newConfig) + + // 如果是第一个配置,自动设为激活 + if (store.configs.length === 1) { + store.activeConfigId = newConfig.id + } + + saveEmbeddingConfigStore(store) + logger.info('RAG', `添加 Embedding 配置: ${newConfig.name}`) + return { success: true, config: newConfig } +} + +/** + * 更新 Embedding 配置 + */ +export function updateEmbeddingConfig( + id: string, + updates: Partial> +): { success: boolean; error?: string } { + const store = loadEmbeddingConfigStore() + const index = store.configs.findIndex((c) => c.id === id) + + if (index === -1) { + return { success: false, error: '配置不存在' } + } + + store.configs[index] = { + ...store.configs[index], + ...updates, + updatedAt: Date.now(), + } + + saveEmbeddingConfigStore(store) + logger.info('RAG', `更新 Embedding 配置: ${store.configs[index].name}`) + return { success: true } +} + +/** + * 删除 Embedding 配置 + */ +export function deleteEmbeddingConfig(id: string): { success: boolean; error?: string } { + const store = loadEmbeddingConfigStore() + const index = store.configs.findIndex((c) => c.id === id) + + if (index === -1) { + return { success: false, error: '配置不存在' } + } + + const deletedName = store.configs[index].name + store.configs.splice(index, 1) + + // 如果删除的是当前激活的配置,选择第一个作为新的激活配置 + if (store.activeConfigId === id) { + store.activeConfigId = store.configs.length > 0 ? store.configs[0].id : null + } + + saveEmbeddingConfigStore(store) + logger.info('RAG', `删除 Embedding 配置: ${deletedName}`) + return { success: true } +} + +/** + * 设置激活的 Embedding 配置 + */ +export function setActiveEmbeddingConfig(id: string): { success: boolean; error?: string } { + const store = loadEmbeddingConfigStore() + const config = store.configs.find((c) => c.id === id) + + if (!config) { + return { success: false, error: '配置不存在' } + } + + store.activeConfigId = id + saveEmbeddingConfigStore(store) + logger.info('RAG', `激活 Embedding 配置: ${config.name}`) + return { success: true } +} + +/** + * 设置语义搜索启用状态 + */ +export function setEmbeddingEnabled(enabled: boolean): { success: boolean } { + const store = loadEmbeddingConfigStore() + store.enabled = enabled + saveEmbeddingConfigStore(store) + logger.info('RAG', `语义搜索 ${enabled ? '已启用' : '已禁用'}`) + return { success: true } +} + +/** + * 检查语义搜索是否启用 + */ +export function isEmbeddingEnabled(): boolean { + const store = loadEmbeddingConfigStore() + return store.enabled && store.activeConfigId !== null +} + +/** + * 获取激活配置 ID + */ +export function getActiveEmbeddingConfigId(): string | null { + return loadEmbeddingConfigStore().activeConfigId +} + +// ==================== 旧版 RAG 配置(兼容) ==================== + +/** + * 加载 RAG 配置 + */ +export function loadRAGConfig(): RAGConfig { + try { + const configPath = getConfigPath() + + if (!fs.existsSync(configPath)) { + return { ...DEFAULT_RAG_CONFIG } + } + + const content = fs.readFileSync(configPath, 'utf-8') + const config = JSON.parse(content) as RAGConfig + + // 合并默认配置,确保新字段有默认值 + return mergeConfig(DEFAULT_RAG_CONFIG, config) + } catch (error) { + logger.error('RAG', '加载配置失败', error) + return { ...DEFAULT_RAG_CONFIG } + } +} + +/** + * 保存 RAG 配置 + */ +export function saveRAGConfig(config: RAGConfig): void { + try { + const configPath = getConfigPath() + const dir = path.dirname(configPath) + + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }) + } + + fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8') + logger.info('RAG', '配置已保存') + } catch (error) { + logger.error('RAG', '保存配置失败', error) + throw error + } +} + +/** + * 更新 RAG 配置(部分更新) + */ +export function updateRAGConfig(updates: Partial): RAGConfig { + const current = loadRAGConfig() + const updated = mergeConfig(current, updates) + saveRAGConfig(updated) + return updated +} + +/** + * 深度合并配置 + */ +function mergeConfig(base: T, override: Partial): T { + const result = { ...base } + + for (const key in override) { + if (Object.prototype.hasOwnProperty.call(override, key)) { + const overrideValue = override[key] + const baseValue = base[key] + + if (overrideValue !== undefined) { + if ( + typeof overrideValue === 'object' && + overrideValue !== null && + !Array.isArray(overrideValue) && + typeof baseValue === 'object' && + baseValue !== null && + !Array.isArray(baseValue) + ) { + // 递归合并对象 + ;(result as Record)[key] = mergeConfig(baseValue as object, overrideValue as object) + } else { + // 直接覆盖 + ;(result as Record)[key] = overrideValue + } + } + } + } + + return result +} + +/** + * 重置 RAG 配置为默认值 + */ +export function resetRAGConfig(): RAGConfig { + const config = { ...DEFAULT_RAG_CONFIG } + saveRAGConfig(config) + return config +} diff --git a/electron/main/ai/rag/embedding/index.ts b/electron/main/ai/rag/embedding/index.ts new file mode 100644 index 0000000..a331ec7 --- /dev/null +++ b/electron/main/ai/rag/embedding/index.ts @@ -0,0 +1,161 @@ +/** + * Embedding 服务管理器 + * 支持多配置模式 + */ + +import type { IEmbeddingService, EmbeddingConfig, EmbeddingServiceConfig } from './types' +import { OpenAICompatibleEmbeddingService } from './openai-compatible' +import { getActiveEmbeddingConfig, isEmbeddingEnabled } from '../config' +import { aiLogger as logger } from '../../logger' +import * as llm from '../../llm' + +// 当前活动的 Embedding 服务实例 +let activeService: IEmbeddingService | null = null +let activeConfigId: string | null = null + +/** + * 获取 Embedding 服务 + */ +export async function getEmbeddingService(): Promise { + // 检查是否启用 + if (!isEmbeddingEnabled()) { + return null + } + + // 获取激活的配置 + const config = getActiveEmbeddingConfig() + if (!config) { + return null + } + + // 如果配置没变,复用现有服务 + if (activeService && activeConfigId === config.id) { + return activeService + } + + // 配置变了,重新创建服务 + if (activeService) { + await activeService.dispose() + activeService = null + } + + try { + activeService = await createEmbeddingService(config) + activeConfigId = config.id + return activeService + } catch (error) { + logger.error('RAG', '创建 Embedding 服务失败', error) + return null + } +} + +/** + * 创建 Embedding 服务实例 + */ +async function createEmbeddingService(config: EmbeddingServiceConfig): Promise { + const apiConfig = resolveApiConfig(config) + logger.info('RAG', `使用 Embedding: ${config.name} (${apiConfig.model})`) + + return new OpenAICompatibleEmbeddingService(apiConfig) +} + +/** + * 解析 API 配置 + */ +function resolveApiConfig(config: EmbeddingServiceConfig): { + baseUrl: string + apiKey?: string + model: string +} { + if (config.apiSource === 'reuse_llm') { + // 复用当前 LLM 配置 + const llmConfig = llm.getActiveConfig() + + if (!llmConfig) { + throw new Error('未找到激活的 LLM 配置,请先在「模型配置」中添加 AI 服务') + } + + // 使用 LLM 的 baseUrl(如果有) + const baseUrl = llmConfig.baseUrl || getDefaultBaseUrl(llmConfig.provider) + + return { + baseUrl, + apiKey: llmConfig.apiKey || undefined, + model: config.model, + } + } else { + // 独立配置 + if (!config.baseUrl) { + throw new Error('自定义 API 模式需要配置端点地址') + } + + return { + baseUrl: config.baseUrl, + apiKey: config.apiKey, + model: config.model, + } + } +} + +/** + * 获取提供商的默认 baseUrl + */ +function getDefaultBaseUrl(provider: string): string { + const defaultUrls: Record = { + deepseek: 'https://api.deepseek.com/v1', + qwen: 'https://dashscope.aliyuncs.com/compatible-mode/v1', + openai: 'https://api.openai.com/v1', + 'openai-compatible': 'http://localhost:11434/v1', // Ollama 默认 + } + + return defaultUrls[provider] || 'http://localhost:11434/v1' +} + +/** + * 重置 Embedding 服务 + * 配置变更后调用 + */ +export async function resetEmbeddingService(): Promise { + if (activeService) { + await activeService.dispose() + activeService = null + activeConfigId = null + logger.info('RAG', 'Embedding 服务已重置') + } +} + +/** + * 验证 Embedding 服务配置 + */ +export async function validateEmbeddingConfig( + config: EmbeddingConfig | EmbeddingServiceConfig +): Promise<{ success: boolean; error?: string }> { + try { + // 转换为 EmbeddingServiceConfig 格式 + const serviceConfig: EmbeddingServiceConfig = 'id' in config + ? config + : { + id: 'temp', + name: 'temp', + apiSource: config.apiSource || 'reuse_llm', + model: config.model || 'nomic-embed-text', + baseUrl: config.baseUrl, + apiKey: config.apiKey, + createdAt: Date.now(), + updatedAt: Date.now(), + } + + const service = await createEmbeddingService(serviceConfig) + const result = await service.validate() + await service.dispose() + return result + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + } + } +} + +// 重导出类型 +export type { IEmbeddingService, EmbeddingConfig, EmbeddingServiceConfig } diff --git a/electron/main/ai/rag/embedding/openai-compatible.ts b/electron/main/ai/rag/embedding/openai-compatible.ts new file mode 100644 index 0000000..0c0738d --- /dev/null +++ b/electron/main/ai/rag/embedding/openai-compatible.ts @@ -0,0 +1,144 @@ +/** + * OpenAI 兼容的 Embedding 服务 + * + * 支持调用 OpenAI、Ollama 等兼容 API + */ + +import type { IEmbeddingService } from './types' +import { aiLogger as logger } from '../../logger' + +/** + * OpenAI 兼容 API 配置 + */ +export interface OpenAICompatibleEmbeddingConfig { + baseUrl: string + apiKey?: string + model: string +} + +/** + * OpenAI 兼容的 Embedding 服务实现 + */ +export class OpenAICompatibleEmbeddingService implements IEmbeddingService { + private baseUrl: string + private apiKey?: string + private model: string + private dimensions: number = 0 // 首次调用时确定 + + constructor(config: OpenAICompatibleEmbeddingConfig) { + this.baseUrl = config.baseUrl.replace(/\/$/, '') // 移除末尾斜杠 + this.apiKey = config.apiKey + this.model = config.model + } + + /** + * 嵌入单条文本 + */ + async embed(text: string): Promise { + const vectors = await this.callEmbeddingApi([text]) + return vectors[0] + } + + /** + * 批量嵌入文本 + */ + async embedBatch(texts: string[]): Promise { + return this.callEmbeddingApi(texts) + } + + /** + * 调用 Embedding API + */ + private async callEmbeddingApi(input: string[]): Promise { + const url = `${this.baseUrl}/embeddings` + + const headers: Record = { + 'Content-Type': 'application/json', + } + + if (this.apiKey) { + headers['Authorization'] = `Bearer ${this.apiKey}` + } + + try { + const response = await fetch(url, { + method: 'POST', + headers, + body: JSON.stringify({ + model: this.model, + input, + }), + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`API 请求失败 (${response.status}): ${errorText}`) + } + + const data = (await response.json()) as { + data?: Array<{ embedding: number[]; index: number }> + } + + if (!data.data || data.data.length === 0) { + throw new Error('API 返回数据为空') + } + + // 按 index 排序,确保顺序正确 + const sorted = data.data.sort((a, b) => a.index - b.index) + const vectors = sorted.map((item) => item.embedding) + + // 记录维度 + if (vectors.length > 0 && this.dimensions === 0) { + this.dimensions = vectors[0].length + } + + return vectors + } catch (error) { + logger.error('RAG', `Embedding API 调用失败: ${url}`, error) + throw error + } + } + + /** + * 获取提供商名称 + */ + getProvider(): string { + return `OpenAI Compatible (${this.model})` + } + + /** + * 获取向量维度 + */ + getDimensions(): number { + return this.dimensions + } + + /** + * 验证服务可用性 + */ + async validate(): Promise<{ success: boolean; error?: string }> { + try { + // 测试一次 embedding + const testVector = await this.embed('test') + + if (testVector.length === 0) { + return { success: false, error: '返回的向量为空' } + } + + return { success: true } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + } + } + } + + /** + * 释放资源 + */ + async dispose(): Promise { + // API 服务无需释放资源 + } +} + diff --git a/electron/main/ai/rag/embedding/types.ts b/electron/main/ai/rag/embedding/types.ts new file mode 100644 index 0000000..fdc1b96 --- /dev/null +++ b/electron/main/ai/rag/embedding/types.ts @@ -0,0 +1,6 @@ +/** + * Embedding 服务类型定义 + * 从 ../types.ts 重导出,方便引用 + */ + +export type { IEmbeddingService, EmbeddingResult, EmbeddingConfig, EmbeddingServiceConfig } from '../types' diff --git a/electron/main/ai/rag/index.ts b/electron/main/ai/rag/index.ts new file mode 100644 index 0000000..5f06e88 --- /dev/null +++ b/electron/main/ai/rag/index.ts @@ -0,0 +1,78 @@ +/** + * RAG 模块主入口 + * + * 提供 RAG(检索增强生成)功能: + * - Embedding 服务(API 方式,多配置模式) + * - 会话级切片 + * - 向量存储(SQLite BLOB + 内存 LRU) + * - Semantic Pipeline + */ + +// ==================== 配置管理 ==================== + +export { + // 新版多配置管理 + loadEmbeddingConfigStore, + saveEmbeddingConfigStore, + getAllEmbeddingConfigs, + getActiveEmbeddingConfig, + getEmbeddingConfigById, + addEmbeddingConfig, + updateEmbeddingConfig, + deleteEmbeddingConfig, + setActiveEmbeddingConfig, + setEmbeddingEnabled, + isEmbeddingEnabled, + getActiveEmbeddingConfigId, + // 旧版兼容 + loadRAGConfig, + saveRAGConfig, + updateRAGConfig, + resetRAGConfig, + getVectorStoreDir, +} from './config' + +// ==================== 类型定义 ==================== + +export type { + RAGConfig, + EmbeddingConfig, + EmbeddingServiceConfig, + EmbeddingConfigStore, + VectorStoreConfig, + RerankConfig, + IEmbeddingService, + IVectorStore, + IRerankService, + Chunk, + ChunkMetadata, + VectorSearchResult, + VectorStoreStats, + SemanticPipelineOptions, + SemanticPipelineResult, +} from './types' + +export { DEFAULT_RAG_CONFIG, DEFAULT_EMBEDDING_CONFIG_STORE, MAX_EMBEDDING_CONFIG_COUNT } from './types' + +// ==================== Embedding 服务 ==================== + +export { getEmbeddingService, resetEmbeddingService, validateEmbeddingConfig } from './embedding' + +// ==================== 切片服务 ==================== + +export { getSessionChunks, getSessionChunk, formatSessionChunk } from './chunking' +export type { ChunkingOptions, SessionMessage, SessionInfo } from './chunking' + +// ==================== 向量存储 ==================== + +export { + getVectorStore, + resetVectorStore, + getVectorStoreStats, + SQLiteVectorStore, + MemoryVectorStore, +} from './store' + +// ==================== Pipeline ==================== + +export { executeSemanticPipeline } from './pipeline' diff --git a/electron/main/ai/rag/pipeline/index.ts b/electron/main/ai/rag/pipeline/index.ts new file mode 100644 index 0000000..2394311 --- /dev/null +++ b/electron/main/ai/rag/pipeline/index.ts @@ -0,0 +1,7 @@ +/** + * Pipeline 模块导出 + */ + +export { executeSemanticPipeline } from './semantic' +export type { SemanticPipelineOptions, SemanticPipelineResult } from './types' + diff --git a/electron/main/ai/rag/pipeline/semantic.ts b/electron/main/ai/rag/pipeline/semantic.ts new file mode 100644 index 0000000..109c296 --- /dev/null +++ b/electron/main/ai/rag/pipeline/semantic.ts @@ -0,0 +1,256 @@ +/** + * Semantic Pipeline 实现 + * + * RAG 流程:Query 改写 → 切片召回 → 向量相似度排序 → 生成证据块 + */ + +import type { SemanticPipelineOptions, SemanticPipelineResult, ChunkMetadata } from './types' +import type { Chunk } from '../types' +import { getEmbeddingService } from '../embedding' +import { getVectorStore } from '../store' +import { getSessionChunks } from '../chunking' +import { loadRAGConfig } from '../config' +import { chat } from '../../llm' +import { aiLogger as logger } from '../../logger' + +/** + * Query 改写提示词 + */ +const QUERY_REWRITE_PROMPT = `你是一个查询优化专家。请将用户的问题改写为更适合语义检索的查询。 + +要求: +1. 保留核心语义,去除口语化表达 +2. 提取关键实体和概念 +3. 扩展同义词或相关表达 +4. 输出一个简洁的检索查询,不要解释 + +用户问题:{query} + +改写后的查询:` + +/** + * 执行 Query 改写 + */ +async function rewriteQuery(query: string, abortSignal?: AbortSignal): Promise { + try { + const prompt = QUERY_REWRITE_PROMPT.replace('{query}', query) + + const response = await chat( + [ + { role: 'system', content: '你是一个查询优化专家,专门将用户问题改写为更适合语义检索的形式。' }, + { role: 'user', content: prompt }, + ], + { + temperature: 0.3, + maxTokens: 200, + abortSignal, + } + ) + + const rewritten = response.content.trim() + return rewritten || query + } catch (error) { + logger.warn('[Semantic Pipeline] Query 改写失败,使用原始查询:', error) + return query + } +} + +/** + * 余弦相似度计算 + */ +function cosineSimilarity(a: number[], b: number[]): number { + let dot = 0 + let normA = 0 + let normB = 0 + const len = Math.min(a.length, b.length) + + for (let i = 0; i < len; i++) { + dot += a[i] * b[i] + normA += a[i] * a[i] + normB += b[i] * b[i] + } + + return dot / (Math.sqrt(normA) * Math.sqrt(normB) + 1e-12) +} + +/** + * 格式化证据块(用于注入 System Prompt) + */ +function formatEvidenceBlock( + rewrittenQuery: string, + results: Array<{ score: number; chunkId: string; content: string; metadata?: ChunkMetadata }> +): string { + if (results.length === 0) { + return '' + } + + const lines = [ + ``, + `以下是与用户问题语义相关的历史对话片段(按相关度排序):`, + '', + ] + + for (let i = 0; i < results.length; i++) { + const result = results[i] + const score = (result.score * 100).toFixed(1) + lines.push(`--- 片段 ${i + 1} (相关度: ${score}%) ---`) + lines.push(result.content) + lines.push('') + } + + lines.push('') + lines.push('') + lines.push('请基于以上历史对话片段回答用户的问题。如果片段中没有相关信息,请说明。') + + return lines.join('\n') +} + +/** + * 执行 Semantic Pipeline + */ +export async function executeSemanticPipeline( + options: SemanticPipelineOptions +): Promise { + const { userMessage, dbPath, timeFilter, abortSignal } = options + + // 获取 RAG 配置 + const ragConfig = loadRAGConfig() + const candidateLimit = options.candidateLimit ?? ragConfig.candidateLimit ?? 50 + const topK = options.topK ?? ragConfig.topK ?? 10 + + logger.info('RAG', `🔍 开始语义搜索: "${userMessage.slice(0, 50)}..."`) + + try { + // 1. 检查 Embedding 服务 + const embeddingService = await getEmbeddingService() + if (!embeddingService) { + logger.warn('RAG', '语义搜索跳过: Embedding 服务未启用') + return { + success: false, + results: [], + error: 'Embedding 服务未启用或未配置', + } + } + + // 2. Query 改写 + const rewrittenQuery = await rewriteQuery(userMessage, abortSignal) + + // 检查中止 + if (abortSignal?.aborted) { + return { success: false, results: [], error: '操作已取消' } + } + + // 3. 获取会话级切片 + const chunks = getSessionChunks(dbPath, { + limit: candidateLimit, + timeFilter, + filterInvalid: true, + }) + + if (chunks.length === 0) { + logger.warn('RAG', '语义搜索跳过: 没有可用的会话切片') + return { + success: true, + rewrittenQuery, + results: [], + evidenceBlock: '', + } + } + + // 4. 获取或计算 Embedding + const vectorStore = await getVectorStore() + const chunkVectors = new Map() + const uncachedChunks: Chunk[] = [] + + // 检查缓存 + for (const chunk of chunks) { + if (vectorStore) { + const cached = await vectorStore.get(chunk.id) + if (cached) { + chunkVectors.set(chunk.id, cached) + continue + } + } + uncachedChunks.push(chunk) + } + + // 检查中止 + if (abortSignal?.aborted) { + return { success: false, results: [], error: '操作已取消' } + } + + // 批量计算未缓存的 Embedding + if (uncachedChunks.length > 0) { + const contents = uncachedChunks.map((c) => c.content) + const vectors = await embeddingService.embedBatch(contents) + + // 存入缓存并记录 + for (let i = 0; i < uncachedChunks.length; i++) { + const chunk = uncachedChunks[i] + const vector = vectors[i] + chunkVectors.set(chunk.id, vector) + + if (vectorStore) { + await vectorStore.add(chunk.id, vector, chunk.metadata as Record) + } + } + } + + // 检查中止 + if (abortSignal?.aborted) { + return { success: false, results: [], error: '操作已取消' } + } + + // 5. 计算 Query Embedding + const queryVector = await embeddingService.embed(rewrittenQuery) + + // 6. 向量相似度排序 + const scoredResults: Array<{ + score: number + chunkId: string + content: string + metadata?: ChunkMetadata + }> = [] + + for (const chunk of chunks) { + const vector = chunkVectors.get(chunk.id) + if (!vector) continue + + const score = cosineSimilarity(queryVector, vector) + scoredResults.push({ + score, + chunkId: chunk.id, + content: chunk.content, + metadata: chunk.metadata, + }) + } + + // 排序取 topK + scoredResults.sort((a, b) => b.score - a.score) + const topResults = scoredResults.slice(0, topK) + + const topScore = topResults[0]?.score ?? 0 + logger.info( + 'RAG', + `✅ 语义搜索完成: 返回 ${topResults.length} 个结果,最高相关度 ${(topScore * 100).toFixed(1)}%` + ) + + // 7. 生成证据块 + const evidenceBlock = formatEvidenceBlock(rewrittenQuery, topResults) + + return { + success: true, + rewrittenQuery, + results: topResults, + evidenceBlock, + } + } catch (error) { + logger.error('RAG', '❌ 语义搜索失败', error) + return { + success: false, + results: [], + error: error instanceof Error ? error.message : String(error), + } + } +} + diff --git a/electron/main/ai/rag/pipeline/types.ts b/electron/main/ai/rag/pipeline/types.ts new file mode 100644 index 0000000..efe8cde --- /dev/null +++ b/electron/main/ai/rag/pipeline/types.ts @@ -0,0 +1,7 @@ +/** + * Pipeline 类型定义 + * 从 ../types.ts 重导出,方便引用 + */ + +export type { SemanticPipelineOptions, SemanticPipelineResult, ChunkMetadata } from '../types' + diff --git a/electron/main/ai/rag/store/index.ts b/electron/main/ai/rag/store/index.ts new file mode 100644 index 0000000..5cd7db1 --- /dev/null +++ b/electron/main/ai/rag/store/index.ts @@ -0,0 +1,112 @@ +/** + * 向量存储管理器 + */ + +import * as path from 'path' +import type { IVectorStore, VectorStoreConfig } from './types' +import { SQLiteVectorStore } from './sqlite' +import { MemoryVectorStore } from './memory' +import { getVectorStoreDir, loadRAGConfig } from '../config' +import { aiLogger as logger } from '../../logger' + +// 当前活动的向量存储实例 +let activeStore: IVectorStore | null = null + +/** + * 获取向量存储 + * 根据配置自动选择存储类型 + */ +export async function getVectorStore(): Promise { + const config = loadRAGConfig() + + if (!config.vectorStore?.enabled) { + return null + } + + // 如果已有存储实例,直接返回 + if (activeStore) { + return activeStore + } + + try { + activeStore = await createVectorStore(config.vectorStore) + return activeStore + } catch (error) { + logger.error('[Store Manager] 创建存储失败:', error) + return null + } +} + +/** + * 创建向量存储实例 + */ +async function createVectorStore(config: VectorStoreConfig): Promise { + switch (config.type) { + case 'memory': { + const capacity = config.memoryCacheSize || 10000 + logger.info(`[Store Manager] 使用内存存储,容量: ${capacity}`) + return new MemoryVectorStore(capacity) + } + + case 'sqlite': { + const dbPath = config.dbPath || path.join(getVectorStoreDir(), 'embeddings.db') + logger.info(`[Store Manager] 使用 SQLite 存储: ${dbPath}`) + return new SQLiteVectorStore(dbPath) + } + + case 'lancedb': { + // TODO: 实现 LanceDB 存储 + throw new Error('LanceDB 存储尚未实现') + } + + default: + throw new Error(`未知的存储类型: ${config.type}`) + } +} + +/** + * 重置向量存储 + * 配置变更后调用 + */ +export async function resetVectorStore(): Promise { + if (activeStore) { + await activeStore.close() + activeStore = null + logger.info('[Store Manager] 存储已重置') + } +} + +/** + * 获取存储统计信息 + */ +export async function getVectorStoreStats(): Promise<{ + enabled: boolean + type?: string + count?: number + dimensions?: number + sizeBytes?: number +}> { + const config = loadRAGConfig() + + if (!config.vectorStore?.enabled) { + return { enabled: false } + } + + const store = await getVectorStore() + if (!store) { + return { enabled: true, type: config.vectorStore.type } + } + + const stats = await store.getStats() + return { + enabled: true, + type: config.vectorStore.type, + ...stats, + } +} + +// 重导出 +export { SQLiteVectorStore } from './sqlite' +export { MemoryVectorStore } from './memory' +export type { IVectorStore, VectorSearchResult, VectorStoreStats, VectorStoreConfig } from './types' + diff --git a/electron/main/ai/rag/store/memory.ts b/electron/main/ai/rag/store/memory.ts new file mode 100644 index 0000000..c0a54f4 --- /dev/null +++ b/electron/main/ai/rag/store/memory.ts @@ -0,0 +1,266 @@ +/** + * 内存向量存储 + * + * 使用 LRU 缓存,重启后数据丢失 + * 适用于不需要持久化的场景 + */ + +import type { IVectorStore, VectorSearchResult, VectorStoreStats } from './types' +import { aiLogger as logger } from '../../logger' + +/** + * 余弦相似度计算 + */ +function cosineSimilarity(a: number[], b: number[]): number { + let dot = 0 + let normA = 0 + let normB = 0 + const len = Math.min(a.length, b.length) + + for (let i = 0; i < len; i++) { + dot += a[i] * b[i] + normA += a[i] * a[i] + normB += b[i] * b[i] + } + + return dot / (Math.sqrt(normA) * Math.sqrt(normB) + 1e-12) +} + +/** + * LRU 缓存节点 + */ +interface CacheNode { + id: string + vector: number[] + metadata?: Record + prev: CacheNode | null + next: CacheNode | null +} + +/** + * 内存向量存储实现(LRU 缓存) + */ +export class MemoryVectorStore implements IVectorStore { + private capacity: number + private cache: Map = new Map() + private head: CacheNode | null = null // 最近使用 + private tail: CacheNode | null = null // 最久未使用 + + constructor(capacity: number = 10000) { + this.capacity = capacity + } + + /** + * 将节点移动到头部(标记为最近使用) + */ + private moveToHead(node: CacheNode): void { + if (node === this.head) return + + // 从原位置移除 + if (node.prev) { + node.prev.next = node.next + } + if (node.next) { + node.next.prev = node.prev + } + if (node === this.tail) { + this.tail = node.prev + } + + // 移动到头部 + node.prev = null + node.next = this.head + if (this.head) { + this.head.prev = node + } + this.head = node + + if (!this.tail) { + this.tail = node + } + } + + /** + * 添加新节点到头部 + */ + private addToHead(node: CacheNode): void { + node.prev = null + node.next = this.head + if (this.head) { + this.head.prev = node + } + this.head = node + + if (!this.tail) { + this.tail = node + } + } + + /** + * 移除尾部节点(最久未使用) + */ + private removeTail(): CacheNode | null { + if (!this.tail) return null + + const removed = this.tail + this.tail = removed.prev + if (this.tail) { + this.tail.next = null + } else { + this.head = null + } + + return removed + } + + /** + * 添加向量 + */ + async add(id: string, vector: number[], metadata?: Record): Promise { + if (this.cache.has(id)) { + // 更新已有节点 + const node = this.cache.get(id)! + node.vector = vector + node.metadata = metadata + this.moveToHead(node) + } else { + // 添加新节点 + const node: CacheNode = { + id, + vector, + metadata, + prev: null, + next: null, + } + + this.cache.set(id, node) + this.addToHead(node) + + // 超出容量,移除最久未使用 + if (this.cache.size > this.capacity) { + const removed = this.removeTail() + if (removed) { + this.cache.delete(removed.id) + } + } + } + } + + /** + * 批量添加向量 + */ + async addBatch( + items: Array<{ id: string; vector: number[]; metadata?: Record }> + ): Promise { + for (const item of items) { + await this.add(item.id, item.vector, item.metadata) + } + } + + /** + * 获取向量 + */ + async get(id: string): Promise { + const node = this.cache.get(id) + if (!node) return null + + this.moveToHead(node) + return node.vector + } + + /** + * 检查是否存在 + */ + async has(id: string): Promise { + return this.cache.has(id) + } + + /** + * 删除向量 + */ + async delete(id: string): Promise { + const node = this.cache.get(id) + if (!node) return + + // 从链表中移除 + if (node.prev) { + node.prev.next = node.next + } + if (node.next) { + node.next.prev = node.prev + } + if (node === this.head) { + this.head = node.next + } + if (node === this.tail) { + this.tail = node.prev + } + + this.cache.delete(id) + } + + /** + * 相似度搜索 + */ + async search(query: number[], topK: number): Promise { + const results: VectorSearchResult[] = [] + + for (const node of this.cache.values()) { + const score = cosineSimilarity(query, node.vector) + results.push({ + id: node.id, + score, + metadata: node.metadata, + }) + } + + // 排序取 topK + results.sort((a, b) => b.score - a.score) + return results.slice(0, topK) + } + + /** + * 清空存储 + */ + async clear(): Promise { + this.cache.clear() + this.head = null + this.tail = null + logger.info('[Memory Store] 已清空所有向量') + } + + /** + * 获取存储统计 + */ + async getStats(): Promise { + // 获取第一个向量的维度 + let dimensions: number | undefined + for (const node of this.cache.values()) { + dimensions = node.vector.length + break + } + + // 估算内存占用(粗略计算) + let sizeBytes: number | undefined + if (dimensions) { + // 每个向量占用 dimensions * 4 bytes (Float32) + // 加上 id、metadata 等开销,估算为 dimensions * 8 + sizeBytes = this.cache.size * dimensions * 8 + } + + return { + count: this.cache.size, + dimensions, + sizeBytes, + } + } + + /** + * 关闭存储 + */ + async close(): Promise { + // 内存存储无需关闭操作 + logger.info('[Memory Store] 已关闭') + } +} + diff --git a/electron/main/ai/rag/store/sqlite.ts b/electron/main/ai/rag/store/sqlite.ts new file mode 100644 index 0000000..89e293c --- /dev/null +++ b/electron/main/ai/rag/store/sqlite.ts @@ -0,0 +1,226 @@ +/** + * SQLite 向量存储(BLOB 格式) + * + * 使用 BLOB 存储 Float32Array buffer,而不是 JSON 字符串 + * 优点:体积小(约 50%)、读取快(无需 JSON.parse) + */ + +import Database from 'better-sqlite3' +import type { IVectorStore, VectorSearchResult, VectorStoreStats } from './types' +import { aiLogger as logger } from '../../logger' + +/** + * 余弦相似度计算 + */ +function cosineSimilarity(a: number[], b: number[]): number { + let dot = 0 + let normA = 0 + let normB = 0 + const len = Math.min(a.length, b.length) + + for (let i = 0; i < len; i++) { + dot += a[i] * b[i] + normA += a[i] * a[i] + normB += b[i] * b[i] + } + + return dot / (Math.sqrt(normA) * Math.sqrt(normB) + 1e-12) +} + +/** + * 将数字数组转换为 Buffer(Float32Array) + */ +function vectorToBuffer(vector: number[]): Buffer { + const float32 = new Float32Array(vector) + return Buffer.from(float32.buffer) +} + +/** + * 将 Buffer 转换为数字数组 + */ +function bufferToVector(buffer: Buffer): number[] { + const float32 = new Float32Array(buffer.buffer, buffer.byteOffset, buffer.length / 4) + return Array.from(float32) +} + +/** + * SQLite 向量存储实现 + */ +export class SQLiteVectorStore implements IVectorStore { + private db: Database.Database + private dbPath: string + + constructor(dbPath: string) { + this.dbPath = dbPath + this.db = new Database(dbPath) + this.initSchema() + } + + /** + * 初始化数据库 Schema + */ + private initSchema(): void { + this.db.pragma('journal_mode = WAL') + + this.db.exec(` + CREATE TABLE IF NOT EXISTS vectors ( + id TEXT PRIMARY KEY, + vector BLOB NOT NULL, + dimensions INTEGER NOT NULL, + metadata TEXT, + created_at INTEGER DEFAULT (strftime('%s', 'now') * 1000) + ) + `) + + // 创建索引 + try { + this.db.exec('CREATE INDEX IF NOT EXISTS idx_vectors_created ON vectors(created_at)') + } catch { + // 索引可能已存在 + } + + logger.info(`[SQLite Store] 初始化完成: ${this.dbPath}`) + } + + /** + * 添加向量(Float32Array → BLOB) + */ + async add(id: string, vector: number[], metadata?: Record): Promise { + const buffer = vectorToBuffer(vector) + + this.db + .prepare( + ` + INSERT OR REPLACE INTO vectors (id, vector, dimensions, metadata) + VALUES (?, ?, ?, ?) + ` + ) + .run(id, buffer, vector.length, metadata ? JSON.stringify(metadata) : null) + } + + /** + * 批量添加向量 + */ + async addBatch( + items: Array<{ id: string; vector: number[]; metadata?: Record }> + ): Promise { + const insert = this.db.prepare(` + INSERT OR REPLACE INTO vectors (id, vector, dimensions, metadata) + VALUES (?, ?, ?, ?) + `) + + const insertMany = this.db.transaction((items: typeof items) => { + for (const item of items) { + const buffer = vectorToBuffer(item.vector) + insert.run(item.id, buffer, item.vector.length, item.metadata ? JSON.stringify(item.metadata) : null) + } + }) + + insertMany(items) + } + + /** + * 获取向量(BLOB → Float32Array) + */ + async get(id: string): Promise { + const row = this.db.prepare('SELECT vector FROM vectors WHERE id = ?').get(id) as + | { vector: Buffer } + | undefined + + if (!row) return null + + return bufferToVector(row.vector) + } + + /** + * 检查是否存在 + */ + async has(id: string): Promise { + const row = this.db.prepare('SELECT 1 FROM vectors WHERE id = ?').get(id) + return !!row + } + + /** + * 删除向量 + */ + async delete(id: string): Promise { + this.db.prepare('DELETE FROM vectors WHERE id = ?').run(id) + } + + /** + * 相似度搜索 + * 注意:SQLite 不支持向量索引,需要全量加载到内存计算 + */ + async search(query: number[], topK: number): Promise { + // 1. 全量读取(仅读取 id 和 vector) + const rows = this.db.prepare('SELECT id, vector, metadata FROM vectors').all() as Array<{ + id: string + vector: Buffer + metadata: string | null + }> + + if (rows.length === 0) { + return [] + } + + // 2. 计算余弦相似度并排序 + const results: VectorSearchResult[] = rows.map((row) => { + const vector = bufferToVector(row.vector) + const score = cosineSimilarity(query, vector) + return { + id: row.id, + score, + metadata: row.metadata ? JSON.parse(row.metadata) : undefined, + } + }) + + // 3. 排序取 topK + results.sort((a, b) => b.score - a.score) + return results.slice(0, topK) + } + + /** + * 清空存储 + */ + async clear(): Promise { + this.db.exec('DELETE FROM vectors') + logger.info('[SQLite Store] 已清空所有向量') + } + + /** + * 获取存储统计 + */ + async getStats(): Promise { + const countRow = this.db.prepare('SELECT COUNT(*) as count FROM vectors').get() as { count: number } + + // 获取第一个向量的维度 + const dimRow = this.db.prepare('SELECT dimensions FROM vectors LIMIT 1').get() as + | { dimensions: number } + | undefined + + // 获取数据库文件大小 + let sizeBytes: number | undefined + try { + const fs = await import('fs') + const stats = fs.statSync(this.dbPath) + sizeBytes = stats.size + } catch { + // 忽略错误 + } + + return { + count: countRow.count, + dimensions: dimRow?.dimensions, + sizeBytes, + } + } + + /** + * 关闭存储 + */ + async close(): Promise { + this.db.close() + logger.info('[SQLite Store] 已关闭') + } +} + diff --git a/electron/main/ai/rag/store/types.ts b/electron/main/ai/rag/store/types.ts new file mode 100644 index 0000000..0cb88d6 --- /dev/null +++ b/electron/main/ai/rag/store/types.ts @@ -0,0 +1,7 @@ +/** + * 向量存储类型定义 + * 从 ../types.ts 重导出,方便引用 + */ + +export type { IVectorStore, VectorSearchResult, VectorStoreStats, VectorStoreConfig } from '../types' + diff --git a/electron/main/ai/rag/types.ts b/electron/main/ai/rag/types.ts new file mode 100644 index 0000000..394f716 --- /dev/null +++ b/electron/main/ai/rag/types.ts @@ -0,0 +1,391 @@ +/** + * RAG 模块类型定义 + */ + +// ==================== Embedding 服务配置(多配置模式) ==================== + +/** + * 单个 Embedding 服务配置 + */ +export interface EmbeddingServiceConfig { + /** 配置 ID */ + id: string + + /** 用户自定义名称 */ + name: string + + /** + * API 配置来源 + * - 'reuse_llm': 复用当前 LLM 配置的 baseUrl/apiKey + * - 'custom': 独立配置 + */ + apiSource: 'reuse_llm' | 'custom' + + /** Embedding 模型名称(如 'nomic-embed-text') */ + model: string + + // apiSource === 'custom' 时使用 + baseUrl?: string + apiKey?: string + + /** 创建时间戳 */ + createdAt: number + + /** 更新时间戳 */ + updatedAt: number +} + +/** + * Embedding 配置存储 + */ +export interface EmbeddingConfigStore { + /** 所有配置 */ + configs: EmbeddingServiceConfig[] + + /** 当前激活的配置 ID */ + activeConfigId: string | null + + /** 是否启用语义搜索 */ + enabled: boolean +} + +/** + * 最大配置数量 + */ +export const MAX_EMBEDDING_CONFIG_COUNT = 10 + +/** + * 默认 Embedding 配置存储 + */ +export const DEFAULT_EMBEDDING_CONFIG_STORE: EmbeddingConfigStore = { + configs: [], + activeConfigId: null, + enabled: false, +} + +// ==================== 旧版 EmbeddingConfig(兼容) ==================== + +/** + * Embedding 配置(旧版,用于兼容) + * @deprecated 使用 EmbeddingServiceConfig 代替 + */ +export interface EmbeddingConfig { + /** 是否启用 Embedding */ + enabled: boolean + + /** + * 提供商类型(目前固定为 'api') + */ + provider: 'api' + + /** + * API 配置来源 + * - 'reuse_llm': 复用当前 LLM 配置的 baseUrl/apiKey + * - 'custom': 独立配置 + */ + apiSource?: 'reuse_llm' | 'custom' + + /** Embedding 模型名称(如 'nomic-embed-text') */ + model?: string + + // apiSource === 'custom' 时使用 + baseUrl?: string + apiKey?: string +} + +// ==================== 向量存储相关类型 ==================== + +/** + * 向量存储配置 + */ +export interface VectorStoreConfig { + /** 是否启用向量缓存 */ + enabled: boolean + + /** + * 存储类型 + * - 'memory': 仅内存缓存(重启后丢失) + * - 'sqlite': SQLite 持久化(推荐) + * - 'lancedb': LanceDB(预留,需解决 Electron 打包) + */ + type: 'memory' | 'sqlite' | 'lancedb' + + // type === 'memory' 时的选项 + /** LRU 缓存大小(条目数) */ + memoryCacheSize?: number + + // type === 'sqlite' 时的选项 + /** 数据库路径(默认 {userData}/data/ai/vectors/embeddings.db) */ + dbPath?: string +} + +// ==================== Rerank 相关类型(预留) ==================== + +/** + * Rerank 配置(预留) + */ +export interface RerankConfig { + /** 是否启用重排 */ + enabled: boolean + + /** 提供商 */ + provider: 'jina' | 'cohere' | 'bge' | 'custom' + + /** 模型名称 */ + model?: string + + /** API 端点 */ + baseUrl?: string + + /** API Key */ + apiKey?: string + + /** 重排后返回的数量 */ + topK?: number +} + +// ==================== RAG 配置 ==================== + +/** + * RAG 完整配置 + */ +export interface RAGConfig { + /** Embedding 配置 */ + embedding?: EmbeddingConfig + + /** 向量存储配置 */ + vectorStore?: VectorStoreConfig + + /** Rerank 配置(预留) */ + rerank?: RerankConfig + + // Pipeline 配置 + + /** 是否启用 Semantic Pipeline(自动语义搜索) */ + enableSemanticPipeline?: boolean + + /** 候选切片数量上限 */ + candidateLimit?: number + + /** 返回结果数量 */ + topK?: number +} + +/** + * 默认 RAG 配置 + */ +export const DEFAULT_RAG_CONFIG: RAGConfig = { + embedding: { + enabled: false, + provider: 'api', + apiSource: 'reuse_llm', + }, + vectorStore: { + enabled: true, + type: 'sqlite', + }, + enableSemanticPipeline: true, + candidateLimit: 50, + topK: 10, +} + +// ==================== Embedding 服务接口 ==================== + +/** + * Embedding 服务接口 + */ +export interface IEmbeddingService { + /** 获取提供商名称 */ + getProvider(): string + + /** 获取向量维度 */ + getDimensions(): number + + /** 嵌入单条文本 */ + embed(text: string): Promise + + /** 批量嵌入文本 */ + embedBatch(texts: string[]): Promise + + /** 验证服务可用性 */ + validate(): Promise<{ success: boolean; error?: string }> + + /** 释放资源 */ + dispose(): Promise +} + +/** + * Embedding 结果 + */ +export interface EmbeddingResult { + /** 原始文本 */ + text: string + /** 向量 */ + vector: number[] + /** 向量维度 */ + dimensions: number +} + +// ==================== 向量存储服务接口 ==================== + +/** + * 向量存储接口 + */ +export interface IVectorStore { + /** 添加向量 */ + add(id: string, vector: number[], metadata?: Record): Promise + + /** 批量添加向量 */ + addBatch(items: Array<{ id: string; vector: number[]; metadata?: Record }>): Promise + + /** 获取向量 */ + get(id: string): Promise + + /** 检查是否存在 */ + has(id: string): Promise + + /** 删除向量 */ + delete(id: string): Promise + + /** 相似度搜索 */ + search(query: number[], topK: number): Promise + + /** 清空存储 */ + clear(): Promise + + /** 获取存储统计 */ + getStats(): Promise + + /** 关闭存储 */ + close(): Promise +} + +/** + * 向量搜索结果 + */ +export interface VectorSearchResult { + id: string + score: number + metadata?: Record +} + +/** + * 存储统计 + */ +export interface VectorStoreStats { + count: number + dimensions?: number + sizeBytes?: number +} + +// ==================== 切片相关类型 ==================== + +/** + * 切片结果 + */ +export interface Chunk { + /** 切片 ID(如 session_123) */ + id: string + + /** 切片类型 */ + type: 'session' | 'window' | 'time' + + /** 用于 Embedding 的文本内容 */ + content: string + + /** 元数据 */ + metadata: ChunkMetadata +} + +/** + * 切片元数据 + */ +export interface ChunkMetadata { + sessionId?: number + startTs: number + endTs: number + messageCount: number + participants: string[] + + /** 子切片索引(从 0 开始,仅当会话被拆分时存在) */ + subChunkIndex?: number + + /** 总子切片数(仅当会话被拆分时存在) */ + totalSubChunks?: number +} + +// ==================== Rerank 服务接口(预留) ==================== + +/** + * Rerank 服务接口(预留) + */ +export interface IRerankService { + /** 重排文档 */ + rerank(query: string, documents: string[], topK?: number): Promise + + /** 验证服务可用性 */ + validate(): Promise<{ success: boolean; error?: string }> +} + +/** + * 重排结果 + */ +export interface RerankResult { + index: number + score: number + text: string +} + +// ==================== Pipeline 相关类型 ==================== + +/** + * Semantic Pipeline 选项 + */ +export interface SemanticPipelineOptions { + /** 用户原始问题 */ + userMessage: string + + /** 数据库路径 */ + dbPath: string + + /** 时间过滤器 */ + timeFilter?: { startTs: number; endTs: number } + + /** 候选切片数量 */ + candidateLimit?: number + + /** 返回结果数量 */ + topK?: number + + /** 是否使用重排 */ + useRerank?: boolean + + /** 中止信号 */ + abortSignal?: AbortSignal +} + +/** + * Semantic Pipeline 结果 + */ +export interface SemanticPipelineResult { + /** 是否成功执行 */ + success: boolean + + /** 改写后的 query */ + rewrittenQuery?: string + + /** 检索结果 */ + results: Array<{ + score: number + chunkId: string + content: string + metadata?: ChunkMetadata + }> + + /** 格式化的证据块(用于注入 System Prompt) */ + evidenceBlock?: string + + /** 错误信息 */ + error?: string +} + diff --git a/electron/main/ai/tools/index.ts b/electron/main/ai/tools/index.ts index fed6a10..473c9be 100644 --- a/electron/main/ai/tools/index.ts +++ b/electron/main/ai/tools/index.ts @@ -5,6 +5,7 @@ import type { ToolDefinition, ToolCall } from '../llm/types' import type { ToolRegistry, RegisteredTool, ToolContext, ToolExecutionResult, ToolExecutor } from './types' +import { isEmbeddingEnabled } from '../rag' // 导出类型 export * from './types' @@ -45,11 +46,21 @@ export async function ensureToolsInitialized(): Promise { /** * 获取所有已注册的工具定义 + * 根据配置动态过滤工具(如:语义搜索工具仅在启用 Embedding 时可用) * @returns 工具定义数组(用于传给 LLM) */ export async function getAllToolDefinitions(): Promise { await ensureToolsInitialized() - return Array.from(toolRegistry.values()).map((t) => t.definition) + + const allTools = Array.from(toolRegistry.values()).map((t) => t.definition) + + // 根据 Embedding 配置决定是否包含语义搜索工具 + const embeddingEnabled = isEmbeddingEnabled() + if (!embeddingEnabled) { + return allTools.filter((t) => t.function.name !== 'semantic_search_messages') + } + + return allTools } /** diff --git a/electron/main/ai/tools/registry.ts b/electron/main/ai/tools/registry.ts index 1742525..ab78aa3 100644 --- a/electron/main/ai/tools/registry.ts +++ b/electron/main/ai/tools/registry.ts @@ -7,6 +7,8 @@ import { registerTool } from './index' import type { ToolDefinition } from '../llm/types' import type { ToolContext } from './types' import * as workerManager from '../../worker/workerManager' +import { executeSemanticPipeline, isEmbeddingEnabled } from '../rag' +import { getDbPath } from '../../database/core' // ==================== 国际化辅助函数 ==================== @@ -916,6 +918,138 @@ async function getSessionMessagesExecutor( } } +// ==================== 语义搜索工具 ==================== + +/** + * 语义搜索消息工具 + * 使用 Embedding 向量相似度搜索相关的历史对话 + */ +const semanticSearchMessagesTool: ToolDefinition = { + type: 'function', + function: { + name: 'semantic_search_messages', + description: `使用 Embedding 向量相似度搜索历史对话,理解语义而非关键词匹配。 + +⚠️ 使用场景(优先使用 search_messages 关键词搜索,以下场景再考虑本工具): +1. 找"类似的话"或"类似的表达":如"有没有说过类似'我想你了'这样的话" +2. 关键词搜索结果不足:当 search_messages 返回结果太少或不相关时,可用本工具补充 +3. 模糊的情感/关系分析:如"对方对我的态度是怎样的"、"我们之间的氛围" + +❌ 不适合的场景(请用 search_messages): +- 有明确关键词的搜索(如"旅游"、"生日"、"加班") +- 查找特定人物的发言 +- 查找特定时间段的消息`, + parameters: { + type: 'object', + properties: { + query: { + type: 'string', + description: '语义检索查询,用自然语言描述你想要找的内容类型', + }, + top_k: { + type: 'number', + description: '返回结果数量,默认 10(建议 5-20)', + }, + candidate_limit: { + type: 'number', + description: '候选会话数量,默认 50(越大越慢但可能更准确)', + }, + year: { + type: 'number', + description: '筛选指定年份的会话', + }, + month: { + type: 'number', + description: '筛选指定月份的会话(1-12)', + }, + day: { + type: 'number', + description: '筛选指定日期的会话(1-31)', + }, + start_time: { + type: 'string', + description: '开始时间,格式 "YYYY-MM-DD HH:mm"', + }, + end_time: { + type: 'string', + description: '结束时间,格式 "YYYY-MM-DD HH:mm"', + }, + }, + required: ['query'], + }, + }, +} + +async function semanticSearchMessagesExecutor( + params: { + query: string + top_k?: number + candidate_limit?: number + year?: number + month?: number + day?: number + start_time?: string + end_time?: string + }, + context: ToolContext +): Promise { + const { sessionId, timeFilter: contextTimeFilter, locale } = context + + // 检查语义搜索是否启用 + if (!isEmbeddingEnabled()) { + return { + error: isChineseLocale(locale) + ? '语义搜索未启用。请在设置中添加并启用 Embedding 配置。' + : 'Semantic search is not enabled. Please add and enable an Embedding config in settings.', + } + } + + // 使用扩展的时间参数解析 + const effectiveTimeFilter = parseExtendedTimeParams(params, contextTimeFilter) + + // 获取数据库路径 + const dbPath = getDbPath(sessionId) + + // 执行语义搜索 + const result = await executeSemanticPipeline({ + userMessage: params.query, + dbPath, + timeFilter: effectiveTimeFilter, + candidateLimit: params.candidate_limit, + topK: params.top_k, + }) + + if (!result.success) { + return { + error: result.error || (isChineseLocale(locale) ? '语义搜索失败' : 'Semantic search failed'), + } + } + + if (result.results.length === 0) { + return { + message: isChineseLocale(locale) ? '未找到相关的历史对话' : 'No relevant conversations found', + rewrittenQuery: result.rewrittenQuery, + } + } + + // 格式化结果 + return { + total: result.results.length, + rewrittenQuery: result.rewrittenQuery, + timeRange: formatTimeRange(effectiveTimeFilter, locale), + results: result.results.map((r, i) => ({ + rank: i + 1, + score: `${(r.score * 100).toFixed(1)}%`, + sessionId: r.metadata?.sessionId, + timeRange: r.metadata + ? formatTimeRange({ startTs: r.metadata.startTs, endTs: r.metadata.endTs }, locale) + : undefined, + participants: r.metadata?.participants, + content: r.content.length > 500 ? r.content.slice(0, 500) + '...' : r.content, + })), + } +} + // ==================== 注册工具 ==================== registerTool(searchMessagesTool, searchMessagesExecutor) @@ -928,3 +1062,4 @@ registerTool(getConversationBetweenTool, getConversationBetweenExecutor) registerTool(getMessageContextTool, getMessageContextExecutor) registerTool(searchSessionsTool, searchSessionsExecutor) registerTool(getSessionMessagesTool, getSessionMessagesExecutor) +registerTool(semanticSearchMessagesTool, semanticSearchMessagesExecutor) diff --git a/electron/main/ipc/ai.ts b/electron/main/ipc/ai.ts index fe3a87a..d01c537 100644 --- a/electron/main/ipc/ai.ts +++ b/electron/main/ipc/ai.ts @@ -4,6 +4,7 @@ import * as fs from 'fs' import * as path from 'path' import * as aiConversations from '../ai/conversations' import * as llm from '../ai/llm' +import * as rag from '../ai/rag' import { aiLogger } from '../ai/logger' import { getLogsDir } from '../paths' import { Agent, type AgentStreamChunk, type PromptConfig } from '../ai/agent' @@ -653,4 +654,194 @@ export function registerAIHandlers({ win }: IpcContext): void { return { success: false, error: '未找到该请求' } } }) + + // ==================== Embedding 多配置管理 ==================== + + /** + * 获取所有 Embedding 配置(展示用,隐藏 apiKey) + */ + ipcMain.handle('embedding:getAllConfigs', async () => { + try { + const configs = rag.getAllEmbeddingConfigs() + // 隐藏敏感信息 + return configs.map((c) => ({ + ...c, + apiKey: undefined, + apiKeySet: !!c.apiKey, + })) + } catch (error) { + aiLogger.error('IPC', '获取 Embedding 配置失败', error) + return [] + } + }) + + /** + * 获取单个 Embedding 配置(用于编辑,包含完整信息) + */ + ipcMain.handle('embedding:getConfig', async (_, id: string) => { + try { + return rag.getEmbeddingConfigById(id) + } catch (error) { + aiLogger.error('IPC', '获取 Embedding 配置失败', error) + return null + } + }) + + /** + * 获取激活的 Embedding 配置 ID + */ + ipcMain.handle('embedding:getActiveConfigId', async () => { + try { + return rag.getActiveEmbeddingConfigId() + } catch (error) { + return null + } + }) + + /** + * 检查语义搜索是否启用 + */ + ipcMain.handle('embedding:isEnabled', async () => { + try { + return rag.isEmbeddingEnabled() + } catch (error) { + return false + } + }) + + /** + * 设置语义搜索启用状态 + */ + ipcMain.handle('embedding:setEnabled', async (_, enabled: boolean) => { + try { + rag.setEmbeddingEnabled(enabled) + if (!enabled) { + await rag.resetEmbeddingService() + } + return { success: true } + } catch (error) { + aiLogger.error('IPC', '设置语义搜索状态失败', error) + return { success: false, error: String(error) } + } + }) + + /** + * 添加 Embedding 配置 + */ + ipcMain.handle( + 'embedding:addConfig', + async (_, config: Omit) => { + try { + aiLogger.info('IPC', '添加 Embedding 配置', { name: config.name, model: config.model }) + const result = rag.addEmbeddingConfig(config) + if (result.success) { + await rag.resetEmbeddingService() + } + return result + } catch (error) { + aiLogger.error('IPC', '添加 Embedding 配置失败', error) + return { success: false, error: String(error) } + } + } + ) + + /** + * 更新 Embedding 配置 + */ + ipcMain.handle( + 'embedding:updateConfig', + async ( + _, + id: string, + updates: Partial> + ) => { + try { + aiLogger.info('IPC', '更新 Embedding 配置', { id }) + const result = rag.updateEmbeddingConfig(id, updates) + if (result.success) { + await rag.resetEmbeddingService() + } + return result + } catch (error) { + aiLogger.error('IPC', '更新 Embedding 配置失败', error) + return { success: false, error: String(error) } + } + } + ) + + /** + * 删除 Embedding 配置 + */ + ipcMain.handle('embedding:deleteConfig', async (_, id: string) => { + try { + aiLogger.info('IPC', '删除 Embedding 配置', { id }) + const result = rag.deleteEmbeddingConfig(id) + if (result.success) { + await rag.resetEmbeddingService() + } + return result + } catch (error) { + aiLogger.error('IPC', '删除 Embedding 配置失败', error) + return { success: false, error: String(error) } + } + }) + + /** + * 设置激活的 Embedding 配置 + */ + ipcMain.handle('embedding:setActiveConfig', async (_, id: string) => { + try { + aiLogger.info('IPC', '设置激活 Embedding 配置', { id }) + const result = rag.setActiveEmbeddingConfig(id) + if (result.success) { + await rag.resetEmbeddingService() + } + return result + } catch (error) { + aiLogger.error('IPC', '设置激活 Embedding 配置失败', error) + return { success: false, error: String(error) } + } + }) + + /** + * 验证 Embedding 配置 + */ + ipcMain.handle('embedding:validateConfig', async (_, config: rag.EmbeddingServiceConfig) => { + try { + return await rag.validateEmbeddingConfig(config) + } catch (error) { + return { success: false, error: String(error) } + } + }) + + // ==================== 向量存储管理 ==================== + + /** + * 获取向量存储统计信息 + */ + ipcMain.handle('rag:getVectorStoreStats', async () => { + try { + return await rag.getVectorStoreStats() + } catch (error) { + console.error('获取向量存储统计失败:', error) + return { enabled: false, error: String(error) } + } + }) + + /** + * 清空向量存储 + */ + ipcMain.handle('rag:clearVectorStore', async () => { + try { + const store = await rag.getVectorStore() + if (store) { + await store.clear() + return { success: true } + } + return { success: false, error: '向量存储未启用' } + } catch (error) { + console.error('清空向量存储失败:', error) + return { success: false, error: String(error) } + } + }) } diff --git a/electron/preload/index.d.ts b/electron/preload/index.d.ts index 443f6e9..289bb31 100644 --- a/electron/preload/index.d.ts +++ b/electron/preload/index.d.ts @@ -437,6 +437,91 @@ interface LlmApi { ) => Promise<{ success: boolean; error?: string }> } +// ==================== Embedding 多配置相关类型 ==================== + +interface EmbeddingServiceConfig { + id: string + name: string + apiSource: 'reuse_llm' | 'custom' + model: string + baseUrl?: string + apiKey?: string + createdAt: number + updatedAt: number +} + +interface EmbeddingServiceConfigDisplay { + id: string + name: string + apiSource: 'reuse_llm' | 'custom' + model: string + baseUrl?: string + apiKeySet: boolean + createdAt: number + updatedAt: number +} + +interface EmbeddingApi { + getAllConfigs: () => Promise + getConfig: (id: string) => Promise + getActiveConfigId: () => Promise + isEnabled: () => Promise + setEnabled: (enabled: boolean) => Promise<{ success: boolean; error?: string }> + addConfig: ( + config: Omit + ) => Promise<{ success: boolean; config?: EmbeddingServiceConfig; error?: string }> + updateConfig: ( + id: string, + updates: Partial> + ) => Promise<{ success: boolean; error?: string }> + deleteConfig: (id: string) => Promise<{ success: boolean; error?: string }> + setActiveConfig: (id: string) => Promise<{ success: boolean; error?: string }> + validateConfig: (config: EmbeddingServiceConfig) => Promise<{ success: boolean; error?: string }> + getVectorStoreStats: () => Promise<{ + enabled: boolean + count?: number + sizeBytes?: number + error?: string + }> + clearVectorStore: () => Promise<{ success: boolean; error?: string }> +} + +// ==================== 旧版 RAG 相关类型(兼容) ==================== + +interface EmbeddingConfig { + enabled: boolean + provider: 'api' + apiSource?: 'reuse_llm' | 'custom' + model?: string + baseUrl?: string + apiKey?: string +} + +interface VectorStoreConfig { + enabled: boolean + type: 'memory' | 'sqlite' | 'lancedb' + memoryCacheSize?: number + dbPath?: string +} + +interface RerankConfig { + enabled: boolean + provider: 'jina' | 'cohere' | 'bge' | 'custom' + model?: string + baseUrl?: string + apiKey?: string + topK?: number +} + +interface RAGConfig { + embedding?: EmbeddingConfig + vectorStore?: VectorStoreConfig + rerank?: RerankConfig + enableSemanticPipeline?: boolean + candidateLimit?: number + topK?: number +} + // Token 使用量类型 interface TokenUsage { promptTokens: number @@ -578,6 +663,7 @@ declare global { mergeApi: MergeApi aiApi: AiApi llmApi: LlmApi + embeddingApi: EmbeddingApi agentApi: AgentApi cacheApi: CacheApi networkApi: NetworkApi @@ -591,6 +677,9 @@ export { MergeApi, AiApi, LlmApi, + EmbeddingApi, + EmbeddingServiceConfig, + EmbeddingServiceConfigDisplay, AgentApi, CacheApi, NetworkApi, @@ -613,4 +702,8 @@ export { FilterMessage, ContextBlock, FilterResult, + RAGConfig, + EmbeddingConfig, + VectorStoreConfig, + RerankConfig, } diff --git a/electron/preload/index.ts b/electron/preload/index.ts index ed23a79..d8cf543 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -852,6 +852,74 @@ interface AIServiceConfigDisplay { updatedAt: number } +// Embedding 服务配置(多配置模式) +interface EmbeddingServiceConfig { + id: string + name: string + apiSource: 'reuse_llm' | 'custom' + model: string + baseUrl?: string + apiKey?: string + createdAt: number + updatedAt: number +} + +// Embedding 配置展示用(隐藏 apiKey) +interface EmbeddingServiceConfigDisplay { + id: string + name: string + apiSource: 'reuse_llm' | 'custom' + model: string + baseUrl?: string + apiKeySet: boolean + createdAt: number + updatedAt: number +} + +// 旧版 EmbeddingConfig(兼容) +interface EmbeddingConfig { + enabled: boolean + provider: 'api' + apiSource?: 'reuse_llm' | 'custom' + model?: string + baseUrl?: string + apiKey?: string +} + +interface VectorStoreConfig { + enabled: boolean + type: 'memory' | 'sqlite' | 'lancedb' + memoryCacheSize?: number + dbPath?: string +} + +interface RerankConfig { + enabled: boolean + provider: 'jina' | 'cohere' | 'bge' | 'custom' + model?: string + baseUrl?: string + apiKey?: string + topK?: number +} + +interface RAGConfig { + embedding?: EmbeddingConfig + vectorStore?: VectorStoreConfig + rerank?: RerankConfig + enableSemanticPipeline?: boolean + candidateLimit?: number + topK?: number +} + +interface LocalEmbeddingModel { + id: string + name: string + description: string + size: string + dimensions: number + languages: readonly string[] +} + const llmApi = { /** * 获取所有支持的 LLM 提供商 @@ -1326,6 +1394,98 @@ const extendedApi = { }, } +// Embedding API(多配置模式) +const embeddingApi = { + /** + * 获取所有 Embedding 配置(展示用) + */ + getAllConfigs: (): Promise => { + return ipcRenderer.invoke('embedding:getAllConfigs') + }, + + /** + * 获取单个 Embedding 配置(用于编辑) + */ + getConfig: (id: string): Promise => { + return ipcRenderer.invoke('embedding:getConfig', id) + }, + + /** + * 获取激活的配置 ID + */ + getActiveConfigId: (): Promise => { + return ipcRenderer.invoke('embedding:getActiveConfigId') + }, + + /** + * 检查语义搜索是否启用 + */ + isEnabled: (): Promise => { + return ipcRenderer.invoke('embedding:isEnabled') + }, + + /** + * 设置语义搜索启用状态 + */ + setEnabled: (enabled: boolean): Promise<{ success: boolean; error?: string }> => { + return ipcRenderer.invoke('embedding:setEnabled', enabled) + }, + + /** + * 添加 Embedding 配置 + */ + addConfig: ( + config: Omit + ): Promise<{ success: boolean; config?: EmbeddingServiceConfig; error?: string }> => { + return ipcRenderer.invoke('embedding:addConfig', config) + }, + + /** + * 更新 Embedding 配置 + */ + updateConfig: ( + id: string, + updates: Partial> + ): Promise<{ success: boolean; error?: string }> => { + return ipcRenderer.invoke('embedding:updateConfig', id, updates) + }, + + /** + * 删除 Embedding 配置 + */ + deleteConfig: (id: string): Promise<{ success: boolean; error?: string }> => { + return ipcRenderer.invoke('embedding:deleteConfig', id) + }, + + /** + * 设置激活的配置 + */ + setActiveConfig: (id: string): Promise<{ success: boolean; error?: string }> => { + return ipcRenderer.invoke('embedding:setActiveConfig', id) + }, + + /** + * 验证 Embedding 配置 + */ + validateConfig: (config: EmbeddingServiceConfig): Promise<{ success: boolean; error?: string }> => { + return ipcRenderer.invoke('embedding:validateConfig', config) + }, + + /** + * 获取向量存储统计信息 + */ + getVectorStoreStats: (): Promise<{ enabled: boolean; count?: number; sizeBytes?: number }> => { + return ipcRenderer.invoke('rag:getVectorStoreStats') + }, + + /** + * 清空向量存储 + */ + clearVectorStore: (): Promise<{ success: boolean; error?: string }> => { + return ipcRenderer.invoke('rag:clearVectorStore') + }, +} + // Use `contextBridge` APIs to expose Electron APIs to // renderer only if context isolation is enabled, otherwise // just add to the DOM global. @@ -1338,6 +1498,7 @@ if (process.contextIsolated) { contextBridge.exposeInMainWorld('aiApi', aiApi) contextBridge.exposeInMainWorld('llmApi', llmApi) contextBridge.exposeInMainWorld('agentApi', agentApi) + contextBridge.exposeInMainWorld('embeddingApi', embeddingApi) contextBridge.exposeInMainWorld('cacheApi', cacheApi) contextBridge.exposeInMainWorld('networkApi', networkApi) contextBridge.exposeInMainWorld('sessionApi', sessionApi) @@ -1360,6 +1521,8 @@ if (process.contextIsolated) { // @ts-ignore (define in dts) window.agentApi = agentApi // @ts-ignore (define in dts) + window.embeddingApi = embeddingApi + // @ts-ignore (define in dts) window.cacheApi = cacheApi // @ts-ignore (define in dts) window.networkApi = networkApi diff --git a/package.json b/package.json index 038bb5f..7926845 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "@electron-toolkit/utils": "^4.0.0", "@tanstack/vue-virtual": "^3.13.18", "@types/markdown-it": "^14.1.2", + "@xenova/transformers": "^2.17.2", "@zumer/snapdom": "^2.0.1", "ai": "^6.0.41", "better-sqlite3": "^12.4.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index af3c878..1eea8b6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: '@types/markdown-it': specifier: ^14.1.2 version: 14.1.2 + '@xenova/transformers': + specifier: ^2.17.2 + version: 2.17.2 '@zumer/snapdom': specifier: ^2.0.1 version: 2.0.1 @@ -554,6 +557,10 @@ packages: '@floating-ui/vue@1.1.9': resolution: {integrity: sha512-BfNqNW6KA83Nexspgb9DZuz578R7HT8MZw1CfK9I6Ah4QReNWEJsXWHN+SdmOVLNGmTPDi+fDT535Df5PzMLbQ==} + '@huggingface/jinja@0.2.2': + resolution: {integrity: sha512-/KPde26khDUIPkTGU82jdtTW9UAuvUTumCAbFs/7giR0SxsvZC4hru51PBvpijH6BVkHcROcvZM/lpy5h1jRRA==} + engines: {node: '>=18'} + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -777,6 +784,36 @@ packages: '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.4': + resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==} + + '@protobufjs/eventemitter@1.1.0': + resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==} + + '@protobufjs/fetch@1.1.0': + resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/inquire@1.1.0': + resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.0': + resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} + '@remirror/core-constants@3.0.0': resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==} @@ -1303,6 +1340,9 @@ packages: '@types/linkify-it@5.0.0': resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} + '@types/long@4.0.2': + resolution: {integrity: sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==} + '@types/markdown-it@14.1.2': resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} @@ -1579,6 +1619,9 @@ packages: peerDependencies: vue: ^3.5.0 + '@xenova/transformers@2.17.2': + resolution: {integrity: sha512-lZmHqzrVIkSvZdKZEx7IYY51TK0WDrC8eR0c5IMnBsO8di8are1zzw8BlLhyO2TklZKLN5UffNGs1IJwT6oOqQ==} + '@xmldom/xmldom@0.8.11': resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==} engines: {node: '>=10.0.0'} @@ -1684,9 +1727,55 @@ packages: axios@1.13.2: resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==} + b4a@1.7.3: + resolution: {integrity: sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==} + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + bare-events@2.8.2: + resolution: {integrity: sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==} + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + + bare-fs@4.5.2: + resolution: {integrity: sha512-veTnRzkb6aPHOvSKIOy60KzURfBdUflr5VReI+NSaPL6xf+XLdONQgZgpYvUuZLVQ8dCqxpBAudaOM1+KpAUxw==} + engines: {bare: '>=1.16.0'} + peerDependencies: + bare-buffer: '*' + peerDependenciesMeta: + bare-buffer: + optional: true + + bare-os@3.6.2: + resolution: {integrity: sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==} + engines: {bare: '>=1.14.0'} + + bare-path@3.0.0: + resolution: {integrity: sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==} + + bare-stream@2.7.0: + resolution: {integrity: sha512-oyXQNicV1y8nc2aKffH+BUHFRXmx6VrPzlnaEvMhram0nPBrKcEdcyBg5r08D0i8VxngHFAiVyn1QKXpSG0B8A==} + peerDependencies: + bare-buffer: '*' + bare-events: '*' + peerDependenciesMeta: + bare-buffer: + optional: true + bare-events: + optional: true + + bare-url@2.3.2: + resolution: {integrity: sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -1850,6 +1939,13 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + color-string@1.9.1: + resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==} + + color@4.2.3: + resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} + engines: {node: '>=12.5.0'} + colortranslator@5.0.0: resolution: {integrity: sha512-Z3UPUKasUVDFCDYAjP2fmlVRf1jFHJv1izAmPjiOa0OCIw1W7iC8PZ2GsoDa8uZv+mKyWopxxStT9q05+27h7w==} @@ -2272,6 +2368,9 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + events-universal@1.0.1: + resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + eventsource-parser@3.0.6: resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} engines: {node: '>=18.0.0'} @@ -2305,6 +2404,9 @@ packages: fast-diff@1.3.0: resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} @@ -2352,6 +2454,9 @@ packages: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} + flatbuffers@1.12.0: + resolution: {integrity: sha512-c7CZADjRcl6j0PlvFy0ZqXQ67qSEZfrVPynmnL+2zPc+NtMvrF8Y0QceMo7QqnSPc7+uWjUIAbvCQ5WIKlMVdQ==} + flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} @@ -2529,6 +2634,9 @@ packages: graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + guid-typescript@1.0.9: + resolution: {integrity: sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==} + h3@1.15.4: resolution: {integrity: sha512-z5cFQWDffyOe4vQ9xIqNfCZdV4p//vy6fBnr8Q1AWnVZ0teurKMG66rLj++TKwKPUP3u7iMUvrvKaEUiQw2QWQ==} @@ -2625,6 +2733,9 @@ packages: iron-webcrypto@1.2.1: resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} + is-arrayish@0.3.4: + resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -2859,6 +2970,9 @@ packages: resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} engines: {node: '>=10'} + long@4.0.0: + resolution: {integrity: sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==} + lowercase-keys@2.0.0: resolution: {integrity: sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==} engines: {node: '>=8'} @@ -3067,6 +3181,9 @@ packages: node-addon-api@1.7.2: resolution: {integrity: sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==} + node-addon-api@6.1.0: + resolution: {integrity: sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==} + node-api-version@0.2.1: resolution: {integrity: sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==} @@ -3130,6 +3247,19 @@ packages: resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} engines: {node: '>=12'} + onnx-proto@4.0.4: + resolution: {integrity: sha512-aldMOB3HRoo6q/phyB6QRQxSt895HNNw82BNyZ2CMh4bjeKv7g/c+VpAFtJuEMVfYLMbRx61hbuqnKceLeDcDA==} + + onnxruntime-common@1.14.0: + resolution: {integrity: sha512-3LJpegM2iMNRX2wUmtYfeX/ytfOzNwAWKSq1HbRrKc9+uqG/FsEA0bbKZl1btQeZaXhC26l44NWpNUeXPII7Ew==} + + onnxruntime-node@1.14.0: + resolution: {integrity: sha512-5ba7TWomIV/9b6NH/1x/8QEeowsb+jBEvFzU6z0T4mNsFwdPqXeFUM7uxC6QeSRkEbWu3qEB0VMjrvzN/0S9+w==} + os: [win32, darwin, linux] + + onnxruntime-web@1.14.0: + resolution: {integrity: sha512-Kcqf43UMfW8mCydVGcX9OMXI2VN17c0p6XvR7IPSZzBf/6lteBzXHvcEVWDPmCKuGombl997HgLqj91F11DzXw==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -3253,6 +3383,9 @@ packages: pkg-types@2.3.0: resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + platform@1.3.6: + resolution: {integrity: sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==} + plist@3.1.0: resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} engines: {node: '>=10.4.0'} @@ -3358,6 +3491,10 @@ packages: prosemirror-view@1.41.4: resolution: {integrity: sha512-WkKgnyjNncri03Gjaz3IFWvCAE94XoiEgvtr0/r2Xw7R8/IjK3sKLSiDoCHWcsXSAinVaKlGRZDvMCsF1kbzjA==} + protobufjs@6.11.4: + resolution: {integrity: sha512-5kQWPaJHi1WoCpjTGszzQ32PG2F4+wRY6BmAT4Vfw56Q2FZ4YZzK20xUYQH4YkfehY1e6QSICrJquM6xXZNcrw==} + hasBin: true + proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} @@ -3508,6 +3645,10 @@ packages: resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==} engines: {node: '>=10'} + sharp@0.32.6: + resolution: {integrity: sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w==} + engines: {node: '>=14.15.0'} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -3529,6 +3670,9 @@ packages: simple-get@4.0.1: resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + simple-swizzle@0.2.4: + resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==} + simple-update-notifier@2.0.0: resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} engines: {node: '>=10'} @@ -3592,6 +3736,9 @@ packages: stream-json@1.9.1: resolution: {integrity: sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw==} + streamx@2.23.0: + resolution: {integrity: sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -3665,10 +3812,16 @@ packages: tar-fs@2.1.4: resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} + tar-fs@3.1.1: + resolution: {integrity: sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==} + tar-stream@2.2.0: resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} engines: {node: '>=6'} + tar-stream@3.1.7: + resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==} + tar@6.2.1: resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} engines: {node: '>=10'} @@ -3684,6 +3837,9 @@ packages: resolution: {integrity: sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==} engines: {node: '>=6.0.0'} + text-decoder@1.2.3: + resolution: {integrity: sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==} + tiny-async-pool@1.3.0: resolution: {integrity: sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==} @@ -4555,6 +4711,8 @@ snapshots: - '@vue/composition-api' - vue + '@huggingface/jinja@0.2.2': {} + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': @@ -4987,6 +5145,29 @@ snapshots: '@polka/url@1.0.0-next.29': {} + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.4': {} + + '@protobufjs/eventemitter@1.1.0': {} + + '@protobufjs/fetch@1.1.0': + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/inquire': 1.1.0 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/inquire@1.1.0': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.0': {} + '@remirror/core-constants@3.0.0': {} '@rollup/pluginutils@5.3.0(rollup@4.55.1)': @@ -5437,6 +5618,8 @@ snapshots: '@types/linkify-it@5.0.0': {} + '@types/long@4.0.2': {} + '@types/markdown-it@14.1.2': dependencies: '@types/linkify-it': 5.0.0 @@ -5773,6 +5956,18 @@ snapshots: dependencies: vue: 3.5.26(typescript@5.9.3) + '@xenova/transformers@2.17.2': + dependencies: + '@huggingface/jinja': 0.2.2 + onnxruntime-web: 1.14.0 + sharp: 0.32.6 + optionalDependencies: + onnxruntime-node: 1.14.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + '@xmldom/xmldom@0.8.11': {} '@zumer/snapdom@2.0.1': {} @@ -5894,8 +6089,47 @@ snapshots: transitivePeerDependencies: - debug + b4a@1.7.3: {} + balanced-match@1.0.2: {} + bare-events@2.8.2: {} + + bare-fs@4.5.2: + dependencies: + bare-events: 2.8.2 + bare-path: 3.0.0 + bare-stream: 2.7.0(bare-events@2.8.2) + bare-url: 2.3.2 + fast-fifo: 1.3.2 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + optional: true + + bare-os@3.6.2: + optional: true + + bare-path@3.0.0: + dependencies: + bare-os: 3.6.2 + optional: true + + bare-stream@2.7.0(bare-events@2.8.2): + dependencies: + streamx: 2.23.0 + optionalDependencies: + bare-events: 2.8.2 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + optional: true + + bare-url@2.3.2: + dependencies: + bare-path: 3.0.0 + optional: true + base64-js@1.5.1: {} baseline-browser-mapping@2.9.12: {} @@ -6096,6 +6330,16 @@ snapshots: color-name@1.1.4: {} + color-string@1.9.1: + dependencies: + color-name: 1.1.4 + simple-swizzle: 0.2.4 + + color@4.2.3: + dependencies: + color-convert: 2.0.1 + color-string: 1.9.1 + colortranslator@5.0.0: {} combined-stream@1.0.8: @@ -6592,6 +6836,12 @@ snapshots: esutils@2.0.3: {} + events-universal@1.0.1: + dependencies: + bare-events: 2.8.2 + transitivePeerDependencies: + - bare-abort-controller + eventsource-parser@3.0.6: {} execa@8.0.1: @@ -6629,6 +6879,8 @@ snapshots: fast-diff@1.3.0: {} + fast-fifo@1.3.2: {} + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -6677,6 +6929,8 @@ snapshots: flatted: 3.3.3 keyv: 4.5.4 + flatbuffers@1.12.0: {} + flatted@3.3.3: {} follow-redirects@1.15.11: {} @@ -6926,6 +7180,8 @@ snapshots: graphemer@1.4.0: {} + guid-typescript@1.0.9: {} + h3@1.15.4: dependencies: cookie-es: 1.2.2 @@ -7022,6 +7278,8 @@ snapshots: iron-webcrypto@1.2.1: {} + is-arrayish@0.3.4: {} + is-extglob@2.1.1: {} is-fullwidth-code-point@3.0.0: {} @@ -7201,6 +7459,8 @@ snapshots: chalk: 4.1.2 is-unicode-supported: 0.1.0 + long@4.0.0: {} + lowercase-keys@2.0.0: {} lru-cache@10.4.3: {} @@ -7407,6 +7667,8 @@ snapshots: node-addon-api@1.7.2: optional: true + node-addon-api@6.1.0: {} + node-api-version@0.2.1: dependencies: semver: 7.7.3 @@ -7479,6 +7741,26 @@ snapshots: dependencies: mimic-fn: 4.0.0 + onnx-proto@4.0.4: + dependencies: + protobufjs: 6.11.4 + + onnxruntime-common@1.14.0: {} + + onnxruntime-node@1.14.0: + dependencies: + onnxruntime-common: 1.14.0 + optional: true + + onnxruntime-web@1.14.0: + dependencies: + flatbuffers: 1.12.0 + guid-typescript: 1.0.9 + long: 4.0.0 + onnx-proto: 4.0.4 + onnxruntime-common: 1.14.0 + platform: 1.3.6 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -7583,6 +7865,8 @@ snapshots: exsolve: 1.0.8 pathe: 2.0.3 + platform@1.3.6: {} + plist@3.1.0: dependencies: '@xmldom/xmldom': 0.8.11 @@ -7740,6 +8024,22 @@ snapshots: prosemirror-state: 1.4.4 prosemirror-transform: 1.10.5 + protobufjs@6.11.4: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.4 + '@protobufjs/eventemitter': 1.1.0 + '@protobufjs/fetch': 1.1.0 + '@protobufjs/float': 1.0.2 + '@protobufjs/inquire': 1.1.0 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.0 + '@types/long': 4.0.2 + '@types/node': 25.0.3 + long: 4.0.0 + proxy-from-env@1.1.0: {} pump@3.0.3: @@ -7910,6 +8210,21 @@ snapshots: type-fest: 0.13.1 optional: true + sharp@0.32.6: + dependencies: + color: 4.2.3 + detect-libc: 2.1.2 + node-addon-api: 6.1.0 + prebuild-install: 7.1.3 + semver: 7.7.3 + simple-get: 4.0.1 + tar-fs: 3.1.1 + tunnel-agent: 0.6.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -7928,6 +8243,10 @@ snapshots: once: 1.4.0 simple-concat: 1.0.1 + simple-swizzle@0.2.4: + dependencies: + is-arrayish: 0.3.4 + simple-update-notifier@2.0.0: dependencies: semver: 7.7.3 @@ -7990,6 +8309,15 @@ snapshots: dependencies: stream-chain: 2.2.5 + streamx@2.23.0: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.3 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -8061,6 +8389,18 @@ snapshots: pump: 3.0.3 tar-stream: 2.2.0 + tar-fs@3.1.1: + dependencies: + pump: 3.0.3 + tar-stream: 3.1.7 + optionalDependencies: + bare-fs: 4.5.2 + bare-path: 3.0.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + tar-stream@2.2.0: dependencies: bl: 4.1.0 @@ -8069,6 +8409,15 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + tar-stream@3.1.7: + dependencies: + b4a: 1.7.3 + fast-fifo: 1.3.2 + streamx: 2.23.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + tar@6.2.1: dependencies: chownr: 2.0.0 @@ -8096,6 +8445,12 @@ snapshots: mkdirp: 0.5.6 rimraf: 2.6.3 + text-decoder@1.2.3: + dependencies: + b4a: 1.7.3 + transitivePeerDependencies: + - react-native-b4a + tiny-async-pool@1.3.0: dependencies: semver: 5.7.2 diff --git a/src/components/common/settings/AI/EmbeddingConfigEditModal.vue b/src/components/common/settings/AI/EmbeddingConfigEditModal.vue new file mode 100644 index 0000000..628c3f5 --- /dev/null +++ b/src/components/common/settings/AI/EmbeddingConfigEditModal.vue @@ -0,0 +1,304 @@ + + + + diff --git a/src/components/common/settings/AI/RAGConfigTab.vue b/src/components/common/settings/AI/RAGConfigTab.vue new file mode 100644 index 0000000..7f517f0 --- /dev/null +++ b/src/components/common/settings/AI/RAGConfigTab.vue @@ -0,0 +1,253 @@ + + + diff --git a/src/components/common/settings/AISettingsTab.vue b/src/components/common/settings/AISettingsTab.vue index eecc3b1..60806d1 100644 --- a/src/components/common/settings/AISettingsTab.vue +++ b/src/components/common/settings/AISettingsTab.vue @@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n' import AIModelConfigTab from './AI/AIModelConfigTab.vue' import AIPromptConfigTab from './AI/AIPromptConfigTab.vue' import AIPromptPresetTab from './AI/AIPromptPresetTab.vue' +import RAGConfigTab from './AI/RAGConfigTab.vue' import SubTabs from '@/components/UI/SubTabs.vue' import { useSubTabsScroll } from '@/composables/useSubTabsScroll' @@ -17,6 +18,7 @@ const emit = defineEmits<{ // 导航配置 const navItems = computed(() => [ { id: 'model', label: t('settings.tabs.aiConfig') }, + { id: 'rag', label: t('settings.tabs.aiRAG') }, { id: 'chat', label: t('settings.tabs.aiPrompt') }, { id: 'preset', label: t('settings.tabs.aiPreset') }, ]) @@ -65,6 +67,14 @@ void aiModelConfigRef
+ +
+ +
+ + +
+
diff --git a/src/composables/useAIChat.ts b/src/composables/useAIChat.ts index 55a5eef..cd33950 100644 --- a/src/composables/useAIChat.ts +++ b/src/composables/useAIChat.ts @@ -87,6 +87,7 @@ const TOOL_DISPLAY_NAMES_I18N: Record> = { get_member_name_history: '获取昵称历史', get_conversation_between: '获取对话记录', get_message_context: '获取上下文', + semantic_search_messages: '🔍 语义搜索', }, 'en-US': { search_messages: 'Search Messages', @@ -97,6 +98,7 @@ const TOOL_DISPLAY_NAMES_I18N: Record> = { get_member_name_history: 'Get Nickname History', get_conversation_between: 'Get Conversation', get_message_context: 'Get Message Context', + semantic_search_messages: '🔍 Semantic Search', }, } diff --git a/src/i18n/locales/en-US/settings.json b/src/i18n/locales/en-US/settings.json index fde185b..e817acf 100644 --- a/src/i18n/locales/en-US/settings.json +++ b/src/i18n/locales/en-US/settings.json @@ -4,6 +4,7 @@ "basic": "General", "ai": "AI Settings", "aiConfig": "AI Models", + "aiRAG": "Semantic Search", "aiPrompt": "Chat Config", "aiPreset": "Prompts", "storage": "Data & Storage", @@ -208,5 +209,40 @@ "analytics": "Anonymous Usage Statistics", "analyticsDesc": "When enabled, the app collects non-sensitive data like version number and OS version to help improve the product" } + }, + "embedding": { + "title": "Semantic Search", + "description": "Uses embedding vectors to understand question meaning, suitable for analyzing relationships, emotions, and abstract topics", + "configList": "Embedding Configs", + "addConfig": "Add Config", + "editConfig": "Edit Config", + "noConfigs": "No configs yet, click the button above to add", + "active": "Active", + "setActive": "Set Active", + "deleteConfirm": "Delete config \"{name}\"?", + "configName": "Config Name", + "configNamePlaceholder": "e.g. Ollama Embedding", + "apiSource": "API Source", + "apiSourceHint": "\"Reuse AI Config\" will use the endpoint and key from the active AI model config", + "reuseLLM": "Reuse AI Config", + "customAPI": "Custom API", + "model": "Model Name", + "modelPlaceholder": "e.g. nomic-embed-text", + "modelHint": "Ollama common: nomic-embed-text, mxbai-embed-large", + "baseUrl": "API Endpoint", + "baseUrlPlaceholder": "e.g. http://localhost:11434/v1", + "apiKey": "API Key", + "apiKeyPlaceholder": "Enter API Key", + "optional": "(Optional)", + "validate": "Test Connection", + "validateSuccess": "Connection successful!", + "validateFailed": "Connection failed", + "saveFailed": "Save failed", + "vectorStore": "Vector Cache", + "vectorStoreDesc": "Cache computed vectors to avoid recalculation and improve speed", + "cached": "Cached", + "size": "Size", + "clear": "Clear", + "clearVectorStoreConfirm": "Clear all vector cache? This will cause recalculation on next search." } } diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index 678bfe0..179eada 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -4,6 +4,7 @@ "basic": "基础设置", "ai": "AI 设置", "aiConfig": "模型配置", + "aiRAG": "语义搜索", "aiPrompt": "对话配置", "aiPreset": "提示词配置", "storage": "数据和存储", @@ -208,5 +209,40 @@ "analytics": "匿名使用统计", "analyticsDesc": "开启后,软件会收集版本号、操作系统版本等非敏感数据,用于帮助优化产品" } + }, + "embedding": { + "title": "语义搜索", + "description": "通过 Embedding 向量相似度理解问题含义,适合分析关系、情感、抽象话题等场景", + "configList": "Embedding 配置", + "addConfig": "添加配置", + "editConfig": "编辑配置", + "noConfigs": "暂无配置,点击上方按钮添加", + "active": "使用中", + "setActive": "设为使用", + "deleteConfirm": "确定删除配置「{name}」吗?", + "configName": "配置名称", + "configNamePlaceholder": "如:Ollama Embedding", + "apiSource": "API 来源", + "apiSourceHint": "「复用 AI 配置」将使用当前激活的 AI 模型配置的端点和密钥", + "reuseLLM": "复用 AI 配置", + "customAPI": "自定义 API", + "model": "模型名称", + "modelPlaceholder": "如 nomic-embed-text", + "modelHint": "Ollama 常用:nomic-embed-text、mxbai-embed-large", + "baseUrl": "API 端点", + "baseUrlPlaceholder": "如 http://localhost:11434/v1", + "apiKey": "API Key", + "apiKeyPlaceholder": "输入 API Key", + "optional": "(可选)", + "validate": "测试连接", + "validateSuccess": "连接成功!", + "validateFailed": "连接失败", + "saveFailed": "保存失败", + "vectorStore": "向量缓存", + "vectorStoreDesc": "缓存已计算的向量,避免重复计算提高速度", + "cached": "已缓存", + "size": "占用", + "clear": "清空", + "clearVectorStoreConfirm": "确定清空所有向量缓存吗?这将导致下次搜索时重新计算。" } } diff --git a/src/stores/embedding.ts b/src/stores/embedding.ts new file mode 100644 index 0000000..a9165c9 --- /dev/null +++ b/src/stores/embedding.ts @@ -0,0 +1,192 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import type { EmbeddingServiceConfigDisplay } from '@electron/preload/index' + +/** + * Embedding 配置状态管理 + * 集中管理 Embedding 配置的获取、切换和刷新 + */ +export const useEmbeddingStore = defineStore('embedding', () => { + // ============ 状态 ============ + + /** 所有配置列表 */ + const configs = ref([]) + + /** 当前激活配置 ID */ + const activeConfigId = ref(null) + + /** 是否启用语义搜索 */ + const enabled = ref(false) + + /** 是否正在加载 */ + const isLoading = ref(false) + + /** 是否已初始化 */ + const isInitialized = ref(false) + + /** 向量存储统计 */ + const vectorStoreStats = ref<{ + enabled: boolean + count?: number + sizeBytes?: number + }>({ enabled: false }) + + // ============ 计算属性 ============ + + /** 当前激活的配置 */ + const activeConfig = computed(() => configs.value.find((c) => c.id === activeConfigId.value) || null) + + /** 是否有可用配置 */ + const hasConfig = computed(() => configs.value.length > 0) + + /** 是否达到最大配置数量 */ + const isMaxConfigs = computed(() => configs.value.length >= 10) + + /** 格式化的存储大小 */ + const vectorStoreSizeFormatted = computed(() => { + const bytes = vectorStoreStats.value.sizeBytes ?? 0 + if (bytes === 0) return '0 B' + const k = 1024 + const sizes = ['B', 'KB', 'MB', 'GB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] + }) + + // ============ 方法 ============ + + /** + * 初始化加载配置(仅首次调用生效) + */ + async function init() { + if (isInitialized.value) return + await loadConfigs() + isInitialized.value = true + } + + /** + * 加载所有配置 + */ + async function loadConfigs() { + isLoading.value = true + try { + const [configsData, activeId, isEnabled, stats] = await Promise.all([ + window.embeddingApi.getAllConfigs(), + window.embeddingApi.getActiveConfigId(), + window.embeddingApi.isEnabled(), + window.embeddingApi.getVectorStoreStats(), + ]) + configs.value = configsData + activeConfigId.value = activeId + enabled.value = isEnabled + vectorStoreStats.value = stats + } catch (error) { + console.error('[Embedding Store] 加载配置失败:', error) + } finally { + isLoading.value = false + } + } + + /** + * 设置语义搜索启用状态 + */ + async function setEnabled(value: boolean): Promise { + try { + const result = await window.embeddingApi.setEnabled(value) + if (result.success) { + enabled.value = value + return true + } + console.error('[Embedding Store] 设置启用状态失败:', result.error) + return false + } catch (error) { + console.error('[Embedding Store] 设置启用状态失败:', error) + return false + } + } + + /** + * 切换激活配置 + */ + async function setActiveConfig(id: string): Promise { + try { + const result = await window.embeddingApi.setActiveConfig(id) + if (result.success) { + activeConfigId.value = id + return true + } + console.error('[Embedding Store] 设置激活配置失败:', result.error) + return false + } catch (error) { + console.error('[Embedding Store] 设置激活配置失败:', error) + return false + } + } + + /** + * 删除配置 + */ + async function deleteConfig(id: string): Promise { + try { + const result = await window.embeddingApi.deleteConfig(id) + if (result.success) { + await loadConfigs() + return true + } + console.error('[Embedding Store] 删除配置失败:', result.error) + return false + } catch (error) { + console.error('[Embedding Store] 删除配置失败:', error) + return false + } + } + + /** + * 清空向量存储 + */ + async function clearVectorStore(): Promise { + try { + const result = await window.embeddingApi.clearVectorStore() + if (result.success) { + vectorStoreStats.value.count = 0 + vectorStoreStats.value.sizeBytes = 0 + return true + } + console.error('[Embedding Store] 清空向量存储失败:', result.error) + return false + } catch (error) { + console.error('[Embedding Store] 清空向量存储失败:', error) + return false + } + } + + /** + * 刷新配置列表 + */ + async function refreshConfigs() { + await loadConfigs() + } + + return { + // 状态 + configs, + activeConfigId, + enabled, + isLoading, + isInitialized, + vectorStoreStats, + // 计算属性 + activeConfig, + hasConfig, + isMaxConfigs, + vectorStoreSizeFormatted, + // 方法 + init, + loadConfigs, + setEnabled, + setActiveConfig, + deleteConfig, + clearVectorStore, + refreshConfigs, + } +}) +