From ae9241cd33f78f1f976ca022cf5802c0df1ff2f6 Mon Sep 17 00:00:00 2001 From: Oxyel <74046599+TyChest@users.noreply.github.com> Date: Thu, 26 Feb 2026 11:50:41 +0800 Subject: [PATCH 1/6] base --- AdminPanel/image_cache_editor.html | 48 +- FileFetcherServer.js | 24 +- KnowledgeBaseManager.js | 98 +++- Plugin.js | 8 +- Plugin/ImageProcessor/image-processor.js | 116 +++- Plugin/RAGDiaryPlugin/RAGDiaryPlugin.js | 641 ++++++++++++++++++----- WebSocketServer.js | 51 +- config.env.example | 16 + modules/chatCompletionHandler.js | 217 +++++++- modules/mediaSidecarManager.js | 417 +++++++++++++++ modules/messageProcessor.js | 1 - modules/tvsManager.js | 28 +- routes/adminPanelRoutes.js | 149 +++++- server.js | 72 ++- 14 files changed, 1693 insertions(+), 193 deletions(-) create mode 100644 modules/mediaSidecarManager.js diff --git a/AdminPanel/image_cache_editor.html b/AdminPanel/image_cache_editor.html index 6b0bfb389..777ccfbbf 100644 --- a/AdminPanel/image_cache_editor.html +++ b/AdminPanel/image_cache_editor.html @@ -38,6 +38,7 @@
+
@@ -51,8 +52,10 @@

多媒体缓存列表

let mediaCacheData = {}; const mediaListDiv = document.getElementById('mediaList'); const saveButton = document.getElementById('saveButton'); + const exportButton = document.getElementById('exportButton'); saveButton.addEventListener('click', handleSave); + exportButton.addEventListener('click', handleExport); function guessMimeType(base64String) { if (base64String.startsWith('/9j/')) return 'image/jpeg'; @@ -78,10 +81,7 @@

多媒体缓存列表

} async function handleSave() { - if (Object.keys(mediaCacheData).length === 0) { - alert('没有数据可保存。'); - return; - } + // 允许空对象保存:用于“删除最后一条后清空缓存文件”的场景 // Update mediaCacheData with current textarea values const entries = mediaListDiv.getElementsByClassName('media-entry'); @@ -117,6 +117,46 @@

多媒体缓存列表

} } + async function handleExport() { + exportButton.disabled = true; + const originalText = exportButton.textContent; + exportButton.textContent = '导出中...'; + + try { + const response = await fetch('/admin_api/multimodal-cache/export'); + if (!response.ok) { + let errorMsg = `HTTP ${response.status}`; + try { + const errJson = await response.json(); + errorMsg = errJson.error || errJson.details || errorMsg; + } catch (_) {} + throw new Error(errorMsg); + } + + const blob = await response.blob(); + const disposition = response.headers.get('Content-Disposition') || ''; + const fileNameMatch = disposition.match(/filename="([^"]+)"/i); + const fileName = fileNameMatch ? fileNameMatch[1] : `multimodal_cache_export_${Date.now()}.json`; + + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = fileName; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + + alert(`导出完成:${fileName}`); + } catch (error) { + console.error('导出多媒体缓存失败:', error); + alert(`导出失败: ${error.message}`); + } finally { + exportButton.disabled = false; + exportButton.textContent = originalText; + } + } + async function handleReidentify(base64Key, entryDiv) { const reidentifyBtn = entryDiv.querySelector('.reidentify-btn'); const descriptionTextarea = entryDiv.querySelector('textarea'); diff --git a/FileFetcherServer.js b/FileFetcherServer.js index 48534ddc7..de13b4bc5 100644 --- a/FileFetcherServer.js +++ b/FileFetcherServer.js @@ -89,8 +89,28 @@ async function fetchFile(fileUrl, requestIp) { console.log(`[FileFetcherServer] URL缓存未命中: ${fileUrl}。将尝试从来源服务器获取。`); } - // 2. 缓存未命中,直接从来源的分布式服务器获取 - // 已移除:在主服务器上直接读取本地文件的尝试,因为这是逻辑缺陷。 + // 2. 缓存未命中,优先尝试主服务器本地读取(单机/本机客户端场景) + // 若本地不存在或不可读,再回退到分布式超栈追踪链路。 + try { + const localFilePath = fileURLToPath(fileUrl); + const localBuffer = await fs.readFile(localFilePath); + const localMimeType = mime.lookup(localFilePath) || 'application/octet-stream'; + console.log(`[FileFetcherServer] 本地直读成功: ${localFilePath}`); + + try { + await fs.writeFile(cachedFilePath, localBuffer); + console.log(`[FileFetcherServer] 已将本地文件缓存到: ${cachedFilePath}`); + } catch (writeError) { + console.error(`[FileFetcherServer] 本地文件缓存写入失败: ${writeError.message}`); + } + + return { buffer: localBuffer, mimeType: localMimeType }; + } catch (localReadError) { + const localErrorCode = localReadError && localReadError.code ? localReadError.code : 'UNKNOWN'; + console.log(`[FileFetcherServer] 本地直读未命中或失败 (${localErrorCode}),回退到分布式拉取: ${fileUrl}`); + } + + // 3. 本地直读失败后,回退到来源分布式服务器获取 // --- 失败缓存逻辑 (保留) --- const cachedFailure = failedFetchCache.get(fileUrl); diff --git a/KnowledgeBaseManager.js b/KnowledgeBaseManager.js index c7680e9bd..758822847 100644 --- a/KnowledgeBaseManager.js +++ b/KnowledgeBaseManager.js @@ -48,6 +48,8 @@ class KnowledgeBaseManager { tagBlacklistSuper: (process.env.TAG_BLACKLIST_SUPER || '').split(',').map(t => t.trim()).filter(Boolean), tagExpandMaxCount: parseInt(process.env.TAG_EXPAND_MAX_COUNT, 10) || 30, fullScanOnStartup: (process.env.KNOWLEDGEBASE_FULL_SCAN_ON_STARTUP || 'true').toLowerCase() === 'true', + multimodalIndexEnabled: (process.env.KNOWLEDGEBASE_MULTIMODAL_INDEX || 'false').toLowerCase() === 'true', + sidecarSuffix: process.env.MULTIMODAL_SIDECAR_SUFFIX || '.vcpmeta.json', // 语言置信度补偿配置 langConfidenceEnabled: (process.env.LANG_CONFIDENCE_GATING_ENABLED || 'true').toLowerCase() === 'true', langPenaltyUnknown: parseFloat(process.env.LANG_PENALTY_UNKNOWN) || 0.05, @@ -947,29 +949,50 @@ class KnowledgeBaseManager { } } + notifyFileChanged(filePath) { + if (!filePath || typeof filePath !== 'string') return false; + + const normalizedPath = path.resolve(filePath); + const relPath = path.relative(this.config.rootPath, normalizedPath); + if (!relPath || relPath.startsWith('..')) return false; + + const parts = relPath.split(path.sep); + const diaryName = parts.length > 1 ? parts[0] : 'Root'; + + if (this.config.ignoreFolders.includes(diaryName)) return false; + const fileName = path.basename(relPath); + if (this.config.ignorePrefixes.some(prefix => fileName.startsWith(prefix))) return false; + if (this.config.ignoreSuffixes.some(suffix => fileName.endsWith(suffix))) return false; + if (!this._isIndexableFile(normalizedPath)) return false; + + this.pendingFiles.add(normalizedPath); + if (this.pendingFiles.size >= this.config.maxBatchSize) { + this._flushBatch(); + } else { + this._scheduleBatch(); + } + return true; + } + + async notifyFileDeleted(filePath) { + if (!filePath || typeof filePath !== 'string') return false; + + const normalizedPath = path.resolve(filePath); + const relPath = path.relative(this.config.rootPath, normalizedPath); + if (!relPath || relPath.startsWith('..')) return false; + + if (!this._isIndexableFile(normalizedPath)) return false; + await this._handleDelete(normalizedPath); + return true; + } + _startWatcher() { if (!this.watcher) { const handleFile = (filePath) => { - const relPath = path.relative(this.config.rootPath, filePath); - // 提取第一级目录作为日记本名称 - const parts = relPath.split(path.sep); - const diaryName = parts.length > 1 ? parts[0] : 'Root'; - - if (this.config.ignoreFolders.includes(diaryName)) return; - const fileName = path.basename(relPath); - if (this.config.ignorePrefixes.some(prefix => fileName.startsWith(prefix))) return; - if (this.config.ignoreSuffixes.some(suffix => fileName.endsWith(suffix))) return; - if (!filePath.match(/\.(md|txt)$/i)) return; - - this.pendingFiles.add(filePath); - if (this.pendingFiles.size >= this.config.maxBatchSize) { - this._flushBatch(); - } else { - this._scheduleBatch(); - } + this.notifyFileChanged(filePath); }; this.watcher = chokidar.watch(this.config.rootPath, { ignored: /(^|[\/\\])\../, ignoreInitial: !this.config.fullScanOnStartup }); - this.watcher.on('add', handleFile).on('change', handleFile).on('unlink', fp => this._handleDelete(fp)); + this.watcher.on('add', handleFile).on('change', handleFile).on('unlink', fp => this.notifyFileDeleted(fp)); } } @@ -1002,7 +1025,9 @@ class KnowledgeBaseManager { const row = checkFile.get(relPath); if (row && row.mtime === stats.mtimeMs && row.size === stats.size) return; - const content = await fs.readFile(filePath, 'utf-8'); + const content = await this._readIndexableContent(filePath); + if (!content) return; + const checksum = crypto.createHash('md5').update(content).digest('hex'); if (row && row.checksum === checksum) { @@ -1273,6 +1298,41 @@ class KnowledgeBaseManager { } catch (e) { console.error(`[KnowledgeBase] Save failed for ${name}:`, e); } } + _isSidecarFile(filePath) { + return filePath.toLowerCase().endsWith((this.config.sidecarSuffix || '.vcpmeta.json').toLowerCase()); + } + + _isIndexableFile(filePath) { + if (filePath.match(/\.(md|txt)$/i)) return true; + if (!this.config.multimodalIndexEnabled) return false; + return this._isSidecarFile(filePath); + } + + async _readIndexableContent(filePath) { + if (!this._isSidecarFile(filePath)) { + return await fs.readFile(filePath, 'utf-8'); + } + + try { + const raw = await fs.readFile(filePath, 'utf-8'); + const sidecar = JSON.parse(raw); + const description = typeof sidecar.description === 'string' ? sidecar.description.trim() : ''; + const tagsArray = Array.isArray(sidecar.tags) + ? sidecar.tags.map(t => (typeof t === 'string' ? t.trim() : '')).filter(Boolean) + : []; + + let merged = description; + if (tagsArray.length > 0 && !/^\s*Tag:/im.test(merged)) { + merged = `${merged}${merged ? '\n' : ''}Tag: ${tagsArray.join(', ')}`; + } + + return this._prepareTextForEmbedding(merged) === '[EMPTY_CONTENT]' ? null : merged; + } catch (error) { + console.warn(`[KnowledgeBase] Failed to parse sidecar file "${filePath}": ${error.message}`); + return null; + } + } + _extractTags(content) { // 增强型正则:支持多行 Tag 提取,并兼容多种分隔符 (中英文逗号、分号、顿号、竖线) const tagLines = content.match(/Tag:\s*(.+)$/gim); diff --git a/Plugin.js b/Plugin.js index 4a7ca792e..bd1c53681 100755 --- a/Plugin.js +++ b/Plugin.js @@ -344,7 +344,7 @@ class PluginManager { return `[Invalid value format for placeholder ${placeholder}]`; } - async executeMessagePreprocessor(pluginName, messages) { + async executeMessagePreprocessor(pluginName, messages, runtimeConfig = {}) { const processorModule = this.messagePreprocessors.get(pluginName); const pluginManifest = this.plugins.get(pluginName); if (!processorModule || !pluginManifest) { @@ -358,7 +358,11 @@ class PluginManager { try { if (this.debugMode) console.log(`[PluginManager] Executing message preprocessor: ${pluginName}`); const pluginSpecificConfig = this._getPluginConfig(pluginManifest); - const processedMessages = await processorModule.processMessages(messages, pluginSpecificConfig); + const mergedConfig = { + ...pluginSpecificConfig, + ...(runtimeConfig && typeof runtimeConfig === 'object' ? runtimeConfig : {}) + }; + const processedMessages = await processorModule.processMessages(messages, mergedConfig); if (this.debugMode) console.log(`[PluginManager] Message preprocessor ${pluginName} finished.`); return processedMessages; } catch (error) { diff --git a/Plugin/ImageProcessor/image-processor.js b/Plugin/ImageProcessor/image-processor.js index 0f5f5fdc4..c054065c7 100644 --- a/Plugin/ImageProcessor/image-processor.js +++ b/Plugin/ImageProcessor/image-processor.js @@ -41,6 +41,29 @@ async function saveMediaCacheToFile() { } } +function _resolveMediaPath(cacheEntry, mediaIndexForLabel) { + if (cacheEntry && typeof cacheEntry.filePath === 'string' && cacheEntry.filePath.trim()) { + return cacheEntry.filePath.trim(); + } + if (cacheEntry && typeof cacheEntry.id === 'string' && cacheEntry.id.trim()) { + return `file://multimodal_cache/${cacheEntry.id.trim()}`; + } + return `file://multimodal_cache/media_${mediaIndexForLabel + 1}`; +} + +function _formatStructuredMediaInfo(description, cacheEntry, mediaIndexForLabel) { + const safeDescription = (description || '').replace(/\s+/g, ' ').trim(); + const mediaPath = _resolveMediaPath(cacheEntry, mediaIndexForLabel); + return JSON.stringify( + { + description: safeDescription, + filePath: mediaPath + }, + null, + 2 + ); +} + async function translateMediaAndCacheInternal(base64DataWithPrefix, mediaIndexForLabel, currentConfig) { const { default: fetch } = await import('node-fetch'); const base64PrefixPattern = /^data:(image|audio|video)\/[^;]+;base64,/; @@ -49,15 +72,27 @@ async function translateMediaAndCacheInternal(base64DataWithPrefix, mediaIndexFo const cachedEntry = mediaBase64Cache[pureBase64Data]; if (cachedEntry) { - const description = typeof cachedEntry === 'string' ? cachedEntry : cachedEntry.description; // Handle old and new cache format + const description = typeof cachedEntry === 'string' ? cachedEntry : cachedEntry.description; + const normalizedEntry = typeof cachedEntry === 'string' + ? { id: crypto.randomUUID(), description, timestamp: new Date().toISOString(), mimeType: mediaMimeType } + : cachedEntry; console.log(`[MultiModalProcessor] Cache hit for media ${mediaIndexForLabel + 1}.`); - return `[MULTIMODAL_DATA_${mediaIndexForLabel + 1}_Info: ${description}]`; + return { + inlineText: `[MULTIMODAL_DATA_${mediaIndexForLabel + 1}_Info: ${description}]`, + structuredText: _formatStructuredMediaInfo(description, normalizedEntry, mediaIndexForLabel), + cacheEntry: normalizedEntry + }; } console.log(`[MultiModalProcessor] Translating media ${mediaIndexForLabel + 1}...`); if (!currentConfig.MultiModalModel || !currentConfig.MultiModalPrompt || !currentConfig.API_Key || !currentConfig.API_URL) { console.error('[MultiModalProcessor] Multimodal translation config incomplete.'); - return `[MULTIMODAL_DATA_${mediaIndexForLabel + 1}_Info: 多模态数据转译服务配置不完整]`; + const failText = '[多模态数据转译服务配置不完整]'; + return { + inlineText: `[MULTIMODAL_DATA_${mediaIndexForLabel + 1}_Info: ${failText}]`, + structuredText: _formatStructuredMediaInfo(failText, null, mediaIndexForLabel), + cacheEntry: null + }; } const maxRetries = 3; @@ -106,7 +141,11 @@ async function translateMediaAndCacheInternal(base64DataWithPrefix, mediaIndexFo }; mediaBase64Cache[pureBase64Data] = newCacheEntry; await saveMediaCacheToFile(); - return `[MULTIMODAL_DATA_${mediaIndexForLabel + 1}_Info: ${cleanedDescription}]`; + return { + inlineText: `[MULTIMODAL_DATA_${mediaIndexForLabel + 1}_Info: ${cleanedDescription}]`, + structuredText: _formatStructuredMediaInfo(cleanedDescription, newCacheEntry, mediaIndexForLabel), + cacheEntry: newCacheEntry + }; } else if (description) { lastError = new Error(`Description too short (length: ${description.length}, attempt ${attempt}).`); } else { @@ -118,8 +157,14 @@ async function translateMediaAndCacheInternal(base64DataWithPrefix, mediaIndexFo } if (attempt < maxRetries) await new Promise(resolve => setTimeout(resolve, 500)); } + console.error(`[MultiModalProcessor] Failed to translate media ${mediaIndexForLabel + 1} after ${maxRetries} attempts.`); - return `[MULTIMODAL_DATA_${mediaIndexForLabel + 1}_Info: 多模态数据转译失败: ${lastError ? lastError.message.substring(0,100) : '未知错误'}]`; + const failReason = `多模态数据转译失败: ${lastError ? lastError.message.substring(0, 100) : '未知错误'}`; + return { + inlineText: `[MULTIMODAL_DATA_${mediaIndexForLabel + 1}_Info: ${failReason}]`, + structuredText: _formatStructuredMediaInfo(failReason, null, mediaIndexForLabel), + cacheEntry: null + }; } module.exports = { @@ -132,10 +177,15 @@ module.exports = { // Called by Plugin.js for each relevant request async processMessages(messages, requestConfig = {}) { - // Merge base config with request-specific config const currentConfig = { ...pluginConfig, ...requestConfig }; + const transModeRaw = (currentConfig.TransBase64Mode || 'default').toString().toLowerCase(); + const transMode = ['default', 'plus', 'minus'].includes(transModeRaw) ? transModeRaw : 'default'; + const cognitoAgents = Array.isArray(currentConfig.TransBase64CognitoAgents) + ? currentConfig.TransBase64CognitoAgents.filter(item => typeof item === 'string' && item.trim()) + : []; + let globalMediaIndexForLabel = 0; - const processedMessages = JSON.parse(JSON.stringify(messages)); // Deep copy + const processedMessages = JSON.parse(JSON.stringify(messages)); for (let i = 0; i < processedMessages.length; i++) { const msg = processedMessages[i]; @@ -154,7 +204,8 @@ module.exports = { } if (mediaPartsToTranslate.length > 0) { - const allTranslatedMediaTexts = []; + const translatedInlineTexts = []; + const translatedStructuredTexts = []; const asyncLimit = currentConfig.MultiModalModelAsynchronousLimit || 1; for (let j = 0; j < mediaPartsToTranslate.length; j += asyncLimit) { @@ -162,20 +213,53 @@ module.exports = { const translationPromisesInChunk = chunkToTranslate.map((base64Url) => translateMediaAndCacheInternal(base64Url, globalMediaIndexForLabel++, currentConfig) ); - const translatedTextsInChunk = await Promise.all(translationPromisesInChunk); - allTranslatedMediaTexts.push(...translatedTextsInChunk); + const translatedResultsInChunk = await Promise.all(translationPromisesInChunk); + for (const result of translatedResultsInChunk) { + translatedInlineTexts.push(result.inlineText); + translatedStructuredTexts.push(result.structuredText); + } + } + + if (transMode === 'minus') { + const markerId = crypto.randomUUID(); + const beginMarker = `[TRANSBASE64_MINUS_BEGIN_${markerId}]`; + const endMarker = `[TRANSBASE64_MINUS_END_${markerId}]`; + const agentsLine = `[CognitoAgents: ${cognitoAgents.length > 0 ? cognitoAgents.join(', ') : 'Cognito-Core'}]`; + const hiddenBlock = `${beginMarker}\n${agentsLine}\n${translatedStructuredTexts.join('\n')}\n${endMarker}`; + + let userTextPart = contentWithoutMedia.find(p => p.type === 'text'); + if (!userTextPart) { + userTextPart = { type: 'text', text: '' }; + contentWithoutMedia.unshift(userTextPart); + } + userTextPart.text = (userTextPart.text ? userTextPart.text.trim() + '\n' : '') + hiddenBlock; + msg.content = contentWithoutMedia; + continue; } - let userTextPart = contentWithoutMedia.find(p => p.type === 'text'); - if (!userTextPart) { - userTextPart = { type: 'text', text: '' }; - contentWithoutMedia.unshift(userTextPart); + let userTextPart; + if (transMode === 'plus') { + userTextPart = msg.content.find(p => p.type === 'text'); + if (!userTextPart) { + userTextPart = { type: 'text', text: '' }; + msg.content.unshift(userTextPart); + } + } else { + userTextPart = contentWithoutMedia.find(p => p.type === 'text'); + if (!userTextPart) { + userTextPart = { type: 'text', text: '' }; + contentWithoutMedia.unshift(userTextPart); + } } + const insertPrompt = currentConfig.MediaInsertPrompt || "[多模态数据信息已提取:]"; userTextPart.text = (userTextPart.text ? userTextPart.text.trim() + '\n' : '') + insertPrompt + '\n' + - allTranslatedMediaTexts.join('\n'); - msg.content = contentWithoutMedia; + translatedInlineTexts.join('\n'); + + if (transMode === 'default') { + msg.content = contentWithoutMedia; + } } } } diff --git a/Plugin/RAGDiaryPlugin/RAGDiaryPlugin.js b/Plugin/RAGDiaryPlugin/RAGDiaryPlugin.js index d27925e8c..46d61f233 100644 --- a/Plugin/RAGDiaryPlugin/RAGDiaryPlugin.js +++ b/Plugin/RAGDiaryPlugin/RAGDiaryPlugin.js @@ -71,6 +71,7 @@ class RAGDiaryPlugin { this.ragParams = {}; // ✅ 新增:用于存储热调控参数 this.ragParamsWatcher = null; + this.regexRuleCache = new Map(); // 注意:不在构造函数中调用 loadConfig(),而是在 initialize() 中调用 } @@ -388,36 +389,124 @@ class RAGDiaryPlugin { return result; } - async getDiaryContent(characterName) { + async getDiaryContent(characterName, retrievalOptions = null) { + return this.getDiaryContentByOptions(characterName, retrievalOptions || this._parseDiaryRetrievalOptions('')); + } + + _isTextDiaryFile(fileName) { + return /\.(txt|md)$/i.test(fileName || ''); + } + + _getSidecarSuffix() { + return (process.env.MULTIMODAL_SIDECAR_SUFFIX || '.vcpmeta.json').toLowerCase(); + } + + _isSidecarFileName(fileName) { + const suffix = this._getSidecarSuffix(); + return (fileName || '').toLowerCase().endsWith(suffix); + } + + _getSidecarPathForMedia(mediaFilePath) { + return `${mediaFilePath}${this._getSidecarSuffix()}`; + } + + _shouldIncludeFileByOptions(fileName, retrievalOptions) { + const opts = retrievalOptions || {}; + const lower = (fileName || '').toLowerCase(); + + if (Array.isArray(opts.whitelist) && opts.whitelist.length > 0) { + const whitelist = new Set(opts.whitelist.map(item => item.toLowerCase())); + if (!whitelist.has(lower)) return false; + } + + if (Array.isArray(opts.blacklist) && opts.blacklist.length > 0) { + const blacklist = new Set(opts.blacklist.map(item => item.toLowerCase())); + if (blacklist.has(lower)) return false; + } + + const isText = this._isTextDiaryFile(fileName); + if (opts.textOnly && !isText) return false; + if (opts.noText && isText) return false; + + return true; + } + + async _buildMultimodalStructuredText(mediaFilePath, mediaFileName) { + const fallback = JSON.stringify({ + description: '[多模态文件暂无侧车描述信息]', + filePath: `file://${mediaFilePath}` + }, null, 2); + + const sidecarPath = this._getSidecarPathForMedia(mediaFilePath); + try { + const raw = await fs.readFile(sidecarPath, 'utf-8'); + const sidecar = JSON.parse(raw); + let description = typeof sidecar.description === 'string' ? sidecar.description.trim() : ''; + const tags = Array.isArray(sidecar.tags) + ? sidecar.tags.map(t => (typeof t === 'string' ? t.trim() : '')).filter(Boolean) + : []; + + if (tags.length > 0 && !/^\s*Tag:/im.test(description)) { + description = `${description}${description ? '\n' : ''}Tag: ${tags.join(', ')}`; + } + + return JSON.stringify({ + description: description || '[多模态文件暂无描述]', + filePath: (typeof sidecar.filePath === 'string' && sidecar.filePath.trim()) + ? sidecar.filePath.trim() + : `file://${mediaFilePath}` + }, null, 2); + } catch (error) { + console.warn(`[RAGDiaryPlugin] 读取侧车失败(${mediaFileName}): ${error.message}`); + return fallback; + } + } + + async getDiaryContentByOptions(characterName, retrievalOptions = null, mediaFileCollector = null) { + const options = retrievalOptions || this._parseDiaryRetrievalOptions(''); const characterDirPath = path.join(dailyNoteRootPath, characterName); let characterDiaryContent = `[${characterName}日记本内容为空]`; + try { - const files = await fs.readdir(characterDirPath); - const relevantFiles = files.filter(file => { - const lowerCaseFile = file.toLowerCase(); - return lowerCaseFile.endsWith('.txt') || lowerCaseFile.endsWith('.md'); - }).sort(); - - if (relevantFiles.length > 0) { - const fileContents = await Promise.all( - relevantFiles.map(async (file) => { - const filePath = path.join(characterDirPath, file); - try { - return await fs.readFile(filePath, 'utf-8'); - } catch (readErr) { - return `[Error reading file: ${file}]`; + const entries = await fs.readdir(characterDirPath, { withFileTypes: true }); + const visibleFiles = entries + .filter(entry => entry && entry.isFile()) + .map(entry => entry.name) + .filter(name => name && !name.startsWith('.')) + .filter(name => !this._isSidecarFileName(name)) + .filter(name => this._shouldIncludeFileByOptions(name, options)) + .sort((a, b) => a.localeCompare(b, 'zh-Hans-CN')); + + if (visibleFiles.length === 0) return characterDiaryContent; + + const rendered = []; + for (const file of visibleFiles) { + const filePath = path.join(characterDirPath, file); + try { + if (this._isTextDiaryFile(file)) { + const text = await fs.readFile(filePath, 'utf-8'); + rendered.push(text); + } else { + const structured = await this._buildMultimodalStructuredText(filePath, file); + rendered.push(`[MULTIMODAL:${file}]\n${structured}`); + if (options.transMode === 'plus' && mediaFileCollector && typeof mediaFileCollector.add === 'function') { + mediaFileCollector.add(`file://${filePath}`); } - }) - ); - characterDiaryContent = fileContents.join('\n\n---\n\n'); + } + } catch (readErr) { + rendered.push(`[Error reading file: ${file}]`); + } } + + characterDiaryContent = rendered.join('\n\n---\n\n'); } catch (charDirError) { if (charDirError.code !== 'ENOENT') { console.error(`[RAGDiaryPlugin] Error reading character directory ${characterDirPath}:`, charDirError.message); } characterDiaryContent = `[无法读取“${characterName}”的日记本,可能不存在]`; } - return characterDiaryContent; + + return this._applyDiaryPostProcessing(characterDiaryContent, options); } _sigmoid(x) { @@ -987,6 +1076,7 @@ class RAGDiaryPlugin { // 3. 循环处理每个识别到的 system 消息 const newMessages = JSON.parse(JSON.stringify(messages)); const globalProcessedDiaries = new Set(); // 在最外层维护一个 Set + const pendingUserMediaDirectives = new Set(); for (const index of targetSystemMessageIndices) { console.log(`[RAGDiaryPlugin] Processing system message at index: ${index}`); const systemMessage = newMessages[index]; @@ -1006,12 +1096,17 @@ class RAGDiaryPlugin { dynamicParams.tagTruncationRatio, // 🌟 传递动态截断比例 dynamicParams.metrics, // 传递指标用于日志 historySegments, // 🌟 Tagmemo V4: 传递历史分段 - contextDiaryPrefixes // 🌟 V4.1: 传递上下文日记去重前缀 + contextDiaryPrefixes, // 🌟 V4.1: 传递上下文日记去重前缀 + pendingUserMediaDirectives ); newMessages[index].content = processedContent; } + if (lastUserMessageIndex > -1 && pendingUserMediaDirectives.size > 0) { + this._appendFileDirectivesToUserMessage(newMessages[lastUserMessageIndex], Array.from(pendingUserMediaDirectives)); + } + return newMessages; } catch (error) { console.error('[RAGDiaryPlugin] processMessages 发生严重错误:', error); @@ -1034,7 +1129,7 @@ class RAGDiaryPlugin { } // V3.0 新增: 处理单条 system 消息内容的辅助函数 - async _processSingleSystemMessage(content, queryVector, userContent, aiContent, combinedQueryForDisplay, dynamicK, timeRanges, processedDiaries, isAIMemoLicensed, dynamicTagWeight = 0.15, tagTruncationRatio = 0.5, metrics = {}, historySegments = [], contextDiaryPrefixes = new Set()) { + async _processSingleSystemMessage(content, queryVector, userContent, aiContent, combinedQueryForDisplay, dynamicK, timeRanges, processedDiaries, isAIMemoLicensed, dynamicTagWeight = 0.15, tagTruncationRatio = 0.5, metrics = {}, historySegments = [], contextDiaryPrefixes = new Set(), pendingUserMediaDirectives = new Set()) { if (!this.pushVcpInfo) { console.warn('[RAGDiaryPlugin] _processSingleSystemMessage: pushVcpInfo is null. Cannot broadcast RAG details.'); } @@ -1044,10 +1139,10 @@ class RAGDiaryPlugin { processedContent = processedContent.replace(/\[\[AIMemo=True\]\]/g, ''); const ragDeclarations = [...processedContent.matchAll(/\[\[(.*?)日记本(.*?)\]\]/g)]; - const fullTextDeclarations = [...processedContent.matchAll(/<<(.*?)日记本>>/g)]; + const fullTextDeclarations = [...processedContent.matchAll(/<<(.*?)日记本(.*?)>>/g)]; const hybridDeclarations = [...processedContent.matchAll(/《《(.*?)日记本(.*?)》》/g)]; const metaThinkingDeclarations = [...processedContent.matchAll(/\[\[VCP元思考(.*?)\]\]/g)]; - const directDiariesDeclarations = [...processedContent.matchAll(/\{\{(.*?)日记本\}\}/g)]; + const directDiariesDeclarations = [...processedContent.matchAll(/\{\{(.*?)日记本(.*?)\}\}/g)]; // --- 1. 处理 [[VCP元思考...]] 元思考链 --- for (const match of metaThinkingDeclarations) { const placeholder = match[0]; @@ -1137,6 +1232,8 @@ class RAGDiaryPlugin { if (aggregateInfo.isAggregate) { // --- 聚合模式 --- + const retrievalOptions = this._parseDiaryRetrievalOptions(modifiers); + // 核心逻辑:只有在许可证存在的情况下,::AIMemo才生效 const shouldUseAIMemo = isAIMemoLicensed && modifiers.includes('::AIMemo'); @@ -1162,7 +1259,8 @@ class RAGDiaryPlugin { metrics: metrics, historySegments: historySegments, processedDiaries: processedDiaries, - contextDiaryPrefixes // 🌟 V4.1 + contextDiaryPrefixes, // 🌟 V4.1 + retrievalOptions }); return { placeholder, content: retrievedContent }; } catch (error) { @@ -1201,7 +1299,8 @@ class RAGDiaryPlugin { tagTruncationRatio: tagTruncationRatio, // 🌟 传入截断比例 metrics: metrics, historySegments: historySegments, // 🌟 传入历史分段 - contextDiaryPrefixes // 🌟 V4.1: 传入上下文日记去重前缀 + contextDiaryPrefixes, // 🌟 V4.1: 传入上下文日记去重前缀 + retrievalOptions: this._parseDiaryRetrievalOptions(modifiers) }); return { placeholder, content: retrievedContent }; } catch (error) { @@ -1215,73 +1314,76 @@ class RAGDiaryPlugin { // --- 2. 准备 <<...>> RAG 全文检索任务 --- for (const match of fullTextDeclarations) { const placeholder = match[0]; - const dbName = match[1]; - if (processedDiaries.has(dbName)) { - console.warn(`[RAGDiaryPlugin] Detected circular reference to "${dbName}" in <<...>>. Skipping.`); - processingPromises.push(Promise.resolve({ placeholder, content: `[检测到循环引用,已跳过"${dbName}日记本"的解析]` })); - continue; - } - processedDiaries.add(dbName); + const rawName = match[1]; + const modifiers = match[2] || ''; + const aggregateInfo = this._parseAggregateSyntax(rawName, modifiers); - // ✅ 新增:为<<>>模式生成缓存键 - const cacheKey = this._generateCacheKey({ - userContent, - aiContent: aiContent || '', - dbName, - modifiers: '', // 全文模式无修饰符 - dynamicK - }); + for (const dbName of aggregateInfo.diaryNames) { + if (processedDiaries.has(dbName)) { + console.warn(`[RAGDiaryPlugin] Detected circular reference to "${dbName}" in <<...>>. Skipping.`); + processingPromises.push(Promise.resolve({ placeholder, content: `[检测到循环引用,已跳过"${dbName}日记本"的解析]` })); + continue; + } + processedDiaries.add(dbName); - // ✅ 尝试从缓存获取 - const cachedResult = this._getCachedResult(cacheKey); - if (cachedResult) { - processingPromises.push(Promise.resolve({ placeholder, content: cachedResult.content })); - continue; // ⭐ 跳过后续的阈值判断和内容读取 - } + const retrievalOptions = this._parseDiaryRetrievalOptions(modifiers); - processingPromises.push((async () => { - const diaryConfig = this.ragConfig[dbName] || {}; - const localThreshold = diaryConfig.threshold || GLOBAL_SIMILARITY_THRESHOLD; - const dbNameVector = await this.vectorDBManager.getDiaryNameVector(dbName); // <--- 使用缓存 - if (!dbNameVector) { - console.warn(`[RAGDiaryPlugin] Could not find cached vector for diary name: "${dbName}". Skipping.`); - const emptyResult = ''; - this._setCachedResult(cacheKey, { content: emptyResult }); // ✅ 缓存空结果 - return { placeholder, content: emptyResult }; + const cacheKey = this._generateCacheKey({ + userContent, + aiContent: aiContent || '', + dbName, + modifiers, + dynamicK + }); + + const cachedResult = this._getCachedResult(cacheKey); + if (cachedResult) { + processingPromises.push(Promise.resolve({ placeholder, content: cachedResult.content })); + continue; } - const baseSimilarity = this.cosineSimilarity(queryVector, dbNameVector); - const enhancedVector = this.enhancedVectorCache[dbName]; - const enhancedSimilarity = enhancedVector ? this.cosineSimilarity(queryVector, enhancedVector) : 0; - const finalSimilarity = Math.max(baseSimilarity, enhancedSimilarity); + processingPromises.push((async () => { + const diaryConfig = this.ragConfig[dbName] || {}; + const localThreshold = diaryConfig.threshold || GLOBAL_SIMILARITY_THRESHOLD; + const dbNameVector = await this.vectorDBManager.getDiaryNameVector(dbName); + if (!dbNameVector) { + console.warn(`[RAGDiaryPlugin] Could not find cached vector for diary name: "${dbName}". Skipping.`); + const emptyResult = ''; + this._setCachedResult(cacheKey, { content: emptyResult }); + return { placeholder, content: emptyResult }; + } - if (finalSimilarity >= localThreshold) { - const diaryContent = await this.getDiaryContent(dbName); - const safeContent = diaryContent - .replace(/\[\[.*日记本.*\]\]/g, '[循环占位符已移除]') - .replace(/<<.*日记本>>/g, '[循环占位符已移除]') - .replace(/《《.*日记本.*》》/g, '[循环占位符已移除]') - .replace(/\{\{.*日记本\}\}/g, '[循环占位符已移除]'); + const baseSimilarity = this.cosineSimilarity(queryVector, dbNameVector); + const enhancedVector = this.enhancedVectorCache[dbName]; + const enhancedSimilarity = enhancedVector ? this.cosineSimilarity(queryVector, enhancedVector) : 0; + const finalSimilarity = Math.max(baseSimilarity, enhancedSimilarity); - if (this.pushVcpInfo) { - this.pushVcpInfo({ - type: 'DailyNote', - action: 'FullTextRecall', - dbName: dbName, - message: `[RAGDiary] 已全文召回日记本:${dbName},共 1 条全量记录` - }); - } + if (finalSimilarity >= localThreshold) { + const diaryContent = await this.getDiaryContentByOptions(dbName, retrievalOptions, pendingUserMediaDirectives); + const safeContent = diaryContent + .replace(/\[\[.*日记本.*\]\]/g, '[循环占位符已移除]') + .replace(/<<.*日记本.*>>/g, '[循环占位符已移除]') + .replace(/《《.*日记本.*》》/g, '[循环占位符已移除]') + .replace(/\{\{.*日记本.*\}\}/g, '[循环占位符已移除]'); + + if (this.pushVcpInfo) { + this.pushVcpInfo({ + type: 'DailyNote', + action: 'FullTextRecall', + dbName: dbName, + message: `[RAGDiary] 已全文召回日记本:${dbName},共 1 条全量记录` + }); + } - // ✅ 缓存结果 - this._setCachedResult(cacheKey, { content: safeContent }); - return { placeholder, content: safeContent }; - } + this._setCachedResult(cacheKey, { content: safeContent }); + return { placeholder, content: safeContent }; + } - // ✅ 缓存空结果(阈值不匹配) - const emptyResult = ''; - this._setCachedResult(cacheKey, { content: emptyResult }); - return { placeholder, content: emptyResult }; - })()); + const emptyResult = ''; + this._setCachedResult(cacheKey, { content: emptyResult }); + return { placeholder, content: emptyResult }; + })()); + } } // --- 3. 收集 《《...》》 混合模式中的 AIMemo 请求 --- @@ -1295,6 +1397,8 @@ class RAGDiaryPlugin { if (aggregateInfo.isAggregate) { // --- 《《》》聚合模式 --- + const retrievalOptions = this._parseDiaryRetrievalOptions(modifiers); + processingPromises.push((async () => { try { // 使用平均阈值进行相似度门控 @@ -1347,7 +1451,8 @@ class RAGDiaryPlugin { metrics: metrics, historySegments: historySegments, processedDiaries: processedDiaries, - contextDiaryPrefixes // 🌟 V4.1 + contextDiaryPrefixes, // 🌟 V4.1 + retrievalOptions }); return { placeholder, content: retrievedContent }; } catch (error) { @@ -1419,7 +1524,8 @@ class RAGDiaryPlugin { tagTruncationRatio: tagTruncationRatio, // 🌟 传入截断比例 metrics: metrics, historySegments: historySegments, // 🌟 传入历史分段 - contextDiaryPrefixes // 🌟 V4.1: 传入上下文日记去重前缀 + contextDiaryPrefixes, // 🌟 V4.1: 传入上下文日记去重前缀 + retrievalOptions: this._parseDiaryRetrievalOptions(modifiers) }); // ✅ 缓存结果(RAG已在内部缓存,这里是额外保险) @@ -1494,57 +1600,335 @@ class RAGDiaryPlugin { // --- 5. 处理 {{...日记本}} 直接引入模式 --- for (const match of directDiariesDeclarations) { const placeholder = match[0]; - const dbName = match[1]; + const rawName = match[1]; + const modifiers = match[2] || ''; + const aggregateInfo = this._parseAggregateSyntax(rawName, modifiers); + const retrievalOptions = this._parseDiaryRetrievalOptions(modifiers); - if (processedDiaries.has(dbName)) { - console.warn(`[RAGDiaryPlugin] Detected circular reference to "${dbName}" in {{...}}. Skipping.`); - processingPromises.push(Promise.resolve({ placeholder, content: `[检测到循环引用,已跳过"${dbName}日记本"的解析]` })); - continue; - } - // 标记以防其他模式循环 - processedDiaries.add(dbName); + for (const dbName of aggregateInfo.diaryNames) { + if (processedDiaries.has(dbName)) { + console.warn(`[RAGDiaryPlugin] Detected circular reference to "${dbName}" in {{...}}. Skipping.`); + processingPromises.push(Promise.resolve({ placeholder, content: `[检测到循环引用,已跳过"${dbName}日记本"的解析]` })); + continue; + } + processedDiaries.add(dbName); - // 直接获取内容,跳过阈值判断 - processingPromises.push((async () => { - try { - const diaryContent = await this.getDiaryContent(dbName); - const safeContent = diaryContent - .replace(/\[\[.*日记本.*\]\]/g, '[循环占位符已移除]') - .replace(/<<.*日记本>>/g, '[循环占位符已移除]') - .replace(/《《.*日记本.*》》/g, '[循环占位符已移除]') - .replace(/\{\{.*日记本\}\}/g, '[循环占位符已移除]'); - - if (this.pushVcpInfo) { - this.pushVcpInfo({ - type: 'DailyNote', - action: 'DirectRecall', - dbName: dbName, - message: `[RAGDiary] 已直接引入日记本:${dbName},共 1 条全量记录` - }); - } + processingPromises.push((async () => { + try { + const diaryContent = await this.getDiaryContentByOptions(dbName, retrievalOptions, pendingUserMediaDirectives); + const safeContent = diaryContent + .replace(/\[\[.*日记本.*\]\]/g, '[循环占位符已移除]') + .replace(/<<.*日记本.*>>/g, '[循环占位符已移除]') + .replace(/《《.*日记本.*》》/g, '[循环占位符已移除]') + .replace(/\{\{.*日记本.*\}\}/g, '[循环占位符已移除]'); + + if (this.pushVcpInfo) { + this.pushVcpInfo({ + type: 'DailyNote', + action: 'DirectRecall', + dbName: dbName, + message: `[RAGDiary] 已直接引入日记本:${dbName},共 1 条全量记录` + }); + } - return { placeholder, content: safeContent }; - } catch (error) { - console.error(`[RAGDiaryPlugin] 处理 {{...日记本}} 直接引入模式出错 (${dbName}):`, error); - return { placeholder, content: `[处理失败: ${error.message}]` }; - } - })()); + return { placeholder, content: safeContent }; + } catch (error) { + console.error(`[RAGDiaryPlugin] 处理 {{...日记本}} 直接引入模式出错 (${dbName}):`, error); + return { placeholder, content: `[处理失败: ${error.message}]` }; + } + })()); + } } // --- 执行所有任务并替换内容 --- const results = await Promise.all(processingPromises); + + // 同一占位符可能对应多条结果(如 {{A+B日记本}} 直接引入模式) + // 需要先按占位符聚合再替换,避免后续结果被覆盖或丢失 + const groupedResults = new Map(); for (const result of results) { - processedContent = processedContent.replace(result.placeholder, result.content); + if (!result || typeof result.placeholder !== 'string') continue; + if (!groupedResults.has(result.placeholder)) { + groupedResults.set(result.placeholder, []); + } + groupedResults.get(result.placeholder).push(result.content || ''); + } + + for (const [placeholder, chunks] of groupedResults.entries()) { + const mergedContent = chunks + .map(chunk => (typeof chunk === 'string' ? chunk : String(chunk))) + .filter(chunk => chunk.length > 0) + .join('\n\n'); + processedContent = processedContent.replace(placeholder, mergedContent); } return processedContent; } + _appendFileDirectivesToUserMessage(message, fileUrls = []) { + if (!message || !Array.isArray(fileUrls) || fileUrls.length === 0) return; + + const normalizedUrls = fileUrls + .map(item => (typeof item === 'string' ? item.trim() : '')) + .filter(item => item.startsWith('file://')); + + if (normalizedUrls.length === 0) return; + + const lines = normalizedUrls.map(url => `{{VCP@${url}}}`).join('\n'); + + if (typeof message.content === 'string') { + const suffix = message.content && !message.content.endsWith('\n') ? '\n' : ''; + message.content = `${message.content || ''}${suffix}${lines}`; + return; + } + + if (Array.isArray(message.content)) { + let textPart = message.content.find(part => part && part.type === 'text' && typeof part.text === 'string'); + if (!textPart) { + textPart = { type: 'text', text: '' }; + message.content.unshift(textPart); + } + const suffix = textPart.text && !textPart.text.endsWith('\n') ? '\n' : ''; + textPart.text = `${textPart.text || ''}${suffix}${lines}`; + } + } + _extractKMultiplier(modifiers) { const kMultiplierMatch = modifiers.match(/:(\d+\.?\d*)/); return kMultiplierMatch ? parseFloat(kMultiplierMatch[1]) : 1.0; } + _parseDiaryRetrievalOptions(modifiers = '') { + const options = { + transMode: null, + transCognitoAgents: [], + textOnly: false, + noText: false, + whitelist: [], + blacklist: [], + regexRulesEarly: [], + regexRulesLate: [], + tagOnlyStage: null + }; + + if (!modifiers || typeof modifiers !== 'string') return options; + + const tokens = modifiers + .split('::') + .map(item => item.trim()) + .filter(Boolean) + .filter(item => item !== '…'); + + for (const token of tokens) { + const lower = token.toLowerCase(); + + const transMatch = token.match(/^TransBase64([+-]?)(?::(.+))?$/i); + if (transMatch) { + const symbol = transMatch[1] || ''; + options.transMode = symbol === '-' ? 'minus' : (symbol === '+' ? 'plus' : 'default'); + const agentsRaw = (transMatch[2] || '').trim(); + if (agentsRaw) { + options.transCognitoAgents = agentsRaw.split(';').map(v => v.trim()).filter(Boolean); + } + continue; + } + + if (lower === 'textonly') { + options.textOnly = true; + options.noText = false; + continue; + } + + if (lower === 'notext') { + options.noText = true; + options.textOnly = false; + continue; + } + + if (lower.startsWith('whitelist:')) { + options.whitelist = token + .substring('whitelist:'.length) + .split(';') + .map(v => v.trim()) + .filter(Boolean); + continue; + } + + if (lower.startsWith('blacklist:')) { + options.blacklist = token + .substring('blacklist:'.length) + .split(';') + .map(v => v.trim()) + .filter(Boolean); + continue; + } + + if (lower.startsWith('regexrule:')) { + const suffix = token.substring('regexrule:'.length).trim(); + const segments = suffix.split(':').map(v => v.trim()).filter(Boolean); + const lastSegment = segments.length > 0 ? segments[segments.length - 1].toLowerCase() : ''; + const isLastStep = lastSegment === '@laststep'; + const rulesRaw = isLastStep ? segments.slice(0, -1).join(':') : segments.join(':'); + const rules = rulesRaw.split(';').map(v => v.trim()).filter(Boolean); + if (isLastStep) options.regexRulesLate.push(...rules); + else options.regexRulesEarly.push(...rules); + continue; + } + + if (lower.startsWith('tagonly')) { + options.tagOnlyStage = token.toLowerCase().includes('@laststep') ? 'late' : 'early'; + continue; + } + } + + return options; + } + + _isLikelyTextSourceFile(sourceFile = '') { + return /\.(txt|md)$/i.test(sourceFile); + } + + _isLikelySidecarSourceFile(sourceFile = '') { + return sourceFile.toLowerCase().endsWith(this._getSidecarSuffix()); + } + + _applyDiaryResultFilters(results, retrievalOptions) { + if (!Array.isArray(results) || results.length === 0) return results; + const opts = retrievalOptions || {}; + if (!opts.textOnly && !opts.noText && (!opts.whitelist || opts.whitelist.length === 0) && (!opts.blacklist || opts.blacklist.length === 0)) { + return results; + } + + const whitelist = new Set((opts.whitelist || []).map(v => v.toLowerCase())); + const blacklist = new Set((opts.blacklist || []).map(v => v.toLowerCase())); + + return results.filter(item => { + const source = item?.sourceFile || item?.fullPath || ''; + const basename = source ? path.basename(source).toLowerCase() : ''; + + if (whitelist.size > 0 && !whitelist.has(basename)) return false; + if (blacklist.size > 0 && blacklist.has(basename)) return false; + + const isText = this._isLikelyTextSourceFile(basename); + const isSidecar = this._isLikelySidecarSourceFile(basename); + + if (opts.textOnly) return isText; + if (opts.noText) return !isText || isSidecar; + + return true; + }); + } + + _extractTagLinesOnly(text) { + if (!text || typeof text !== 'string') return text; + const lines = text.match(/Tag:\s*.+$/gim) || []; + const normalized = []; + const seen = new Set(); + for (const line of lines) { + const fixed = line.replace(/\s+/g, ' ').trim(); + if (!fixed) continue; + const key = fixed.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + normalized.push(fixed.startsWith('Tag:') ? fixed : `Tag: ${fixed.replace(/^tag:\s*/i, '')}`); + } + return normalized.length > 0 ? normalized.join('\n') : ''; + } + + _getMultimediaPresetsDir() { + const explicit = process.env.MULTIMEDIA_PRESETS_PATH || process.env.MULTIMEDIA_PRESETS_DIR_PATH; + if (explicit && explicit.trim()) return explicit.trim(); + if (projectBasePath) return path.join(projectBasePath, 'MultimediaPresets'); + return path.join(__dirname, '..', '..', 'MultimediaPresets'); + } + + async _loadRegexRulePreset(ruleName) { + if (!ruleName) return null; + if (this.regexRuleCache.has(ruleName)) return this.regexRuleCache.get(ruleName); + + const presetsDir = this._getMultimediaPresetsDir(); + const filePath = path.join(presetsDir, `${ruleName}.json`); + try { + const raw = await fs.readFile(filePath, 'utf-8'); + const parsed = JSON.parse(raw); + this.regexRuleCache.set(ruleName, parsed); + return parsed; + } catch (error) { + console.warn(`[RAGDiaryPlugin] RegexRule "${ruleName}" 加载失败: ${error.message}`); + this.regexRuleCache.set(ruleName, null); + return null; + } + } + + async _applySingleRegexRule(text, ruleName) { + const preset = await this._loadRegexRulePreset(ruleName); + if (!preset) return text; + + const applyOne = (current, item) => { + const pattern = item?.pattern || item?.regex || item?.match; + const flags = item?.flags || 'g'; + const replacement = item?.replace ?? item?.replacement ?? ''; + if (!pattern || typeof pattern !== 'string') return current; + try { + const regex = new RegExp(pattern, flags); + return current.replace(regex, replacement); + } catch (error) { + console.warn(`[RAGDiaryPlugin] RegexRule "${ruleName}" 子规则失败: ${error.message}`); + return current; + } + }; + + try { + let output = text; + if (Array.isArray(preset.rules)) { + for (const item of preset.rules) output = applyOne(output, item); + return output; + } + if (Array.isArray(preset.replacements)) { + for (const item of preset.replacements) output = applyOne(output, item); + return output; + } + if (preset.pattern || preset.regex || preset.match) { + return applyOne(output, preset); + } + return text; + } catch (error) { + console.warn(`[RAGDiaryPlugin] RegexRule "${ruleName}" 应用失败: ${error.message}`); + return text; + } + } + + async _applyRegexRuleList(text, ruleNames = []) { + let output = text; + for (const ruleName of ruleNames) { + output = await this._applySingleRegexRule(output, ruleName); + } + return output; + } + + async _applyDiaryPostProcessing(text, retrievalOptions) { + const opts = retrievalOptions || {}; + let output = typeof text === 'string' ? text : String(text || ''); + + if (opts.tagOnlyStage === 'early') { + output = this._extractTagLinesOnly(output); + } + + if (Array.isArray(opts.regexRulesEarly) && opts.regexRulesEarly.length > 0) { + output = await this._applyRegexRuleList(output, opts.regexRulesEarly); + } + + if (opts.tagOnlyStage === 'late') { + output = this._extractTagLinesOnly(output); + } + + if (Array.isArray(opts.regexRulesLate) && opts.regexRulesLate.length > 0) { + output = await this._applyRegexRuleList(output, opts.regexRulesLate); + } + + return output; + } + //#################################################################################### //## 🌟 V5 日记聚合检索 (Diary Aggregate Retrieval) //#################################################################################### @@ -1617,7 +2001,8 @@ class RAGDiaryPlugin { metrics, historySegments, processedDiaries, // 🛡️ 循环引用检测 - contextDiaryPrefixes = new Set() // 🌟 V4.1: 上下文日记去重前缀 + contextDiaryPrefixes = new Set(), // 🌟 V4.1: 上下文日记去重前缀 + retrievalOptions = null } = options; const totalK = Math.max(1, Math.round(dynamicK * kMultiplier)); @@ -1693,6 +2078,8 @@ class RAGDiaryPlugin { // 🛡️ 去除 modifiers 中的 kMultiplier,防止 _processRAGPlaceholder 内部再次乘以 kMultiplier const cleanedModifiers = modifiers.replace(/^:\d+\.?\d*/, ''); + const effectiveRetrievalOptions = retrievalOptions || this._parseDiaryRetrievalOptions(cleanedModifiers); + const retrievalPromises = kAllocations.map(async (allocation) => { // 标记为已处理,防止循环引用 if (processedDiaries) processedDiaries.add(allocation.name); @@ -1712,7 +2099,8 @@ class RAGDiaryPlugin { tagTruncationRatio, metrics, historySegments, - contextDiaryPrefixes // 🌟 V4.1: 透传上下文日记去重前缀 + contextDiaryPrefixes, // 🌟 V4.1: 透传上下文日记去重前缀 + retrievalOptions: effectiveRetrievalOptions }); return { name: allocation.name, content, k: allocation.k, success: true }; } catch (e) { @@ -1852,7 +2240,8 @@ class RAGDiaryPlugin { tagTruncationRatio = 0.5, // 🌟 新增截断比例 metrics = {}, historySegments = [], // 🌟 Tagmemo V4 - contextDiaryPrefixes = new Set() // 🌟 V4.1: 上下文日记去重前缀 + contextDiaryPrefixes = new Set(), // 🌟 V4.1: 上下文日记去重前缀 + retrievalOptions = null } = options; // 1️⃣ 生成缓存键 @@ -1903,6 +2292,7 @@ class RAGDiaryPlugin { const kForSearch = useRerank ? Math.max(1, Math.round(finalK * this.rerankConfig.multiplier) + dedupBuffer) : finalK + dedupBuffer; + const effectiveRetrievalOptions = retrievalOptions || this._parseDiaryRetrievalOptions(modifiers); // 准备元数据用于生成自描述区块 const metadata = { @@ -1958,6 +2348,7 @@ class RAGDiaryPlugin { // 1. 语义路召回 let ragResults = await this.vectorDBManager.search(dbName, finalQueryVector, kSemantic + dedupBuffer, tagWeight, coreTagsForSearch); + ragResults = this._applyDiaryResultFilters(ragResults, effectiveRetrievalOptions); ragResults = this._filterContextDuplicates(ragResults, contextDiaryPrefixes); ragResults = ragResults.slice(0, kSemantic).map(r => ({ ...r, source: 'rag' })); @@ -1987,7 +2378,7 @@ class RAGDiaryPlugin { // 按相似度排序并取前 kTime 个 scoredTimeChunks.sort((a, b) => b.score - a.score); - timeResults = scoredTimeChunks.slice(0, kTime); + timeResults = this._applyDiaryResultFilters(scoredTimeChunks.slice(0, kTime), effectiveRetrievalOptions); console.log(`[RAGDiaryPlugin] Time path: Found ${timeChunks.length} chunks in range, selected top ${timeResults.length} by relevance.`); } @@ -2074,6 +2465,7 @@ class RAGDiaryPlugin { let flattenedResults = resultsArrays.flat(); // 🧹 V4.1: 上下文去重(在 SVD 去重之前先过滤掉与上下文工具调用重复的条目) + flattenedResults = this._applyDiaryResultFilters(flattenedResults, effectiveRetrievalOptions); flattenedResults = this._filterContextDuplicates(flattenedResults, contextDiaryPrefixes); // 🌟 Tagmemo V4: Intelligent Deduplication @@ -2165,12 +2557,13 @@ class RAGDiaryPlugin { } // 4️⃣ 保存到缓存 + const postProcessed = await this._applyDiaryPostProcessing(retrievedContent, effectiveRetrievalOptions); this._setCachedResult(cacheKey, { - content: retrievedContent, + content: postProcessed, vcpInfo: vcpInfoData }); - return retrievedContent; + return postProcessed; } diff --git a/WebSocketServer.js b/WebSocketServer.js index 5cb90837d..137052238 100755 --- a/WebSocketServer.js +++ b/WebSocketServer.js @@ -520,11 +520,58 @@ async function executeDistributedTool(serverIdOrName, toolName, toolArgs, timeou } function findServerByIp(ip) { + const buildIpVariants = (rawIp) => { + const variants = new Set(); + if (!rawIp || typeof rawIp !== 'string') return variants; + + let normalized = rawIp.trim(); + if (!normalized) return variants; + + variants.add(normalized); + + if (normalized.startsWith('::ffff:')) { + const mapped = normalized.slice(7); + if (mapped) variants.add(mapped); + normalized = mapped || normalized; + } + + if (normalized === '::1') { + variants.add('127.0.0.1'); + variants.add('localhost'); + } + + if (normalized === '127.0.0.1' || normalized === 'localhost') { + variants.add('::1'); + } + + return variants; + }; + + const requestIpVariants = buildIpVariants(ip); + if (requestIpVariants.size === 0) { + return null; + } + for (const [serverId, ipInfo] of distributedServerIPs.entries()) { - if (ipInfo.publicIP === ip || (ipInfo.localIPs && ipInfo.localIPs.includes(ip))) { - return ipInfo.serverName || serverId; + const candidateIps = new Set(); + + if (ipInfo.publicIP) { + for (const v of buildIpVariants(ipInfo.publicIP)) candidateIps.add(v); + } + + if (Array.isArray(ipInfo.localIPs)) { + for (const localIp of ipInfo.localIPs) { + for (const v of buildIpVariants(localIp)) candidateIps.add(v); + } + } + + for (const candidateIp of candidateIps) { + if (requestIpVariants.has(candidateIp)) { + return ipInfo.serverName || serverId; + } } } + return null; } diff --git a/config.env.example b/config.env.example index a750c6622..9265dcb58 100644 --- a/config.env.example +++ b/config.env.example @@ -109,6 +109,22 @@ WhitelistEmbeddingModelList=5 # 默认值: (VCP根目录)/Agent # AGENT_DIR_PATH=./Agent +# ------------------------------------------------------------------- +# [TVS 目录] +# ------------------------------------------------------------------- +# TVS_DIR_PATH: 指定存放 TVS 变量文件(.txt)的根目录。 +# 默认值: (VCP根目录)/TVStxt +# TVS_DIR_PATH=./TVStxt + +# ------------------------------------------------------------------- +# [VCP 文件直传语法] +# ------------------------------------------------------------------- +# VCP_FILE_DIRECTIVE_ENABLED: +# 是否启用 {{VCP@file://...}} 语法。 +# 启用后,user 消息中的该语法会在进入模型前被转换为多模态 part。 +# 当 file:// 指向分布式节点本地文件时,会复用 FileFetcherServer 的超栈追踪链路拉取 Base64 数据。 +VCP_FILE_DIRECTIVE_ENABLED=true + # ------------------------------------------------------------------- # [知识库 (Knowledge Base) V2 - Powered by Vexus-Lite] # ------------------------------------------------------------------- diff --git a/modules/chatCompletionHandler.js b/modules/chatCompletionHandler.js index ffcbd8f56..af5562513 100644 --- a/modules/chatCompletionHandler.js +++ b/modules/chatCompletionHandler.js @@ -10,6 +10,7 @@ const ToolCallParser = require('./vcpLoop/toolCallParser'); const ToolExecutor = require('./vcpLoop/toolExecutor'); const StreamHandler = require('./handlers/streamHandler'); const NonStreamHandler = require('./handlers/nonStreamHandler'); +const FileFetcherServer = require('../FileFetcherServer.js'); /** * 检测工具返回结果是否为错误 @@ -289,6 +290,113 @@ async function _refreshRagBlocksIfNeeded(messages, newContext, pluginManager, de return newMessages; } +async function _expandVcpFileDirectives(messages, requestIp, debugMode = false) { + if (!Array.isArray(messages)) return messages; + const directiveRegex = /\{\{VCP@((?:file:\/\/)[^}]+)\}\}/g; + const newMessages = JSON.parse(JSON.stringify(messages)); + let hasDirective = false; + const isEnabled = (process.env.VCP_FILE_DIRECTIVE_ENABLED || 'true').toLowerCase() === 'true'; + + if (!isEnabled) { + return newMessages; + } + + async function transformTextToParts(text) { + if (typeof text !== 'string' || !text.includes('{{VCP@file://')) { + return null; + } + + const matches = [...text.matchAll(directiveRegex)]; + if (matches.length === 0) { + return null; + } + + hasDirective = true; + const parts = []; + let cursor = 0; + + for (const match of matches) { + const fullMatch = match[0]; + const rawUrl = match[1]; + const startIndex = match.index || 0; + const endIndex = startIndex + fullMatch.length; + + if (startIndex > cursor) { + const plainText = text.slice(cursor, startIndex); + if (plainText) parts.push({ type: 'text', text: plainText }); + } + + const fileUrl = (rawUrl || '').trim(); + try { + const { buffer, mimeType } = await FileFetcherServer.fetchFile(fileUrl, requestIp); + const dataUri = `data:${mimeType || 'application/octet-stream'};base64,${buffer.toString('base64')}`; + parts.push({ + type: 'image_url', + image_url: { url: dataUri } + }); + if (debugMode) { + console.log(`[VCPFileDirective] Resolved directive via FileFetcherServer: ${fileUrl}`); + } + } catch (error) { + const errorText = `[VCP@file 读取失败: ${fileUrl} | ${error.message}]`; + parts.push({ type: 'text', text: errorText }); + if (debugMode) { + console.warn(`[VCPFileDirective] Failed to resolve ${fileUrl}: ${error.message}`); + } + } + + cursor = endIndex; + } + + if (cursor < text.length) { + const tail = text.slice(cursor); + if (tail) parts.push({ type: 'text', text: tail }); + } + + if (parts.length === 0) { + parts.push({ type: 'text', text: '' }); + } + + return parts; + } + + for (const msg of newMessages) { + if (!msg || msg.role !== 'user') continue; + + if (typeof msg.content === 'string') { + const transformedParts = await transformTextToParts(msg.content); + if (transformedParts) { + msg.content = transformedParts; + } + continue; + } + + if (Array.isArray(msg.content)) { + const flattened = []; + for (const part of msg.content) { + if (!part || typeof part.text !== 'string') { + flattened.push(part); + continue; + } + + const transformedParts = await transformTextToParts(part.text); + if (transformedParts) { + flattened.push(...transformedParts); + } else { + flattened.push(part); + } + } + msg.content = flattened; + } + } + + if (debugMode && hasDirective) { + console.log('[VCPFileDirective] All {{VCP@file://...}} directives have been transformed for user messages.'); + } + + return newMessages; +} + class ChatCompletionHandler { constructor(config) { this.config = config; @@ -418,31 +526,86 @@ class ChatCompletionHandler { if (DEBUG_MODE) await writeDebugLog('LogAfterInitialRoleDivider', originalBody.messages); } + originalBody.messages = await _expandVcpFileDirectives(originalBody.messages, clientIp, DEBUG_MODE); + if (DEBUG_MODE) await writeDebugLog('LogAfterVcpFileDirectiveProcessing', originalBody.messages); + let shouldProcessMedia = false; + let transBase64Mode = null; + let transBase64CognitoAgents = []; + const transBase64PlaceholderRegex = /\{\{TransBase64([+-]?)(?:::(.*?))?\}\}/g; + + const parseTransBase64Directives = (text) => { + if (typeof text !== 'string' || !text) { + return { cleanedText: text, found: false, mode: null, agents: [] }; + } + + let found = false; + let detectedMode = null; + let detectedAgents = []; + const cleanedText = text.replace(transBase64PlaceholderRegex, (_, modeSymbol, agentsRaw) => { + found = true; + if (!detectedMode) { + if (modeSymbol === '-') detectedMode = 'minus'; + else if (modeSymbol === '+') detectedMode = 'plus'; + else detectedMode = 'default'; + } + + if (!detectedAgents.length && typeof agentsRaw === 'string' && agentsRaw.trim()) { + detectedAgents = agentsRaw.split(';').map(item => item.trim()).filter(Boolean); + } + + return ''; + }); + + return { cleanedText, found, mode: detectedMode, agents: detectedAgents }; + }; + if (originalBody.messages && Array.isArray(originalBody.messages)) { for (const msg of originalBody.messages) { let foundPlaceholderInMsg = false; - if (msg.role === 'user' || msg.role === 'system') { - if (typeof msg.content === 'string' && msg.content.includes('{{TransBase64}}')) { - foundPlaceholderInMsg = true; - msg.content = msg.content.replace(/\{\{TransBase64\}\}/g, ''); + const normalizedRole = typeof msg.role === 'string' ? msg.role.toLowerCase() : ''; + if (normalizedRole === 'user' || normalizedRole === 'system') { + if (typeof msg.content === 'string') { + const parsed = parseTransBase64Directives(msg.content); + if (parsed.found) { + foundPlaceholderInMsg = true; + msg.content = parsed.cleanedText; + if (!transBase64Mode && parsed.mode) transBase64Mode = parsed.mode; + if (transBase64CognitoAgents.length === 0 && parsed.agents.length > 0) { + transBase64CognitoAgents = parsed.agents; + } + } } else if (Array.isArray(msg.content)) { for (const part of msg.content) { - if (part.type === 'text' && typeof part.text === 'string' && part.text.includes('{{TransBase64}}')) { - foundPlaceholderInMsg = true; - part.text = part.text.replace(/\{\{TransBase64\}\}/g, ''); + if (typeof part.text === 'string') { + const parsed = parseTransBase64Directives(part.text); + if (parsed.found) { + foundPlaceholderInMsg = true; + part.text = parsed.cleanedText; + if (!transBase64Mode && parsed.mode) transBase64Mode = parsed.mode; + if (transBase64CognitoAgents.length === 0 && parsed.agents.length > 0) { + transBase64CognitoAgents = parsed.agents; + } + } } } } } if (foundPlaceholderInMsg) { shouldProcessMedia = true; - if (DEBUG_MODE) console.log('[Server] Media translation enabled by {{TransBase64}} placeholder.'); - break; } } } + if (!transBase64Mode && shouldProcessMedia) { + transBase64Mode = 'default'; + } + if (DEBUG_MODE && shouldProcessMedia) { + console.log( + `[Server] Media translation enabled by TransBase64 placeholder. mode=${transBase64Mode || 'default'} agents=${transBase64CognitoAgents.join(',') || 'Cognito-Core'}` + ); + } + // --- VCPTavern 优先处理 --- // 在任何变量替换之前,首先运行 VCPTavern 来注入预设内容 let tavernProcessedMessages = originalBody.messages; @@ -508,7 +671,14 @@ class ChatCompletionHandler { if (pluginManager.messagePreprocessors.has(processorName)) { if (DEBUG_MODE) console.log(`[Server] Calling message preprocessor: ${processorName}`); try { - processedMessages = await pluginManager.executeMessagePreprocessor(processorName, processedMessages); + processedMessages = await pluginManager.executeMessagePreprocessor( + processorName, + processedMessages, + { + TransBase64Mode: transBase64Mode || 'default', + TransBase64CognitoAgents: transBase64CognitoAgents + } + ); } catch (pluginError) { console.error(`[Server] Error in preprocessor ${processorName}:`, pluginError); } @@ -529,8 +699,35 @@ class ChatCompletionHandler { } if (DEBUG_MODE) await writeDebugLog('LogAfterPreprocessors', processedMessages); + // 二次展开:允许预处理器(如 RAGDiaryPlugin 的 TransBase64+)在后置阶段新增 {{VCP@file://...}} 指令 + // 这样可把“日记本召回出的媒体文件”真正转为多模态 part 发送给模型 + processedMessages = await _expandVcpFileDirectives(processedMessages, clientIp, DEBUG_MODE); + if (DEBUG_MODE) await writeDebugLog('LogAfterPostPreprocessorVcpFileDirectiveProcessing', processedMessages); + // 经过改造后,processedMessages 已经是最终版本,无需再调用 replaceOtherVariables + if (shouldProcessMedia && transBase64Mode === 'minus') { + const minusBlockRegex = /\[TRANSBASE64_MINUS_BEGIN_[^\]]+\][\s\S]*?\[TRANSBASE64_MINUS_END_[^\]]+\]\s*/g; + processedMessages = processedMessages.map(message => { + if (!message || message.role !== 'user') return message; + if (typeof message.content === 'string') { + return { ...message, content: message.content.replace(minusBlockRegex, '') }; + } + if (Array.isArray(message.content)) { + return { + ...message, + content: message.content.map(part => { + if (part && part.type === 'text' && typeof part.text === 'string') { + return { ...part, text: part.text.replace(minusBlockRegex, '') }; + } + return part; + }) + }; + } + return message; + }); + } + originalBody.messages = processedMessages; await writeDebugLog('LogOutputAfterProcessing', originalBody); diff --git a/modules/mediaSidecarManager.js b/modules/mediaSidecarManager.js new file mode 100644 index 000000000..700de06af --- /dev/null +++ b/modules/mediaSidecarManager.js @@ -0,0 +1,417 @@ +// modules/mediaSidecarManager.js +const fs = require('fs').promises; +const fsSync = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const chokidar = require('chokidar'); +const { EventEmitter } = require('events'); + +const DEFAULT_SUPPORTED_EXTENSIONS = new Set([ + '.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp', '.tiff', '.tif', '.avif', '.svg', + '.mp3', '.wav', '.flac', '.m4a', '.aac', '.ogg', + '.mp4', '.mov', '.mkv', '.webm', '.avi', + '.pdf' +]); + +class MediaSidecarManager extends EventEmitter { + constructor() { + super(); + this.watcher = null; + this.initialized = false; + this.debugMode = false; + this.config = { + rootPath: '', + storePath: '', + sidecarSuffix: process.env.MULTIMODAL_SIDECAR_SUFFIX || '.vcpmeta.json', + enable: (process.env.MULTIMODAL_SIDECAR_ENABLED || 'false').toLowerCase() === 'true', + sidecarCreateDelayMs: parseInt(process.env.MULTIMODAL_SIDECAR_CREATE_DELAY_MS || '1000', 10), + metadataSyncEnabled: (process.env.MEDIA_METADATA_SYNC || 'false').toLowerCase() === 'true' + }; + + this.hashMapPath = ''; + this.hashMap = new Map(); + this.pendingTimers = new Map(); + this.pendingDeleteTimers = new Map(); + } + + async initialize(options = {}) { + if (this.initialized) return; + + this.debugMode = !!options.debugMode; + this.config.rootPath = options.rootPath || process.env.KNOWLEDGEBASE_ROOT_PATH || path.join(__dirname, '..', 'dailynote'); + this.config.storePath = options.storePath || process.env.KNOWLEDGEBASE_STORE_PATH || path.join(__dirname, '..', 'VectorStore'); + + this.hashMapPath = path.join(this.config.storePath, 'media_hash_map.json'); + + if (!this.config.enable) { + this._log('disabled by env flag.'); + this.initialized = true; + return; + } + + await fs.mkdir(this.config.storePath, { recursive: true }); + await this._loadHashMap(); + this._startWatcher(); + + this.initialized = true; + this._log(`initialized. root=${this.config.rootPath}`); + } + + async shutdown() { + for (const timer of this.pendingTimers.values()) { + clearTimeout(timer); + } + this.pendingTimers.clear(); + + for (const timer of this.pendingDeleteTimers.values()) { + clearTimeout(timer); + } + this.pendingDeleteTimers.clear(); + + if (this.watcher) { + await this.watcher.close(); + this.watcher = null; + } + + await this._saveHashMap(); + this.initialized = false; + } + + isEnabled() { + return this.config.enable; + } + + _log(message) { + if (this.debugMode) { + console.log(`[MediaSidecarManager] ${message}`); + } + } + + _isSidecarFile(filePath) { + return filePath.endsWith(this.config.sidecarSuffix); + } + + _isDotPath(filePath) { + const rel = path.relative(this.config.rootPath, filePath); + if (!rel || rel.startsWith('..')) return false; + return rel.split(path.sep).some(seg => seg.startsWith('.')); + } + + _isSupportedMediaFile(filePath) { + if (this._isSidecarFile(filePath)) return false; + const ext = path.extname(filePath).toLowerCase(); + return DEFAULT_SUPPORTED_EXTENSIONS.has(ext); + } + + _getSidecarPath(mediaFilePath) { + return `${mediaFilePath}${this.config.sidecarSuffix}`; + } + + async _loadHashMap() { + this.hashMap.clear(); + try { + if (!fsSync.existsSync(this.hashMapPath)) { + await this._saveHashMap(); + return; + } + const raw = await fs.readFile(this.hashMapPath, 'utf8'); + const parsed = JSON.parse(raw); + for (const [hash, info] of Object.entries(parsed || {})) { + this.hashMap.set(hash, info); + } + } catch (error) { + console.error('[MediaSidecarManager] failed to load hash map:', error.message); + this.hashMap.clear(); + } + } + + async _saveHashMap() { + try { + const data = {}; + for (const [hash, info] of this.hashMap.entries()) { + data[hash] = info; + } + await fs.writeFile(this.hashMapPath, JSON.stringify(data, null, 2), 'utf8'); + } catch (error) { + console.error('[MediaSidecarManager] failed to save hash map:', error.message); + } + } + + async _computeFileHash(filePath) { + const data = await fs.readFile(filePath); + return `sha256:${crypto.createHash('sha256').update(data).digest('hex')}`; + } + + async _readPngJpegDescription() { + return ''; + } + + async _syncPngJpegDescription() { + return; + } + + async _buildInitialDescription(mediaFilePath) { + const ext = path.extname(mediaFilePath).toLowerCase(); + if (ext === '.png' || ext === '.jpg' || ext === '.jpeg') { + const mediaDescription = await this._readPngJpegDescription(mediaFilePath); + if (mediaDescription) return mediaDescription; + } + return ''; + } + + async _createOrUpdateSidecar(mediaFilePath, options = {}) { + const { force = false } = options; + const sidecarPath = this._getSidecarPath(mediaFilePath); + + let mediaStats; + try { + mediaStats = await fs.stat(mediaFilePath); + if (!mediaStats.isFile()) return null; + } catch (error) { + if (error.code !== 'ENOENT') { + console.error('[MediaSidecarManager] stat media failed:', error.message); + } + return null; + } + + let sidecarExists = false; + let currentSidecar = null; + try { + const raw = await fs.readFile(sidecarPath, 'utf8'); + currentSidecar = JSON.parse(raw); + sidecarExists = true; + } catch (error) { + sidecarExists = false; + } + + const mediaHash = await this._computeFileHash(mediaFilePath); + + if (!sidecarExists) { + const recovered = await this._recoverSidecarByHash(mediaHash, sidecarPath); + if (recovered) { + currentSidecar = recovered; + sidecarExists = true; + } + } + + if (sidecarExists && !force) { + return currentSidecar; + } + const relativePath = path.relative(this.config.rootPath, mediaFilePath); + const mimeType = this._guessMimeType(mediaFilePath); + const now = new Date().toISOString(); + const description = sidecarExists ? (currentSidecar.description || '') : await this._buildInitialDescription(mediaFilePath); + const tags = Array.isArray(currentSidecar?.tags) ? currentSidecar.tags : []; + + const sidecar = { + version: 1, + mediaHash, + mediaPath: `file://${mediaFilePath}`, + relativePath, + mimeType, + description, + presetName: currentSidecar?.presetName || 'Cognito-Core', + tags, + generator: Array.isArray(currentSidecar?.generator) ? currentSidecar.generator : ['Cognito-Core'], + source: currentSidecar?.source || 'auto', + createdAt: currentSidecar?.createdAt || now, + updatedAt: now + }; + + await fs.writeFile(sidecarPath, JSON.stringify(sidecar, null, 2), 'utf8'); + + this.hashMap.set(mediaHash, { + relativeMediaPath: relativePath, + relativeSidecarPath: path.relative(this.config.rootPath, sidecarPath), + updatedAt: now + }); + await this._saveHashMap(); + + if (this.config.metadataSyncEnabled && (mediaFilePath.toLowerCase().endsWith('.png') || mediaFilePath.toLowerCase().endsWith('.jpg') || mediaFilePath.toLowerCase().endsWith('.jpeg'))) { + await this._syncPngJpegDescription(mediaFilePath, sidecar.description); + } + + this.emit('sidecar-upsert', { mediaFilePath, sidecarPath, sidecar }); + this._log(`sidecar upserted: ${path.relative(this.config.rootPath, sidecarPath)}`); + return sidecar; + } + + async _removeSidecarForMedia(mediaFilePath) { + const sidecarPath = this._getSidecarPath(mediaFilePath); + let sidecar = null; + + try { + const raw = await fs.readFile(sidecarPath, 'utf8'); + sidecar = JSON.parse(raw); + } catch (_) { } + + try { + await fs.unlink(sidecarPath); + } catch (error) { + if (error.code !== 'ENOENT') { + console.error('[MediaSidecarManager] remove sidecar failed:', error.message); + } + } + + if (sidecar?.mediaHash) { + this.hashMap.delete(sidecar.mediaHash); + await this._saveHashMap(); + } + + this.emit('sidecar-delete', { mediaFilePath, sidecarPath, sidecar }); + this._log(`sidecar deleted: ${path.relative(this.config.rootPath, sidecarPath)}`); + } + + async _removeBySidecarPath(sidecarPath) { + let sidecar = null; + try { + const raw = await fs.readFile(sidecarPath, 'utf8'); + sidecar = JSON.parse(raw); + } catch (_) { } + + if (sidecar?.mediaHash) { + this.hashMap.delete(sidecar.mediaHash); + await this._saveHashMap(); + } + + const mediaFilePath = sidecarPath.slice(0, -this.config.sidecarSuffix.length); + this.emit('sidecar-delete', { mediaFilePath, sidecarPath, sidecar }); + this._log(`sidecar removed event handled: ${path.relative(this.config.rootPath, sidecarPath)}`); + } + + _clearPendingDelete(mediaFilePath) { + const timer = this.pendingDeleteTimers.get(mediaFilePath); + if (timer) { + clearTimeout(timer); + this.pendingDeleteTimers.delete(mediaFilePath); + } + } + + _scheduleSidecarCreate(mediaFilePath) { + if (this.pendingTimers.has(mediaFilePath)) { + clearTimeout(this.pendingTimers.get(mediaFilePath)); + } + + const timer = setTimeout(async () => { + this.pendingTimers.delete(mediaFilePath); + await this._createOrUpdateSidecar(mediaFilePath, { force: false }); + }, this.config.sidecarCreateDelayMs); + + this.pendingTimers.set(mediaFilePath, timer); + } + + _scheduleSidecarDelete(mediaFilePath) { + this._clearPendingDelete(mediaFilePath); + + const timer = setTimeout(async () => { + this.pendingDeleteTimers.delete(mediaFilePath); + await this._removeSidecarForMedia(mediaFilePath); + }, this.config.sidecarCreateDelayMs); + + this.pendingDeleteTimers.set(mediaFilePath, timer); + } + + async _recoverSidecarByHash(mediaHash, targetSidecarPath) { + const hashInfo = this.hashMap.get(mediaHash); + if (!hashInfo || !hashInfo.relativeSidecarPath) return null; + + const oldSidecarPath = path.join(this.config.rootPath, hashInfo.relativeSidecarPath); + if (oldSidecarPath === targetSidecarPath) return null; + if (!fsSync.existsSync(oldSidecarPath)) return null; + + try { + await fs.mkdir(path.dirname(targetSidecarPath), { recursive: true }); + await fs.rename(oldSidecarPath, targetSidecarPath); + const raw = await fs.readFile(targetSidecarPath, 'utf8'); + this._log(`sidecar rename recovered by hash: ${path.relative(this.config.rootPath, oldSidecarPath)} -> ${path.relative(this.config.rootPath, targetSidecarPath)}`); + return JSON.parse(raw); + } catch (error) { + console.error('[MediaSidecarManager] recover sidecar by hash failed:', error.message); + return null; + } + } + + _startWatcher() { + if (this.watcher) return; + + this.watcher = chokidar.watch(this.config.rootPath, { + ignored: /(^|[\/\\])\../, + ignoreInitial: false, + persistent: true + }); + + this.watcher + .on('add', async (filePath) => { + if (this._isDotPath(filePath)) return; + + if (this._isSupportedMediaFile(filePath)) { + this._clearPendingDelete(filePath); + this._scheduleSidecarCreate(filePath); + return; + } + + if (this._isSidecarFile(filePath)) { + this.emit('sidecar-upsert', { mediaFilePath: filePath.slice(0, -this.config.sidecarSuffix.length), sidecarPath: filePath, sidecar: null }); + } + }) + .on('change', async (filePath) => { + if (this._isDotPath(filePath)) return; + + if (this._isSupportedMediaFile(filePath)) { + this._clearPendingDelete(filePath); + await this._createOrUpdateSidecar(filePath, { force: true }); + return; + } + + if (this._isSidecarFile(filePath)) { + this.emit('sidecar-upsert', { mediaFilePath: filePath.slice(0, -this.config.sidecarSuffix.length), sidecarPath: filePath, sidecar: null }); + } + }) + .on('unlink', async (filePath) => { + if (this._isDotPath(filePath)) return; + + if (this._isSupportedMediaFile(filePath)) { + this._scheduleSidecarDelete(filePath); + return; + } + + if (this._isSidecarFile(filePath)) { + await this._removeBySidecarPath(filePath); + } + }) + .on('error', (error) => { + console.error('[MediaSidecarManager] watcher error:', error.message); + }); + } + + _guessMimeType(filePath) { + const ext = path.extname(filePath).toLowerCase(); + const map = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.webp': 'image/webp', + '.gif': 'image/gif', + '.bmp': 'image/bmp', + '.tiff': 'image/tiff', + '.tif': 'image/tiff', + '.avif': 'image/avif', + '.svg': 'image/svg+xml', + '.mp3': 'audio/mpeg', + '.wav': 'audio/wav', + '.flac': 'audio/flac', + '.m4a': 'audio/mp4', + '.aac': 'audio/aac', + '.ogg': 'audio/ogg', + '.mp4': 'video/mp4', + '.mov': 'video/quicktime', + '.mkv': 'video/x-matroska', + '.webm': 'video/webm', + '.avi': 'video/x-msvideo', + '.pdf': 'application/pdf' + }; + return map[ext] || 'application/octet-stream'; + } +} + +module.exports = new MediaSidecarManager(); diff --git a/modules/messageProcessor.js b/modules/messageProcessor.js index 488962baa..7ce302fab 100644 --- a/modules/messageProcessor.js +++ b/modules/messageProcessor.js @@ -8,7 +8,6 @@ const tvsManager = require('./tvsManager.js'); // 引入新的TVS管理器 const DEFAULT_TIMEZONE = process.env.DEFAULT_TIMEZONE || 'Asia/Shanghai'; const REPORT_TIMEZONE = process.env.REPORT_TIMEZONE || 'Asia/Shanghai'; // 新增:用于控制 AI 报告的时间,默认回退到中国时区 const AGENT_DIR = path.join(__dirname, '..', 'Agent'); -const TVS_DIR = path.join(__dirname, '..', 'TVStxt'); const VCP_ASYNC_RESULTS_DIR = path.join(__dirname, '..', 'VCPAsyncResults'); async function resolveAllVariables(text, model, role, context, processingStack = new Set()) { diff --git a/modules/tvsManager.js b/modules/tvsManager.js index c9ecb4ddc..c5f576384 100644 --- a/modules/tvsManager.js +++ b/modules/tvsManager.js @@ -3,23 +3,39 @@ const fs = require('fs').promises; const path = require('path'); const chokidar = require('chokidar'); -const TVS_DIR = path.join(__dirname, '..', 'TVStxt'); +function resolveTvsDir() { + const configPath = process.env.TVS_DIR_PATH; + if (!configPath || typeof configPath !== 'string' || configPath.trim() === '') { + return path.join(__dirname, '..', 'TVStxt'); + } + + const normalizedPath = path.normalize(configPath.trim()); + return path.isAbsolute(normalizedPath) + ? normalizedPath + : path.resolve(__dirname, '..', normalizedPath); +} class TvsManager { constructor() { this.contentCache = new Map(); this.debugMode = false; + this.tvsDir = resolveTvsDir(); } initialize(debugMode = false) { this.debugMode = debugMode; - console.log('[TvsManager] Initializing...'); - this.watchFiles(); + this.tvsDir = resolveTvsDir(); + console.log(`[TvsManager] Initializing... TVS directory: ${this.tvsDir}`); + fs.mkdir(this.tvsDir, { recursive: true }) + .then(() => this.watchFiles()) + .catch((error) => { + console.error(`[TvsManager] Failed to ensure TVS directory: ${this.tvsDir}`, error); + }); } watchFiles() { try { - const watcher = chokidar.watch(TVS_DIR, { + const watcher = chokidar.watch(this.tvsDir, { ignored: /(^|[\/\\])\../, // ignore dotfiles persistent: true, ignoreInitial: true, // Don't trigger 'add' events on startup @@ -43,7 +59,7 @@ class TvsManager { .on('error', (error) => console.error(`[TvsManager] Watcher error: ${error}`)); if (this.debugMode) { - console.log(`[TvsManager] Watching for changes in: ${TVS_DIR}`); + console.log(`[TvsManager] Watching for changes in: ${this.tvsDir}`); } } catch (error) { console.error(`[TvsManager] Failed to set up file watcher:`, error); @@ -63,7 +79,7 @@ class TvsManager { } try { - const filePath = path.join(TVS_DIR, filename); + const filePath = path.join(this.tvsDir, filename); const content = await fs.readFile(filePath, 'utf8'); this.contentCache.set(filename, content); return content; diff --git a/routes/adminPanelRoutes.js b/routes/adminPanelRoutes.js index 8a3ae79c5..f6fd873f5 100644 --- a/routes/adminPanelRoutes.js +++ b/routes/adminPanelRoutes.js @@ -23,6 +23,18 @@ module.exports = function (DEBUG_MODE, dailyNoteRootPath, pluginManager, getCurr const AGENT_FILES_DIR = agentDirPath; console.log('[AdminPanelRoutes] Agent files directory:', AGENT_FILES_DIR); + function resolveTvsDir() { + const configPath = process.env.TVS_DIR_PATH || process.env.TVSTXT_DIR_PATH; // 兼容两种命名 + if (!configPath || typeof configPath !== 'string' || configPath.trim() === '') { + return path.join(__dirname, '..', 'TVStxt'); + } + + const normalizedPath = path.normalize(configPath.trim()); + return path.isAbsolute(normalizedPath) + ? normalizedPath + : path.resolve(__dirname, '..', normalizedPath); + } + // --- Admin API Router 内容 --- @@ -736,6 +748,59 @@ module.exports = function (DEBUG_MODE, dailyNoteRootPath, pluginManager, getCurr // --- MultiModal Cache API (New) --- + function normalizeMediaBase64Key(base64Key) { + if (typeof base64Key !== 'string') return ''; + const trimmed = base64Key.trim(); + const match = trimmed.match(/^data:[^;]+;base64,(.+)$/i); + return match ? match[1] : trimmed; + } + + function guessMimeTypeFromBase64(base64String) { + if (typeof base64String !== 'string') return 'application/octet-stream'; + const sample = base64String.trim(); + if (sample.startsWith('/9j/')) return 'image/jpeg'; + if (sample.startsWith('iVBOR')) return 'image/png'; + if (sample.startsWith('R0lGOD')) return 'image/gif'; + if (sample.startsWith('UklGR')) return 'image/webp'; + if (sample.startsWith('JVBER')) return 'application/pdf'; + return 'application/octet-stream'; + } + + function getExtensionByMimeType(mimeType) { + const map = { + 'image/jpeg': '.jpg', + 'image/png': '.png', + 'image/gif': '.gif', + 'image/webp': '.webp', + 'image/bmp': '.bmp', + 'image/tiff': '.tiff', + 'image/avif': '.avif', + 'image/svg+xml': '.svg', + 'audio/mpeg': '.mp3', + 'audio/wav': '.wav', + 'audio/flac': '.flac', + 'audio/mp4': '.m4a', + 'audio/aac': '.aac', + 'audio/ogg': '.ogg', + 'video/mp4': '.mp4', + 'video/quicktime': '.mov', + 'video/x-matroska': '.mkv', + 'video/webm': '.webm', + 'video/x-msvideo': '.avi', + 'application/pdf': '.pdf' + }; + return map[mimeType] || '.bin'; + } + + function decodeFileUrl(fileUrl) { + if (typeof fileUrl !== 'string' || !fileUrl.startsWith('file://')) return null; + try { + return decodeURIComponent(fileUrl.slice('file://'.length)); + } catch (_) { + return fileUrl.slice('file://'.length); + } + } + adminApiRouter.get('/multimodal-cache', async (req, res) => { const cachePath = path.join(__dirname, '..', 'Plugin', 'ImageProcessor', 'multimodal_cache.json'); try { @@ -766,6 +831,80 @@ module.exports = function (DEBUG_MODE, dailyNoteRootPath, pluginManager, getCurr } }); + adminApiRouter.get('/multimodal-cache/export', async (req, res) => { + const cachePath = path.join(__dirname, '..', 'Plugin', 'ImageProcessor', 'multimodal_cache.json'); + const sidecarSuffix = process.env.MULTIMODAL_SIDECAR_SUFFIX || '.vcpmeta.json'; + + try { + const content = await fs.readFile(cachePath, 'utf-8'); + const cacheData = JSON.parse(content || '{}'); + const keys = Object.keys(cacheData || {}); + + const exportData = { + exportedAt: new Date().toISOString(), + source: 'multimodal_cache', + sidecarSuffix, + itemCount: keys.length, + items: [] + }; + + for (let index = 0; index < keys.length; index++) { + const base64Key = keys[index]; + const entry = cacheData[base64Key] || {}; + const pureBase64 = normalizeMediaBase64Key(base64Key); + const mimeType = entry.mimeType || guessMimeTypeFromBase64(pureBase64); + const ext = getExtensionByMimeType(mimeType); + const mediaFileName = `media_${index + 1}${ext}`; + + let mediaSize = 0; + try { + mediaSize = Buffer.from(pureBase64, 'base64').length; + } catch (_) { + mediaSize = 0; + } + + const mediaFilePath = decodeFileUrl(entry.filePath); + let sidecarPath = null; + let sidecar = null; + + if (mediaFilePath) { + sidecarPath = `${mediaFilePath}${sidecarSuffix}`; + try { + const sidecarRaw = await fs.readFile(sidecarPath, 'utf-8'); + sidecar = JSON.parse(sidecarRaw); + } catch (_) { + sidecar = null; + } + } + + exportData.items.push({ + id: entry.id || null, + timestamp: entry.timestamp || null, + mimeType, + description: entry.description || '', + sourceFilePath: entry.filePath || null, + mediaFileName, + mediaSize, + mediaBase64: pureBase64, + sidecarFilePath: sidecarPath ? `file://${sidecarPath}` : null, + sidecar + }); + } + + const fileName = `multimodal_cache_export_${Date.now()}.json`; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`); + res.status(200).send(JSON.stringify(exportData, null, 2)); + } catch (error) { + console.error('[AdminPanelRoutes API] Error exporting multimodal cache:', error); + if (error.code === 'ENOENT') { + res.status(404).json({ error: 'multimodal_cache.json 不存在,无法导出。' }); + } else { + res.status(500).json({ error: 'Failed to export multimodal cache', details: error.message }); + } + } + }); + adminApiRouter.post('/multimodal-cache/reidentify', async (req, res) => { const { base64Key } = req.body; if (typeof base64Key !== 'string' || !base64Key) { @@ -997,7 +1136,7 @@ module.exports = function (DEBUG_MODE, dailyNoteRootPath, pluginManager, getCurr // --- End Agent Files API --- // --- TVS Variable Files API --- - const TVS_FILES_DIR = path.join(__dirname, '..', 'TVStxt'); // 定义 TVS 文件目录 + const TVS_FILES_DIR = resolveTvsDir(); // 定义 TVS 文件目录(支持 TVS_DIR_PATH) // GET list of TVS .txt files adminApiRouter.get('/tvsvars', async (req, res) => { @@ -2138,8 +2277,7 @@ module.exports = function (DEBUG_MODE, dailyNoteRootPath, pluginManager, getCurr adminApiRouter.get('/tool-list-editor/check-file/:fileName', async (req, res) => { try { const fileName = req.params.fileName; - const tvsTxtDir = path.join(PROJECT_BASE_PATH, 'TVStxt'); - const outputPath = path.join(tvsTxtDir, `${fileName}.txt`); + const outputPath = path.join(TVS_FILES_DIR, `${fileName}.txt`); try { await fs.access(outputPath); @@ -2159,8 +2297,7 @@ module.exports = function (DEBUG_MODE, dailyNoteRootPath, pluginManager, getCurr adminApiRouter.post('/tool-list-editor/export/:fileName', async (req, res) => { try { const fileName = req.params.fileName; - const tvsTxtDir = path.join(PROJECT_BASE_PATH, 'TVStxt'); - const outputPath = path.join(tvsTxtDir, `${fileName}.txt`); + const outputPath = path.join(TVS_FILES_DIR, `${fileName}.txt`); const { selectedTools, toolDescriptions, includeHeader, includeExamples } = req.body; @@ -2271,7 +2408,7 @@ module.exports = function (DEBUG_MODE, dailyNoteRootPath, pluginManager, getCurr }); await fs.writeFile(outputPath, output, 'utf-8'); - res.json({ status: 'success', filePath: `TVStxt/${fileName}.txt` }); + res.json({ status: 'success', filePath: outputPath }); } catch (error) { console.error('[AdminAPI] Error exporting to txt:', error); res.status(500).json({ error: 'Failed to export to txt', details: error.message }); diff --git a/server.js b/server.js index 5eb803e1c..fc8089b50 100644 --- a/server.js +++ b/server.js @@ -70,11 +70,40 @@ async function ensureAgentDirectory() { } } } -const TVS_DIR = path.join(__dirname, 'TVStxt'); // 新增:定义 TVStxt 目录 +let TVS_DIR; + +function resolveTvsDir() { + const configPath = process.env.TVS_DIR_PATH; + if (!configPath || typeof configPath !== 'string' || configPath.trim() === '') { + return path.join(__dirname, 'TVStxt'); + } + + const normalizedPath = path.normalize(configPath.trim()); + return path.isAbsolute(normalizedPath) + ? normalizedPath + : path.resolve(__dirname, normalizedPath); +} + +TVS_DIR = resolveTvsDir(); + +async function ensureTvsDirectory() { + try { + await fs.mkdir(TVS_DIR, { recursive: true }); + console.log(`[Server] TVS directory: ${TVS_DIR}`); + } catch (error) { + if (error.code !== 'EEXIST') { + console.error(`[Server] Failed to create TVS directory: ${TVS_DIR}`); + console.error(error); + process.exit(1); + } + } +} + const crypto = require('crypto'); const agentManager = require('./modules/agentManager.js'); // 新增:Agent管理器 const tvsManager = require('./modules/tvsManager.js'); // 新增:TVS管理器 const messageProcessor = require('./modules/messageProcessor.js'); +const mediaSidecarManager = require('./modules/mediaSidecarManager.js'); const knowledgeBaseManager = require('./KnowledgeBaseManager.js'); // 新增:引入统一知识库管理器 const pluginManager = require('./Plugin.js'); const taskScheduler = require('./routes/taskScheduler.js'); @@ -1188,6 +1217,9 @@ async function startServer() { // 确保 Agent 目录存在 await ensureAgentDirectory(); + // 确保 TVS 目录存在 + await ensureTvsDirectory(); + // 新增:加载模型重定向配置 console.log('正在加载模型重定向配置...'); modelRedirectHandler.setDebugMode(DEBUG_MODE); @@ -1207,6 +1239,40 @@ async function startServer() { // 🌟 关键修复:在监听端口前完成所有初始化 await initialize(); // This loads plugins and initializes services + console.log('正在初始化多模态侧车管理器...'); + await mediaSidecarManager.initialize({ + rootPath: dailyNoteRootPath, + storePath: process.env.KNOWLEDGEBASE_STORE_PATH || path.join(__dirname, 'VectorStore'), + debugMode: DEBUG_MODE + }); + console.log('多模态侧车管理器初始化完成。'); + + if (knowledgeBaseManager && typeof knowledgeBaseManager.notifyFileChanged === 'function' && typeof knowledgeBaseManager.notifyFileDeleted === 'function') { + mediaSidecarManager.on('sidecar-upsert', async ({ sidecarPath }) => { + try { + if (sidecarPath) { + knowledgeBaseManager.notifyFileChanged(sidecarPath); + } + } catch (error) { + console.error('[Server] sidecar-upsert -> KnowledgeBase notify failed:', error.message); + } + }); + + mediaSidecarManager.on('sidecar-delete', async ({ sidecarPath }) => { + try { + if (sidecarPath) { + await knowledgeBaseManager.notifyFileDeleted(sidecarPath); + } + } catch (error) { + console.error('[Server] sidecar-delete -> KnowledgeBase notify failed:', error.message); + } + }); + + if (DEBUG_MODE) { + console.log('[Server] Media sidecar lifecycle bridge to KnowledgeBaseManager is active.'); + } + } + server = app.listen(port, () => { console.log(`中间层服务器正在监听端口 ${port}`); console.log(`API 服务器地址: ${apiUrl}`); @@ -1247,6 +1313,10 @@ async function gracefulShutdown() { await pluginManager.shutdownAllPlugins(); } + if (mediaSidecarManager) { + await mediaSidecarManager.shutdown(); + } + const serverLogWriteStream = logger.getLogWriteStream(); if (serverLogWriteStream) { logger.originalConsoleLog('[Server] Closing server log file stream...'); From 579818d7f2aeaea203fbde657a62e8ad233e7b50 Mon Sep 17 00:00:00 2001 From: Oxyel <74046599+TyChest@users.noreply.github.com> Date: Thu, 26 Feb 2026 14:25:54 +0800 Subject: [PATCH 2/6] update --- AdminPanel/index.html | 7 + AdminPanel/js/knowledge-media-describer.js | 251 +++++++++++ AdminPanel/knowledge_media_describer.html | 218 +++++++++ KnowledgeBaseManager.js | 27 +- .../knowledge-media-describer.js | 424 ++++++++++++++++++ .../plugin-manifest.json | 41 ++ Plugin/RAGDiaryPlugin/RAGDiaryPlugin.js | 246 +++++++++- config.env.example | 12 + modules/mediaSidecarManager.js | 168 ++++++- routes/adminPanelRoutes.js | 391 ++++++++++++++++ 10 files changed, 1743 insertions(+), 42 deletions(-) create mode 100644 AdminPanel/js/knowledge-media-describer.js create mode 100644 AdminPanel/knowledge_media_describer.html create mode 100644 Plugin/KnowledgeMediaDescriber/knowledge-media-describer.js create mode 100644 Plugin/KnowledgeMediaDescriber/plugin-manifest.json diff --git a/AdminPanel/index.html b/AdminPanel/index.html index b9324faca..1f4d09454 100644 --- a/AdminPanel/index.html +++ b/AdminPanel/index.html @@ -434,6 +434,8 @@

控制中心

class="material-symbols-outlined">forumVCP论坛
  • photo_library多媒体Base64编辑器
  • +
  • perm_media知识库多媒体描述管理
  • hub语义组编辑器
  • 图像缓存编辑器 +
    +

    知识库多媒体描述管理

    + +
    +

    语义组编辑器

    管理 RAGDiaryPlugin 使用的语义组。语义组通过关键词激活,将相关的向量注入查询,以提高检索的准确性。

    diff --git a/AdminPanel/js/knowledge-media-describer.js b/AdminPanel/js/knowledge-media-describer.js new file mode 100644 index 000000000..527aa9fb9 --- /dev/null +++ b/AdminPanel/js/knowledge-media-describer.js @@ -0,0 +1,251 @@ +(function () { + const apiBase = '/admin_api/knowledge-media'; + + let currentItems = []; + + function qs(selector) { + return document.querySelector(selector); + } + + function escapeHtml(str) { + return String(str || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function setStatus(message, type = 'info') { + const statusEl = qs('#kms-status'); + if (!statusEl) return; + statusEl.textContent = message || ''; + statusEl.className = `status-message ${type}`; + } + + function parseTags(tagsText) { + return String(tagsText || '') + .split(',') + .map(tag => tag.trim()) + .filter(Boolean); + } + + function renderTable(items) { + const tbody = qs('#kms-table-body'); + if (!tbody) return; + + if (!items || items.length === 0) { + tbody.innerHTML = '暂无数据'; + return; + } + + tbody.innerHTML = items.map((item, index) => { + const tagsText = Array.isArray(item.tags) ? item.tags.join(', ') : ''; + return ` + + ${index + 1} + ${escapeHtml(item.relativePath)} + ${escapeHtml(item.extension || '')} + ${Number(item.size || 0).toLocaleString()} + ${item.hasSidecar ? '✅' : '❌'} + + + + + +
    + + +
    + + + + + + + `; + }).join(''); + } + + async function apiFetch(url, options = {}) { + const response = await fetch(url, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...(options.headers || {}) + } + }); + + const text = await response.text(); + let data; + try { + data = text ? JSON.parse(text) : {}; + } catch (_) { + data = { error: text || 'Invalid JSON response' }; + } + + if (!response.ok) { + const message = data.error || data.details || `HTTP ${response.status}`; + throw new Error(message); + } + + return data; + } + + async function loadList() { + const keyword = qs('#kms-search-input')?.value?.trim() || ''; + const query = keyword ? `?keyword=${encodeURIComponent(keyword)}` : ''; + setStatus('加载中...', 'info'); + + try { + const data = await apiFetch(`${apiBase}/list${query}`); + currentItems = data.items || []; + renderTable(currentItems); + const countEl = qs('#kms-count'); + if (countEl) { + countEl.textContent = `共 ${data.total || currentItems.length} 项`; + } + setStatus('加载完成', 'success'); + } catch (error) { + setStatus(`加载失败:${error.message}`, 'error'); + } + } + + async function saveOne(row) { + const index = Number(row.dataset.index); + const item = currentItems[index]; + if (!item) return; + + const presetName = row.querySelector('.preset-input')?.value || ''; + const description = row.querySelector('.desc-input')?.value || ''; + const tagsText = row.querySelector('.tags-input')?.value || ''; + + try { + await apiFetch(`${apiBase}/update`, { + method: 'POST', + body: JSON.stringify({ + mediaPath: item.mediaPath, + presetName, + description, + tags: parseTags(tagsText) + }) + }); + setStatus(`已保存:${item.relativePath}`, 'success'); + await loadList(); + } catch (error) { + setStatus(`保存失败:${error.message}`, 'error'); + } + } + + async function regenerateOne(row) { + const index = Number(row.dataset.index); + const item = currentItems[index]; + if (!item) return; + + try { + await apiFetch(`${apiBase}/regenerate`, { + method: 'POST', + body: JSON.stringify({ mediaPath: item.mediaPath }) + }); + setStatus(`已重生成:${item.relativePath}`, 'success'); + await loadList(); + } catch (error) { + setStatus(`重生成失败:${error.message}`, 'error'); + } + } + + async function rebuildAll(regenerateExisting) { + const message = regenerateExisting + ? '将重建并覆盖已有描述,确定继续吗?' + : '将只为缺失侧车文件的媒体创建描述,确定继续吗?'; + + if (!window.confirm(message)) return; + + setStatus('重建中,请稍候...', 'info'); + try { + const result = await apiFetch(`${apiBase}/rebuild`, { + method: 'POST', + body: JSON.stringify({ regenerateExisting }) + }); + setStatus( + `重建完成:扫描 ${result.scanned || 0},新建 ${result.created || 0},更新 ${result.updated || 0}`, + 'success' + ); + await loadList(); + } catch (error) { + setStatus(`重建失败:${error.message}`, 'error'); + } + } + + async function exportAll() { + setStatus('导出中...', 'info'); + try { + const response = await fetch(`${apiBase}/export`, { method: 'POST' }); + if (!response.ok) { + const err = await response.text(); + throw new Error(err || `HTTP ${response.status}`); + } + + const blob = await response.blob(); + const disposition = response.headers.get('Content-Disposition') || ''; + const match = disposition.match(/filename="([^"]+)"/i); + const fileName = match ? match[1] : `knowledge_media_export_${Date.now()}.json`; + + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = fileName; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + + setStatus(`导出完成:${fileName}`, 'success'); + } catch (error) { + setStatus(`导出失败:${error.message}`, 'error'); + } + } + + function bindEvents() { + const reloadBtn = qs('#kms-reload-btn'); + const rebuildMissingBtn = qs('#kms-rebuild-missing-btn'); + const rebuildAllBtn = qs('#kms-rebuild-all-btn'); + const exportBtn = qs('#kms-export-btn'); + const searchBtn = qs('#kms-search-btn'); + const searchInput = qs('#kms-search-input'); + const tableBody = qs('#kms-table-body'); + + if (reloadBtn) reloadBtn.addEventListener('click', loadList); + if (rebuildMissingBtn) rebuildMissingBtn.addEventListener('click', () => rebuildAll(false)); + if (rebuildAllBtn) rebuildAllBtn.addEventListener('click', () => rebuildAll(true)); + if (exportBtn) exportBtn.addEventListener('click', exportAll); + if (searchBtn) searchBtn.addEventListener('click', loadList); + + if (searchInput) { + searchInput.addEventListener('keydown', (event) => { + if (event.key === 'Enter') { + event.preventDefault(); + loadList(); + } + }); + } + + if (tableBody) { + tableBody.addEventListener('click', async (event) => { + const row = event.target.closest('tr[data-index]'); + if (!row) return; + + if (event.target.classList.contains('save-one-btn')) { + await saveOne(row); + } else if (event.target.classList.contains('regen-one-btn')) { + await regenerateOne(row); + } + }); + } + } + + document.addEventListener('DOMContentLoaded', async () => { + bindEvents(); + await loadList(); + }); +})(); \ No newline at end of file diff --git a/AdminPanel/knowledge_media_describer.html b/AdminPanel/knowledge_media_describer.html new file mode 100644 index 000000000..7d308865b --- /dev/null +++ b/AdminPanel/knowledge_media_describer.html @@ -0,0 +1,218 @@ + + + + + + 知识库多媒体描述管理 + + + + + + + +
    + + + + + + +
    + +
    + 共 0 项 + +
    + +
    + + + + + + + + + + + + + + + + +
    #相对路径类型大小(bytes)侧车预设名描述与Tag操作
    加载中...
    +
    + + + + \ No newline at end of file diff --git a/KnowledgeBaseManager.js b/KnowledgeBaseManager.js index 758822847..7fc5d3ba4 100644 --- a/KnowledgeBaseManager.js +++ b/KnowledgeBaseManager.js @@ -1075,6 +1075,14 @@ class KnowledgeBaseManager { } const newTags = Array.from(newTagsSet); + if (newTags.length > 0) { + const preview = newTags.slice(0, 20).join(', '); + const suffix = newTags.length > 20 ? ' ...' : ''; + console.log(`[KnowledgeBase] 🏷️ New tags in this batch: ${newTags.length} (${preview}${suffix})`); + } else { + console.log('[KnowledgeBase] 🏷️ New tags in this batch: 0'); + } + // 3. Embedding API Calls const embeddingConfig = { apiKey: this.config.apiKey, apiUrl: this.config.apiUrl, model: this.config.model }; @@ -1318,13 +1326,22 @@ class KnowledgeBaseManager { const sidecar = JSON.parse(raw); const description = typeof sidecar.description === 'string' ? sidecar.description.trim() : ''; const tagsArray = Array.isArray(sidecar.tags) - ? sidecar.tags.map(t => (typeof t === 'string' ? t.trim() : '')).filter(Boolean) + ? sidecar.tags + .map(t => (typeof t === 'string' ? t.trim() : '')) + .map(t => t.replace(/^Tag:\s*/i, '').trim()) + .filter(Boolean) : []; - let merged = description; - if (tagsArray.length > 0 && !/^\s*Tag:/im.test(merged)) { - merged = `${merged}${merged ? '\n' : ''}Tag: ${tagsArray.join(', ')}`; - } + const normalizedTags = [...new Set(tagsArray)]; + const descriptionWithoutTagLines = description + .split(/\r?\n/) + .filter(line => !/^\s*Tag:\s*/i.test(line)) + .join('\n') + .trim(); + + const merged = normalizedTags.length > 0 + ? `${descriptionWithoutTagLines}${descriptionWithoutTagLines ? '\n' : ''}Tag: ${normalizedTags.join(', ')}` + : descriptionWithoutTagLines; return this._prepareTextForEmbedding(merged) === '[EMPTY_CONTENT]' ? null : merged; } catch (error) { diff --git a/Plugin/KnowledgeMediaDescriber/knowledge-media-describer.js b/Plugin/KnowledgeMediaDescriber/knowledge-media-describer.js new file mode 100644 index 000000000..f841c5e4c --- /dev/null +++ b/Plugin/KnowledgeMediaDescriber/knowledge-media-describer.js @@ -0,0 +1,424 @@ +const fs = require('fs').promises; +const path = require('path'); + +require('dotenv').config({ path: path.join(__dirname, '..', '..', 'config.env') }); +require('dotenv').config({ path: path.join(__dirname, 'config.env') }); + +const DEBUG_MODE = (process.env.DebugMode || '').toLowerCase() === 'true'; + +const KNOWLEDGE_MEDIA_EXTENSIONS = new Set([ + '.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.tiff', '.tif', '.avif', '.svg', + '.mp3', '.wav', '.flac', '.m4a', '.aac', '.ogg', + '.mp4', '.mov', '.mkv', '.webm', '.avi', + '.pdf' +]); + +function debugLog(message, data) { + if (!DEBUG_MODE) return; + const suffix = data !== undefined ? ` ${JSON.stringify(data, null, 2)}` : ''; + console.error(`[KnowledgeMediaDescriber][Debug] ${message}${suffix}`); +} + +function getKnowledgeRootPath() { + const configuredRoot = (process.env.KNOWLEDGEBASE_ROOT_PATH || '').trim(); + const root = configuredRoot + ? (path.isAbsolute(configuredRoot) ? configuredRoot : path.resolve(__dirname, '..', '..', configuredRoot)) + : path.join(__dirname, '..', '..', 'dailynote'); + return path.normalize(root); +} + +function getSidecarSuffix() { + return (process.env.MULTIMODAL_SIDECAR_SUFFIX || '.vcpmeta.json').trim() || '.vcpmeta.json'; +} + +function toFileUrl(absPath) { + return `file://${absPath}`; +} + +function decodeFileUrlToPath(fileUrlOrPath) { + if (typeof fileUrlOrPath !== 'string') return ''; + const input = fileUrlOrPath.trim(); + if (!input) return ''; + if (input.startsWith('file://')) { + const rawPath = input.replace(/^file:\/\//i, ''); + try { + return decodeURIComponent(rawPath); + } catch (_) { + return rawPath; + } + } + return input; +} + +function normalizeTags(tags) { + if (Array.isArray(tags)) { + return tags + .map(tag => (typeof tag === 'string' ? tag.trim() : '')) + .filter(Boolean); + } + if (typeof tags === 'string') { + return tags + .split(',') + .map(tag => tag.trim()) + .filter(Boolean); + } + return []; +} + +function isPathInsideRoot(targetPath, rootPath) { + const relative = path.relative(rootPath, targetPath); + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); +} + +async function safeReadJson(jsonPath) { + try { + const raw = await fs.readFile(jsonPath, 'utf-8'); + return JSON.parse(raw); + } catch (_) { + return null; + } +} + +function buildAutoDescription(mediaPath, stats) { + const ext = path.extname(mediaPath).toLowerCase() || 'unknown'; + const fileName = path.basename(mediaPath); + const sizeBytes = stats && typeof stats.size === 'number' ? stats.size : 0; + const modifiedAt = stats && stats.mtime ? stats.mtime.toISOString() : new Date().toISOString(); + return [ + `文件名:${fileName}`, + `类型:${ext}`, + `大小:${sizeBytes} bytes`, + `最近修改:${modifiedAt}`, + `路径:${toFileUrl(mediaPath)}` + ].join('\n'); +} + +async function resolveKnowledgeMediaPath(inputPath, knowledgeRootPath) { + const decoded = decodeFileUrlToPath(inputPath); + if (!decoded) { + throw new Error('mediaPath 不能为空。'); + } + + const resolvedPath = path.isAbsolute(decoded) + ? path.normalize(decoded) + : path.normalize(path.join(knowledgeRootPath, decoded)); + + if (!isPathInsideRoot(resolvedPath, knowledgeRootPath)) { + throw new Error('mediaPath 不在 KNOWLEDGEBASE_ROOT_PATH 范围内。'); + } + + const stats = await fs.stat(resolvedPath); + if (!stats.isFile()) { + throw new Error('mediaPath 必须指向文件。'); + } + + const extension = path.extname(resolvedPath).toLowerCase(); + if (!KNOWLEDGE_MEDIA_EXTENSIONS.has(extension)) { + throw new Error(`不支持的媒体类型: ${extension}`); + } + + return { mediaPath: resolvedPath, stats }; +} + +async function walkKnowledgeMediaFiles(knowledgeRootPath) { + const result = []; + const stack = [knowledgeRootPath]; + const sidecarSuffix = getSidecarSuffix().toLowerCase(); + + while (stack.length > 0) { + const currentDir = stack.pop(); + let entries = []; + try { + entries = await fs.readdir(currentDir, { withFileTypes: true }); + } catch (_) { + continue; + } + + for (const entry of entries) { + if (!entry || !entry.name || entry.name.startsWith('.')) { + continue; + } + + const fullPath = path.join(currentDir, entry.name); + + if (entry.isDirectory()) { + stack.push(fullPath); + continue; + } + + if (!entry.isFile()) { + continue; + } + + const lowerName = entry.name.toLowerCase(); + if (lowerName.endsWith(sidecarSuffix)) { + continue; + } + + const ext = path.extname(entry.name).toLowerCase(); + if (KNOWLEDGE_MEDIA_EXTENSIONS.has(ext)) { + result.push(fullPath); + } + } + } + + return result; +} + +async function ensureSidecarForMedia(mediaPath, options = {}) { + const sidecarSuffix = getSidecarSuffix(); + const sidecarPath = `${mediaPath}${sidecarSuffix}`; + const existing = await safeReadJson(sidecarPath); + + const stats = await fs.stat(mediaPath); + const now = new Date().toISOString(); + + const regenerate = !!options.regenerate; + const nextDescription = regenerate + ? buildAutoDescription(mediaPath, stats) + : (existing && typeof existing.description === 'string' && existing.description.trim() + ? existing.description.trim() + : buildAutoDescription(mediaPath, stats)); + + const sidecar = { + version: 1, + filePath: toFileUrl(mediaPath), + mediaPath: toFileUrl(mediaPath), + presetName: existing && typeof existing.presetName === 'string' ? existing.presetName : '', + description: nextDescription, + tags: existing && Array.isArray(existing.tags) ? normalizeTags(existing.tags) : [], + updatedAt: now + }; + + if (options.patch && typeof options.patch === 'object') { + if (typeof options.patch.presetName === 'string') { + sidecar.presetName = options.patch.presetName.trim(); + } + if (typeof options.patch.description === 'string') { + sidecar.description = options.patch.description.trim(); + } + if (options.patch.tags !== undefined) { + sidecar.tags = normalizeTags(options.patch.tags); + } + sidecar.updatedAt = now; + } + + await fs.writeFile(sidecarPath, JSON.stringify(sidecar, null, 2), 'utf-8'); + return { sidecarPath, sidecar, existed: !!existing }; +} + +async function buildKnowledgeMediaList(knowledgeRootPath, keyword = '') { + const mediaFiles = await walkKnowledgeMediaFiles(knowledgeRootPath); + const loweredKeyword = (keyword || '').trim().toLowerCase(); + const sidecarSuffix = getSidecarSuffix(); + + const items = []; + for (const mediaPath of mediaFiles) { + let stats; + try { + stats = await fs.stat(mediaPath); + } catch (_) { + continue; + } + + const sidecarPath = `${mediaPath}${sidecarSuffix}`; + const sidecar = await safeReadJson(sidecarPath); + const relativePath = path.relative(knowledgeRootPath, mediaPath); + const description = sidecar && typeof sidecar.description === 'string' ? sidecar.description : ''; + const presetName = sidecar && typeof sidecar.presetName === 'string' ? sidecar.presetName : ''; + const tags = sidecar && Array.isArray(sidecar.tags) ? normalizeTags(sidecar.tags) : []; + + const item = { + mediaPath: toFileUrl(mediaPath), + relativePath, + extension: path.extname(mediaPath).toLowerCase(), + size: stats.size, + modifiedAt: stats.mtime.toISOString(), + sidecarPath: toFileUrl(sidecarPath), + hasSidecar: !!sidecar, + presetName, + description, + tags + }; + + if (loweredKeyword) { + const searchable = [ + relativePath.toLowerCase(), + description.toLowerCase(), + presetName.toLowerCase(), + tags.join(',').toLowerCase() + ].join('\n'); + if (!searchable.includes(loweredKeyword)) { + continue; + } + } + + items.push(item); + } + + items.sort((a, b) => new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime()); + return items; +} + +function convertToVCPFormat(response) { + if (response.success) { + const data = response.data || {}; + let contentArray = []; + + if (data.content) { + if (Array.isArray(data.content)) { + contentArray.push(...data.content); + } else { + contentArray.push({ type: 'text', text: String(data.content) }); + } + } + + if (data.message) { + contentArray.unshift({ type: 'text', text: String(data.message) }); + } + + if (contentArray.length === 0) { + const { content, ...rest } = data; + contentArray.push({ type: 'text', text: JSON.stringify(rest, null, 2) }); + } + + return { + status: 'success', + result: { + content: contentArray, + details: data + } + }; + } + + return { + status: 'error', + error: response.error || 'Unknown error' + }; +} + +async function processRequest(request) { + const action = request.command; + const knowledgeRootPath = getKnowledgeRootPath(); + + debugLog('Processing request', { action, request }); + + try { + await fs.access(knowledgeRootPath); + } catch (error) { + return { + success: false, + error: `知识库根目录不可用: ${knowledgeRootPath} (${error.message})` + }; + } + + try { + switch (action) { + case 'ListKnowledgeMedia': { + const keyword = typeof request.keyword === 'string' ? request.keyword : ''; + const items = await buildKnowledgeMediaList(knowledgeRootPath, keyword); + return { + success: true, + data: { + message: `知识库多媒体列表获取成功,共 ${items.length} 项。`, + rootPath: toFileUrl(knowledgeRootPath), + sidecarSuffix: getSidecarSuffix(), + total: items.length, + items + } + }; + } + + case 'UpdateKnowledgeMedia': { + const { mediaPath, description, presetName, tags } = request; + const resolved = await resolveKnowledgeMediaPath(mediaPath, knowledgeRootPath); + const patch = { description, presetName, tags }; + const { sidecarPath, sidecar } = await ensureSidecarForMedia(resolved.mediaPath, { patch }); + return { + success: true, + data: { + message: '媒体侧车已更新。', + mediaPath: toFileUrl(resolved.mediaPath), + sidecarPath: toFileUrl(sidecarPath), + sidecar + } + }; + } + + case 'RegenerateKnowledgeMedia': { + const { mediaPath } = request; + const resolved = await resolveKnowledgeMediaPath(mediaPath, knowledgeRootPath); + const { sidecarPath, sidecar } = await ensureSidecarForMedia(resolved.mediaPath, { regenerate: true }); + return { + success: true, + data: { + message: '媒体描述已重生成。', + mediaPath: toFileUrl(resolved.mediaPath), + sidecarPath: toFileUrl(sidecarPath), + sidecar + } + }; + } + + case 'RebuildKnowledgeMedia': { + const regenerateExisting = !!request.regenerateExisting; + const mediaFiles = await walkKnowledgeMediaFiles(knowledgeRootPath); + + let created = 0; + let updated = 0; + for (const mediaPath of mediaFiles) { + const sidecarPath = `${mediaPath}${getSidecarSuffix()}`; + const sidecarExists = !!(await safeReadJson(sidecarPath)); + + if (!sidecarExists || regenerateExisting) { + await ensureSidecarForMedia(mediaPath, { regenerate: regenerateExisting }); + if (sidecarExists) { + updated += 1; + } else { + created += 1; + } + } + } + + return { + success: true, + data: { + message: '知识库多媒体侧车重建完成。', + scanned: mediaFiles.length, + created, + updated + } + }; + } + + default: + return { + success: false, + error: `Unknown action: ${action}` + }; + } + } catch (error) { + return { + success: false, + error: error.message + }; + } +} + +process.stdin.setEncoding('utf8'); +process.stdin.on('data', async (data) => { + try { + const lines = data.toString().trim().split('\n'); + for (const line of lines) { + if (!line.trim()) continue; + const request = JSON.parse(line); + const response = await processRequest(request); + console.log(JSON.stringify(convertToVCPFormat(response))); + } + } catch (error) { + console.log(JSON.stringify({ + status: 'error', + error: `Invalid request format: ${error.message}` + })); + } +}); \ No newline at end of file diff --git a/Plugin/KnowledgeMediaDescriber/plugin-manifest.json b/Plugin/KnowledgeMediaDescriber/plugin-manifest.json new file mode 100644 index 000000000..e0d6ce12f --- /dev/null +++ b/Plugin/KnowledgeMediaDescriber/plugin-manifest.json @@ -0,0 +1,41 @@ +{ + "manifestVersion": "1.0.0", + "name": "KnowledgeMediaDescriber", + "version": "1.0.0", + "displayName": "知识库多媒体描述管理器", + "description": "管理 KNOWLEDGEBASE_ROOT_PATH 下多媒体文件侧车描述,支持列表、更新、重生成与重建。", + "author": "System", + "pluginType": "synchronous", + "entryPoint": { + "command": "node knowledge-media-describer.js", + "timeout": 120000 + }, + "communication": { + "protocol": "stdio" + }, + "capabilities": { + "invocationCommands": [ + { + "command": "ListKnowledgeMedia", + "description": "列出知识库中的多媒体文件与侧车信息。参数: keyword(可选,关键字过滤)。" + }, + { + "command": "UpdateKnowledgeMedia", + "description": "更新指定媒体文件侧车信息。参数: mediaPath(必需), description(可选), presetName(可选), tags(可选,字符串或数组)。" + }, + { + "command": "RegenerateKnowledgeMedia", + "description": "重生成指定媒体文件描述。参数: mediaPath(必需)。" + }, + { + "command": "RebuildKnowledgeMedia", + "description": "批量重建侧车文件。参数: regenerateExisting(可选,布尔)。" + } + ] + }, + "configSchema": { + "KNOWLEDGEBASE_ROOT_PATH": "string", + "MULTIMODAL_SIDECAR_SUFFIX": "string", + "DebugMode": "boolean" + } +} \ No newline at end of file diff --git a/Plugin/RAGDiaryPlugin/RAGDiaryPlugin.js b/Plugin/RAGDiaryPlugin/RAGDiaryPlugin.js index 46d61f233..ee61853bf 100644 --- a/Plugin/RAGDiaryPlugin/RAGDiaryPlugin.js +++ b/Plugin/RAGDiaryPlugin/RAGDiaryPlugin.js @@ -487,10 +487,16 @@ class RAGDiaryPlugin { const text = await fs.readFile(filePath, 'utf-8'); rendered.push(text); } else { - const structured = await this._buildMultimodalStructuredText(filePath, file); - rendered.push(`[MULTIMODAL:${file}]\n${structured}`); - if (options.transMode === 'plus' && mediaFileCollector && typeof mediaFileCollector.add === 'function') { - mediaFileCollector.add(`file://${filePath}`); + const fileUrl = `file://${filePath}`; + if (options.transMode === null) { + rendered.push(`[MULTIMODAL_FILE:${file}]\n{"filePath":"${fileUrl}"}`); + } else { + const structured = await this._buildMultimodalStructuredText(filePath, file); + rendered.push(`[MULTIMODAL:${file}]\n${structured}`); + } + + if ((options.transMode === null || options.transMode === 'plus') && mediaFileCollector && typeof mediaFileCollector.add === 'function') { + mediaFileCollector.add(fileUrl); } } } catch (readErr) { @@ -979,6 +985,16 @@ class RAGDiaryPlugin { // 2. 准备共享资源 (V3.3: 精准上下文提取) // 始终寻找最后一个用户消息和最后一个AI消息,以避免注入污染。 // V3.4: 跳过特殊的 "系统邀请指令" user 消息 + const firstUserMessageIndex = messages.findIndex(m => { + if (m.role !== 'user') { + return false; + } + const content = typeof m.content === 'string' + ? m.content + : (Array.isArray(m.content) ? m.content.find(p => p.type === 'text')?.text : '') || ''; + return !content.startsWith('[系统邀请指令:]') && !content.startsWith('[系统提示:]'); + }); + const lastUserMessageIndex = messages.findLastIndex(m => { if (m.role !== 'user') { return false; @@ -1103,8 +1119,11 @@ class RAGDiaryPlugin { newMessages[index].content = processedContent; } - if (lastUserMessageIndex > -1 && pendingUserMediaDirectives.size > 0) { - this._appendFileDirectivesToUserMessage(newMessages[lastUserMessageIndex], Array.from(pendingUserMediaDirectives)); + if (pendingUserMediaDirectives.size > 0) { + const targetUserMessageIndex = firstUserMessageIndex > -1 ? firstUserMessageIndex : lastUserMessageIndex; + if (targetUserMessageIndex > -1) { + this._appendFileDirectivesToUserMessage(newMessages[targetUserMessageIndex], Array.from(pendingUserMediaDirectives)); + } } return newMessages; @@ -1260,7 +1279,8 @@ class RAGDiaryPlugin { historySegments: historySegments, processedDiaries: processedDiaries, contextDiaryPrefixes, // 🌟 V4.1 - retrievalOptions + retrievalOptions, + pendingUserMediaDirectives }); return { placeholder, content: retrievedContent }; } catch (error) { @@ -1300,7 +1320,8 @@ class RAGDiaryPlugin { metrics: metrics, historySegments: historySegments, // 🌟 传入历史分段 contextDiaryPrefixes, // 🌟 V4.1: 传入上下文日记去重前缀 - retrievalOptions: this._parseDiaryRetrievalOptions(modifiers) + retrievalOptions: this._parseDiaryRetrievalOptions(modifiers), + pendingUserMediaDirectives }); return { placeholder, content: retrievedContent }; } catch (error) { @@ -1317,6 +1338,7 @@ class RAGDiaryPlugin { const rawName = match[1]; const modifiers = match[2] || ''; const aggregateInfo = this._parseAggregateSyntax(rawName, modifiers); + const allowInjectByThreshold = new Set(); for (const dbName of aggregateInfo.diaryNames) { if (processedDiaries.has(dbName)) { @@ -1359,7 +1381,11 @@ class RAGDiaryPlugin { const finalSimilarity = Math.max(baseSimilarity, enhancedSimilarity); if (finalSimilarity >= localThreshold) { - const diaryContent = await this.getDiaryContentByOptions(dbName, retrievalOptions, pendingUserMediaDirectives); + allowInjectByThreshold.add(dbName); + const mediaCollectorForThisDiary = pendingUserMediaDirectives && typeof pendingUserMediaDirectives.add === 'function' + ? pendingUserMediaDirectives + : null; + const diaryContent = await this.getDiaryContentByOptions(dbName, retrievalOptions, mediaCollectorForThisDiary); const safeContent = diaryContent .replace(/\[\[.*日记本.*\]\]/g, '[循环占位符已移除]') .replace(/<<.*日记本.*>>/g, '[循环占位符已移除]') @@ -1384,6 +1410,19 @@ class RAGDiaryPlugin { return { placeholder, content: emptyResult }; })()); } + + if (pendingUserMediaDirectives && typeof pendingUserMediaDirectives.add === 'function') { + for (const fileUrl of Array.from(pendingUserMediaDirectives)) { + if (typeof fileUrl !== 'string' || !fileUrl.startsWith('file://')) continue; + const belongsToMatchedDiary = Array.from(allowInjectByThreshold).some(name => { + const diaryPrefix = `/${name}/`; + return fileUrl.includes(diaryPrefix) || fileUrl.includes(`\\${name}\\`); + }); + if (!belongsToMatchedDiary) { + pendingUserMediaDirectives.delete(fileUrl); + } + } + } } // --- 3. 收集 《《...》》 混合模式中的 AIMemo 请求 --- @@ -1452,7 +1491,8 @@ class RAGDiaryPlugin { historySegments: historySegments, processedDiaries: processedDiaries, contextDiaryPrefixes, // 🌟 V4.1 - retrievalOptions + retrievalOptions, + pendingUserMediaDirectives }); return { placeholder, content: retrievedContent }; } catch (error) { @@ -1525,7 +1565,8 @@ class RAGDiaryPlugin { metrics: metrics, historySegments: historySegments, // 🌟 传入历史分段 contextDiaryPrefixes, // 🌟 V4.1: 传入上下文日记去重前缀 - retrievalOptions: this._parseDiaryRetrievalOptions(modifiers) + retrievalOptions: this._parseDiaryRetrievalOptions(modifiers), + pendingUserMediaDirectives }); // ✅ 缓存结果(RAG已在内部缓存,这里是额外保险) @@ -1693,6 +1734,115 @@ class RAGDiaryPlugin { } } + _extractMediaFileUrlFromResult(result) { + if (!result || typeof result !== 'object') return ''; + + const toAbsoluteFileUrl = (rawValue, fallbackFromSidecar = '') => { + if (typeof rawValue !== 'string') return ''; + const trimmed = rawValue.trim(); + if (!trimmed) return ''; + + const normalizeAbsPathToUrl = (absPath) => { + if (!absPath || typeof absPath !== 'string') return ''; + const normalized = path.normalize(absPath); + return `file://${normalized}`; + }; + + if (trimmed.startsWith('file://')) { + const decodedPath = decodeURIComponent(trimmed.replace(/^file:\/\//i, '')); + if (path.isAbsolute(decodedPath)) { + return normalizeAbsPathToUrl(decodedPath); + } + + if (fallbackFromSidecar && path.isAbsolute(fallbackFromSidecar)) { + return normalizeAbsPathToUrl(fallbackFromSidecar); + } + + return normalizeAbsPathToUrl(path.join(dailyNoteRootPath, decodedPath)); + } + + if (path.isAbsolute(trimmed)) { + return normalizeAbsPathToUrl(trimmed); + } + + if (fallbackFromSidecar && path.isAbsolute(fallbackFromSidecar)) { + return normalizeAbsPathToUrl(fallbackFromSidecar); + } + + return normalizeAbsPathToUrl(path.join(dailyNoteRootPath, trimmed)); + }; + + const sidecarSuffix = this._getSidecarSuffix(); + const fullPath = typeof result.fullPath === 'string' ? result.fullPath.trim() : ''; + const sourceFile = typeof result.sourceFile === 'string' ? result.sourceFile.trim() : ''; + + if (fullPath) { + if (fullPath.toLowerCase().endsWith(sidecarSuffix)) { + const mediaAbsPath = fullPath.slice(0, -sidecarSuffix.length); + return toAbsoluteFileUrl(mediaAbsPath); + } + if (!this._isTextDiaryFile(fullPath) && !this._isSidecarFileName(fullPath)) { + return toAbsoluteFileUrl(fullPath); + } + } + + if (sourceFile) { + if (sourceFile.toLowerCase().endsWith(sidecarSuffix)) { + const mediaPath = sourceFile.slice(0, -sidecarSuffix.length); + return toAbsoluteFileUrl(mediaPath); + } + if (!this._isTextDiaryFile(sourceFile) && !this._isSidecarFileName(sourceFile)) { + return toAbsoluteFileUrl(sourceFile); + } + } + + const text = typeof result.text === 'string' ? result.text : ''; + if (text) { + const match = text.match(/"filePath"\s*:\s*"([^"]+)"/i); + if (match && typeof match[1] === 'string') { + const fallbackFromSidecar = (fullPath && fullPath.toLowerCase().endsWith(sidecarSuffix)) + ? fullPath.slice(0, -sidecarSuffix.length) + : ''; + return toAbsoluteFileUrl(match[1], fallbackFromSidecar); + } + } + + return ''; + } + + _collectMediaDirectivesFromResults(results, mediaFileCollector) { + if (!Array.isArray(results) || results.length === 0) return; + if (!mediaFileCollector || typeof mediaFileCollector.add !== 'function') return; + + for (const result of results) { + const fileUrl = this._extractMediaFileUrlFromResult(result); + if (!fileUrl || !fileUrl.startsWith('file://')) continue; + mediaFileCollector.add(fileUrl); + } + } + + _toPathOnlyMultimodalResult(result) { + if (!result || typeof result !== 'object') return ''; + const source = (result.fullPath || result.sourceFile || '').toString(); + const lowerSource = source.toLowerCase(); + const looksLikeMultimodal = + this._isSidecarFileName(lowerSource) || + (!!lowerSource && !this._isTextDiaryFile(lowerSource)); + + if (!looksLikeMultimodal) { + return typeof result.text === 'string' ? result.text.trim() : ''; + } + + const fileUrl = this._extractMediaFileUrlFromResult(result); + if (!fileUrl || !fileUrl.startsWith('file://')) { + return typeof result.text === 'string' ? result.text.trim() : ''; + } + + const decodedPath = decodeURIComponent(fileUrl.replace(/^file:\/\//i, '')); + const fileName = path.basename(decodedPath || source || 'unknown'); + return `[MULTIMODAL_FILE:${fileName}]\n{"filePath":"${fileUrl}"}`; + } + _extractKMultiplier(modifiers) { const kMultiplierMatch = modifiers.match(/:(\d+\.?\d*)/); return kMultiplierMatch ? parseFloat(kMultiplierMatch[1]) : 1.0; @@ -2002,7 +2152,8 @@ class RAGDiaryPlugin { historySegments, processedDiaries, // 🛡️ 循环引用检测 contextDiaryPrefixes = new Set(), // 🌟 V4.1: 上下文日记去重前缀 - retrievalOptions = null + retrievalOptions = null, + pendingUserMediaDirectives = new Set() } = options; const totalK = Math.max(1, Math.round(dynamicK * kMultiplier)); @@ -2100,7 +2251,8 @@ class RAGDiaryPlugin { metrics, historySegments, contextDiaryPrefixes, // 🌟 V4.1: 透传上下文日记去重前缀 - retrievalOptions: effectiveRetrievalOptions + retrievalOptions: effectiveRetrievalOptions, + pendingUserMediaDirectives }); return { name: allocation.name, content, k: allocation.k, success: true }; } catch (e) { @@ -2241,7 +2393,8 @@ class RAGDiaryPlugin { metrics = {}, historySegments = [], // 🌟 Tagmemo V4 contextDiaryPrefixes = new Set(), // 🌟 V4.1: 上下文日记去重前缀 - retrievalOptions = null + retrievalOptions = null, + pendingUserMediaDirectives = new Set() } = options; // 1️⃣ 生成缓存键 @@ -2401,7 +2554,14 @@ class RAGDiaryPlugin { finalResultsForBroadcast = await this._rerankDocuments(userContent, finalResultsForBroadcast, finalK); } - retrievedContent = this.formatCombinedTimeAwareResults(finalResultsForBroadcast, timeRanges, dbName, metadata); + const usePathOnlyForMultimodal = effectiveRetrievalOptions.transMode === null; + retrievedContent = this.formatCombinedTimeAwareResults( + finalResultsForBroadcast, + timeRanges, + dbName, + metadata, + { usePathOnlyForMultimodal } + ); } else { // --- Standard path (no time filter) --- @@ -2492,13 +2652,28 @@ class RAGDiaryPlugin { // ✅ 统一添加 source 标识,防止 VCP Info 显示 unknown finalResultsForBroadcast = finalResultsForBroadcast.map(r => ({ ...r, source: 'rag' })); + const usePathOnlyForMultimodal = effectiveRetrievalOptions.transMode === null; if (useGroup) { retrievedContent = this.formatGroupRAGResults(finalResultsForBroadcast, displayName, activatedGroups, metadata); } else { - retrievedContent = this.formatStandardResults(finalResultsForBroadcast, displayName, metadata); + retrievedContent = this.formatStandardResults( + finalResultsForBroadcast, + displayName, + metadata, + { usePathOnlyForMultimodal } + ); } } + const shouldDirectSendMedia = + (effectiveRetrievalOptions.transMode === null || effectiveRetrievalOptions.transMode === 'plus') && + pendingUserMediaDirectives && + typeof pendingUserMediaDirectives.add === 'function'; + + if (shouldDirectSendMedia && Array.isArray(finalResultsForBroadcast) && finalResultsForBroadcast.length > 0) { + this._collectMediaDirectivesFromResults(finalResultsForBroadcast, pendingUserMediaDirectives); + } + if (this.pushVcpInfo && finalResultsForBroadcast) { try { // ✅ 新增:根据相关度分数对结果进行排序 @@ -2664,10 +2839,20 @@ class RAGDiaryPlugin { return diariesInRange; } - formatStandardResults(searchResults, displayName, metadata) { + formatStandardResults(searchResults, displayName, metadata, options = {}) { + const usePathOnlyForMultimodal = !!options.usePathOnlyForMultimodal; let innerContent = `\n[--- 从"${displayName}"中检索到的相关记忆片段 ---]\n`; if (searchResults && searchResults.length > 0) { - innerContent += searchResults.map(r => `* ${r.text.trim()}`).join('\n'); + const lines = searchResults + .map(r => { + if (usePathOnlyForMultimodal) { + const pathOnly = this._toPathOnlyMultimodalResult(r); + if (pathOnly) return `* ${pathOnly}`; + } + return `* ${(r.text || '').trim()}`; + }) + .filter(Boolean); + innerContent += lines.join('\n'); } else { innerContent += "没有找到直接相关的记忆片段。"; } @@ -2677,7 +2862,8 @@ class RAGDiaryPlugin { return `${innerContent}`; } - formatCombinedTimeAwareResults(results, timeRanges, dbName, metadata) { + formatCombinedTimeAwareResults(results, timeRanges, dbName, metadata, options = {}) { + const usePathOnlyForMultimodal = !!options.usePathOnlyForMultimodal; const displayName = dbName + '日记本'; const formatDate = (date) => { const d = new Date(date); @@ -2697,9 +2883,17 @@ class RAGDiaryPlugin { if (ragEntries.length > 0) { innerContent += '【语义相关记忆】\n'; ragEntries.forEach(entry => { - const dateMatch = entry.text.match(/^\[(\d{4}-\d{2}-\d{2})\]/); + if (usePathOnlyForMultimodal) { + const pathOnly = this._toPathOnlyMultimodalResult(entry); + if (pathOnly) { + innerContent += `* ${pathOnly}\n`; + return; + } + } + const text = typeof entry.text === 'string' ? entry.text : ''; + const dateMatch = text.match(/^\[(\d{4}-\d{2}-\d{2})\]/); const datePrefix = dateMatch ? `[${dateMatch[1]}] ` : ''; - innerContent += `* ${datePrefix}${entry.text.replace(/^\[.*?\]\s*-\s*.*?\n?/, '').trim()}\n`; + innerContent += `* ${datePrefix}${text.replace(/^\[.*?\]\s*-\s*.*?\n?/, '').trim()}\n`; }); } @@ -2708,7 +2902,15 @@ class RAGDiaryPlugin { // 按日期从新到旧排序 timeEntries.sort((a, b) => new Date(b.date) - new Date(a.date)); timeEntries.forEach(entry => { - innerContent += `* [${entry.date}] ${entry.text.replace(/^\[.*?\]\s*-\s*.*?\n?/, '').trim()}\n`; + if (usePathOnlyForMultimodal) { + const pathOnly = this._toPathOnlyMultimodalResult(entry); + if (pathOnly) { + innerContent += `* ${pathOnly}\n`; + return; + } + } + const text = typeof entry.text === 'string' ? entry.text : ''; + innerContent += `* [${entry.date}] ${text.replace(/^\[.*?\]\s*-\s*.*?\n?/, '').trim()}\n`; }); } diff --git a/config.env.example b/config.env.example index 9265dcb58..9bdafdede 100644 --- a/config.env.example +++ b/config.env.example @@ -152,6 +152,11 @@ VECTORDB_DIMENSION=3072 # 默认值: true KNOWLEDGEBASE_FULL_SCAN_ON_STARTUP=true +# KNOWLEDGEBASE_MULTIMODAL_INDEX: 是否允许知识库索引多模态侧车文件(.vcpmeta.json)。 +# 关闭时仅索引 txt/md;开启后侧车 description/tags 会进入向量库并参与 RAG 召回。 +# 默认值: false(建议在多模态日记场景显式开启) +KNOWLEDGEBASE_MULTIMODAL_INDEX=true + # KNOWLEDGEBASE_MAX_BATCH_SIZE: 一次批量处理的最大文件数量。 # 当文件变更频繁时,调大此值可以合并更多操作,减少API调用。 # 默认值: 50 @@ -449,6 +454,13 @@ SuperDetector_Output4="噢噢噢" # ------------------------------------------------------------------- # [多模态配置] # ------------------------------------------------------------------- +# 多模态侧车系统总开关(建议开启,自动监管多模态文件生命周期) +MULTIMODAL_SIDECAR_ENABLED=true +# 侧车文件后缀 +MULTIMODAL_SIDECAR_SUFFIX=.vcpmeta.json +# 侧车创建/删除延迟(毫秒) +MULTIMODAL_SIDECAR_CREATE_DELAY_MS=1000 + # 多模态数据识别模型 MultiModalModel=gemini-2.5-flash MultiModalPrompt="你是一个名为 "Cognito-Core" 的高精度多模态分析引擎。你的核心使命是将接收到的多媒体数据(图像、音频、视频)转译为一份整体式的、按时间同步的、语义准确的文本叙事。你的最高追求是忠于内容的原始意图,并将所有信息流(视觉、听觉、文本)无缝整合。你的全局核心准则要求你进行整体分析,严禁将视频的视觉和音频视为独立任务,输出必须体现两者的同步与互动;要意图优先,首要目标是还原信息背后的真实意图,并在必要时启动智能纠错;并且必须采用严格的结构化输出。对于图像输入,你需要进行详细的视觉元素分析,并执行高精度OCR,一字不差地转录所有可见文本。对于音频输入,你需要进行环境音分析,并执行带智能纠错的语音转录,忠于说话者意图而非死板的音标。对于视频或视听媒体输入,协议已核心升级:你必须采用强制性的时序整合结构,将视频分解为连续的场景或关键时间段。为每一个时间段,你都必须提供一个结构化描述,该描述强制包含以下要素:一个明确的时间戳(格式 [Time: HH:MM:SS]);详尽的‘视觉描述’,涵盖该时间段内所有的视觉信息,包括场景、人物、镜头运动、特效以及任何屏幕文本;准确的‘语音/歌词’,内容为该时间段完全对应的、经过智能纠错的语音转录或歌词,并保留原语言;以及‘音景分析’,描述显著的背景音乐变化和关键音效。该结构强制你将视觉和听觉信息绑定在同一个时间戳下,从根本上杜绝了只输出歌词而忽略画面的问题。特别是在处理音乐视频(MV)时,这个方法将生成一份完整的、图文并茂的MV分镜脚本。" diff --git a/modules/mediaSidecarManager.js b/modules/mediaSidecarManager.js index 700de06af..7ce8de890 100644 --- a/modules/mediaSidecarManager.js +++ b/modules/mediaSidecarManager.js @@ -23,7 +23,7 @@ class MediaSidecarManager extends EventEmitter { rootPath: '', storePath: '', sidecarSuffix: process.env.MULTIMODAL_SIDECAR_SUFFIX || '.vcpmeta.json', - enable: (process.env.MULTIMODAL_SIDECAR_ENABLED || 'false').toLowerCase() === 'true', + enable: (process.env.MULTIMODAL_SIDECAR_ENABLED || 'true').toLowerCase() === 'true', sidecarCreateDelayMs: parseInt(process.env.MULTIMODAL_SIDECAR_CREATE_DELAY_MS || '1000', 10), metadataSyncEnabled: (process.env.MEDIA_METADATA_SYNC || 'false').toLowerCase() === 'true' }; @@ -52,6 +52,7 @@ class MediaSidecarManager extends EventEmitter { await fs.mkdir(this.config.storePath, { recursive: true }); await this._loadHashMap(); this._startWatcher(); + await this._bootstrapExistingMediaSidecars(); this.initialized = true; this._log(`initialized. root=${this.config.rootPath}`); @@ -137,6 +138,60 @@ class MediaSidecarManager extends EventEmitter { } } + _upsertHashMapEntry(mediaHash, mediaFilePath, sidecarPath, updatedAt) { + if (!mediaHash) return false; + + const relativeMediaPath = path.relative(this.config.rootPath, mediaFilePath); + const relativeSidecarPath = path.relative(this.config.rootPath, sidecarPath); + const now = updatedAt || new Date().toISOString(); + + let changed = false; + + for (const [hash, info] of this.hashMap.entries()) { + if (hash === mediaHash) continue; + if (info?.relativeMediaPath === relativeMediaPath || info?.relativeSidecarPath === relativeSidecarPath) { + this.hashMap.delete(hash); + changed = true; + } + } + + const prev = this.hashMap.get(mediaHash); + if (!prev || prev.relativeMediaPath !== relativeMediaPath || prev.relativeSidecarPath !== relativeSidecarPath) { + this.hashMap.set(mediaHash, { + relativeMediaPath, + relativeSidecarPath, + updatedAt: now + }); + changed = true; + } + + return changed; + } + + _removeHashMapEntriesByMediaPath(mediaFilePath) { + const relativeMediaPath = path.relative(this.config.rootPath, mediaFilePath); + let changed = false; + for (const [hash, info] of this.hashMap.entries()) { + if (info?.relativeMediaPath === relativeMediaPath) { + this.hashMap.delete(hash); + changed = true; + } + } + return changed; + } + + _removeHashMapEntriesBySidecarPath(sidecarPath) { + const relativeSidecarPath = path.relative(this.config.rootPath, sidecarPath); + let changed = false; + for (const [hash, info] of this.hashMap.entries()) { + if (info?.relativeSidecarPath === relativeSidecarPath) { + this.hashMap.delete(hash); + changed = true; + } + } + return changed; + } + async _computeFileHash(filePath) { const data = await fs.readFile(filePath); return `sha256:${crypto.createHash('sha256').update(data).digest('hex')}`; @@ -194,12 +249,29 @@ class MediaSidecarManager extends EventEmitter { } } + const relativePath = path.relative(this.config.rootPath, mediaFilePath); + const relativeSidecarPath = path.relative(this.config.rootPath, sidecarPath); + const now = new Date().toISOString(); + + let shouldWriteSidecar = force || !sidecarExists; if (sidecarExists && !force) { + const sidecarHash = typeof currentSidecar?.mediaHash === 'string' ? currentSidecar.mediaHash : ''; + if (sidecarHash !== mediaHash) { + shouldWriteSidecar = true; + } + } + + const hashMapChanged = this._upsertHashMapEntry(mediaHash, mediaFilePath, sidecarPath, now); + + if (!shouldWriteSidecar) { + if (hashMapChanged) { + await this._saveHashMap(); + } + this.emit('sidecar-upsert', { mediaFilePath, sidecarPath, sidecar: currentSidecar }); return currentSidecar; } - const relativePath = path.relative(this.config.rootPath, mediaFilePath); + const mimeType = this._guessMimeType(mediaFilePath); - const now = new Date().toISOString(); const description = sidecarExists ? (currentSidecar.description || '') : await this._buildInitialDescription(mediaFilePath); const tags = Array.isArray(currentSidecar?.tags) ? currentSidecar.tags : []; @@ -220,19 +292,17 @@ class MediaSidecarManager extends EventEmitter { await fs.writeFile(sidecarPath, JSON.stringify(sidecar, null, 2), 'utf8'); - this.hashMap.set(mediaHash, { - relativeMediaPath: relativePath, - relativeSidecarPath: path.relative(this.config.rootPath, sidecarPath), - updatedAt: now - }); - await this._saveHashMap(); + const hashChangedAfterWrite = this._upsertHashMapEntry(mediaHash, mediaFilePath, sidecarPath, now); + if (hashMapChanged || hashChangedAfterWrite) { + await this._saveHashMap(); + } if (this.config.metadataSyncEnabled && (mediaFilePath.toLowerCase().endsWith('.png') || mediaFilePath.toLowerCase().endsWith('.jpg') || mediaFilePath.toLowerCase().endsWith('.jpeg'))) { await this._syncPngJpegDescription(mediaFilePath, sidecar.description); } this.emit('sidecar-upsert', { mediaFilePath, sidecarPath, sidecar }); - this._log(`sidecar upserted: ${path.relative(this.config.rootPath, sidecarPath)}`); + this._log(`sidecar upserted: ${relativeSidecarPath}`); return sidecar; } @@ -253,8 +323,13 @@ class MediaSidecarManager extends EventEmitter { } } + let hashMapChanged = false; if (sidecar?.mediaHash) { - this.hashMap.delete(sidecar.mediaHash); + hashMapChanged = this.hashMap.delete(sidecar.mediaHash) || hashMapChanged; + } + hashMapChanged = this._removeHashMapEntriesByMediaPath(mediaFilePath) || hashMapChanged; + hashMapChanged = this._removeHashMapEntriesBySidecarPath(sidecarPath) || hashMapChanged; + if (hashMapChanged) { await this._saveHashMap(); } @@ -269,12 +344,18 @@ class MediaSidecarManager extends EventEmitter { sidecar = JSON.parse(raw); } catch (_) { } + let hashMapChanged = false; if (sidecar?.mediaHash) { - this.hashMap.delete(sidecar.mediaHash); + hashMapChanged = this.hashMap.delete(sidecar.mediaHash) || hashMapChanged; + } + hashMapChanged = this._removeHashMapEntriesBySidecarPath(sidecarPath) || hashMapChanged; + const mediaFilePath = sidecarPath.slice(0, -this.config.sidecarSuffix.length); + hashMapChanged = this._removeHashMapEntriesByMediaPath(mediaFilePath) || hashMapChanged; + + if (hashMapChanged) { await this._saveHashMap(); } - const mediaFilePath = sidecarPath.slice(0, -this.config.sidecarSuffix.length); this.emit('sidecar-delete', { mediaFilePath, sidecarPath, sidecar }); this._log(`sidecar removed event handled: ${path.relative(this.config.rootPath, sidecarPath)}`); } @@ -287,14 +368,23 @@ class MediaSidecarManager extends EventEmitter { } } - _scheduleSidecarCreate(mediaFilePath) { + _scheduleSidecarCreate(mediaFilePath, attempt = 0) { if (this.pendingTimers.has(mediaFilePath)) { clearTimeout(this.pendingTimers.get(mediaFilePath)); } const timer = setTimeout(async () => { this.pendingTimers.delete(mediaFilePath); - await this._createOrUpdateSidecar(mediaFilePath, { force: false }); + const sidecar = await this._createOrUpdateSidecar(mediaFilePath, { force: false }); + + if (sidecar) return; + if (!fsSync.existsSync(mediaFilePath)) return; + if (attempt >= 5) { + this._log(`sidecar create retries exceeded: ${path.relative(this.config.rootPath, mediaFilePath)}`); + return; + } + + this._scheduleSidecarCreate(mediaFilePath, attempt + 1); }, this.config.sidecarCreateDelayMs); this.pendingTimers.set(mediaFilePath, timer); @@ -322,6 +412,14 @@ class MediaSidecarManager extends EventEmitter { try { await fs.mkdir(path.dirname(targetSidecarPath), { recursive: true }); await fs.rename(oldSidecarPath, targetSidecarPath); + + const oldMediaPath = hashInfo.relativeMediaPath + ? path.join(this.config.rootPath, hashInfo.relativeMediaPath) + : null; + if (oldMediaPath) { + this._clearPendingDelete(oldMediaPath); + } + const raw = await fs.readFile(targetSidecarPath, 'utf8'); this._log(`sidecar rename recovered by hash: ${path.relative(this.config.rootPath, oldSidecarPath)} -> ${path.relative(this.config.rootPath, targetSidecarPath)}`); return JSON.parse(raw); @@ -384,6 +482,46 @@ class MediaSidecarManager extends EventEmitter { }); } + async _bootstrapExistingMediaSidecars() { + const mediaFiles = []; + + const walk = async (dirPath) => { + let entries = []; + try { + entries = await fs.readdir(dirPath, { withFileTypes: true }); + } catch (error) { + console.error('[MediaSidecarManager] bootstrap scan failed:', error.message); + return; + } + + for (const entry of entries) { + if (entry.name.startsWith('.')) continue; + + const fullPath = path.join(dirPath, entry.name); + if (entry.isDirectory()) { + await walk(fullPath); + continue; + } + + if (entry.isFile() && this._isSupportedMediaFile(fullPath)) { + mediaFiles.push(fullPath); + } + } + }; + + await walk(this.config.rootPath); + + for (const mediaFilePath of mediaFiles) { + try { + await this._createOrUpdateSidecar(mediaFilePath, { force: false }); + } catch (error) { + console.error('[MediaSidecarManager] bootstrap sidecar create failed:', error.message); + } + } + + this._log(`bootstrap scan finished. mediaCount=${mediaFiles.length}`); + } + _guessMimeType(filePath) { const ext = path.extname(filePath).toLowerCase(); const map = { diff --git a/routes/adminPanelRoutes.js b/routes/adminPanelRoutes.js index f6fd873f5..6a81d4e7d 100644 --- a/routes/adminPanelRoutes.js +++ b/routes/adminPanelRoutes.js @@ -924,6 +924,397 @@ module.exports = function (DEBUG_MODE, dailyNoteRootPath, pluginManager, getCurr }); // --- End MultiModal Cache API --- + // --- Knowledge Media Describer API --- + const KNOWLEDGE_MEDIA_EXTENSIONS = new Set([ + '.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.tiff', '.tif', '.avif', '.svg', + '.mp3', '.wav', '.flac', '.m4a', '.aac', '.ogg', + '.mp4', '.mov', '.mkv', '.webm', '.avi', + '.pdf' + ]); + + function getKnowledgeRootPath() { + const configuredRoot = (process.env.KNOWLEDGEBASE_ROOT_PATH || '').trim(); + const root = configuredRoot + ? (path.isAbsolute(configuredRoot) ? configuredRoot : path.resolve(__dirname, '..', configuredRoot)) + : path.join(__dirname, '..', 'dailynote'); + return path.normalize(root); + } + + function getKnowledgeMediaSidecarSuffix() { + return (process.env.MULTIMODAL_SIDECAR_SUFFIX || '.vcpmeta.json').trim() || '.vcpmeta.json'; + } + + function isPathInsideRoot(targetPath, rootPath) { + const relative = path.relative(rootPath, targetPath); + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); + } + + function decodeFileUrlToPath(fileUrlOrPath) { + if (typeof fileUrlOrPath !== 'string') return ''; + const input = fileUrlOrPath.trim(); + if (!input) return ''; + if (input.startsWith('file://')) { + const rawPath = input.replace(/^file:\/\//i, ''); + try { + return decodeURIComponent(rawPath); + } catch (_) { + return rawPath; + } + } + return input; + } + + function normalizeTags(tags) { + if (Array.isArray(tags)) { + return tags + .map(tag => (typeof tag === 'string' ? tag.trim() : '')) + .filter(Boolean); + } + if (typeof tags === 'string') { + return tags + .split(',') + .map(tag => tag.trim()) + .filter(Boolean); + } + return []; + } + + function toFileUrl(absPath) { + return `file://${absPath}`; + } + + async function safeReadJson(jsonPath) { + try { + const raw = await fs.readFile(jsonPath, 'utf-8'); + return JSON.parse(raw); + } catch (_) { + return null; + } + } + + function buildAutoDescription(mediaPath, stats) { + const ext = path.extname(mediaPath).toLowerCase() || 'unknown'; + const fileName = path.basename(mediaPath); + const sizeBytes = stats && typeof stats.size === 'number' ? stats.size : 0; + const modifiedAt = stats && stats.mtime ? stats.mtime.toISOString() : new Date().toISOString(); + return [ + `文件名:${fileName}`, + `类型:${ext}`, + `大小:${sizeBytes} bytes`, + `最近修改:${modifiedAt}`, + `路径:${toFileUrl(mediaPath)}` + ].join('\n'); + } + + async function resolveKnowledgeMediaPath(inputPath, knowledgeRootPath) { + const decoded = decodeFileUrlToPath(inputPath); + if (!decoded) { + throw new Error('mediaPath 不能为空。'); + } + + const resolvedPath = path.isAbsolute(decoded) + ? path.normalize(decoded) + : path.normalize(path.join(knowledgeRootPath, decoded)); + + if (!isPathInsideRoot(resolvedPath, knowledgeRootPath)) { + throw new Error('mediaPath 不在 KNOWLEDGEBASE_ROOT_PATH 范围内。'); + } + + const stats = await fs.stat(resolvedPath); + if (!stats.isFile()) { + throw new Error('mediaPath 必须指向文件。'); + } + + const extension = path.extname(resolvedPath).toLowerCase(); + if (!KNOWLEDGE_MEDIA_EXTENSIONS.has(extension)) { + throw new Error(`不支持的媒体类型: ${extension}`); + } + + return { mediaPath: resolvedPath, stats }; + } + + async function walkKnowledgeMediaFiles(knowledgeRootPath) { + const result = []; + const stack = [knowledgeRootPath]; + + while (stack.length > 0) { + const currentDir = stack.pop(); + let entries = []; + try { + entries = await fs.readdir(currentDir, { withFileTypes: true }); + } catch (_) { + continue; + } + + for (const entry of entries) { + if (!entry || !entry.name || entry.name.startsWith('.')) { + continue; + } + + const fullPath = path.join(currentDir, entry.name); + + if (entry.isDirectory()) { + stack.push(fullPath); + continue; + } + + if (!entry.isFile()) { + continue; + } + + const ext = path.extname(entry.name).toLowerCase(); + const sidecarSuffix = getKnowledgeMediaSidecarSuffix().toLowerCase(); + if (entry.name.toLowerCase().endsWith(sidecarSuffix)) { + continue; + } + + if (KNOWLEDGE_MEDIA_EXTENSIONS.has(ext)) { + result.push(fullPath); + } + } + } + + return result; + } + + async function ensureSidecarForMedia(mediaPath, options = {}) { + const sidecarSuffix = getKnowledgeMediaSidecarSuffix(); + const sidecarPath = `${mediaPath}${sidecarSuffix}`; + const existing = await safeReadJson(sidecarPath); + + const stats = await fs.stat(mediaPath); + const now = new Date().toISOString(); + + const regenerate = !!options.regenerate; + const nextDescription = regenerate + ? buildAutoDescription(mediaPath, stats) + : (existing && typeof existing.description === 'string' && existing.description.trim() + ? existing.description.trim() + : buildAutoDescription(mediaPath, stats)); + + const sidecar = { + version: 1, + filePath: toFileUrl(mediaPath), + mediaPath: toFileUrl(mediaPath), + presetName: existing && typeof existing.presetName === 'string' ? existing.presetName : '', + description: nextDescription, + tags: existing && Array.isArray(existing.tags) ? normalizeTags(existing.tags) : [], + updatedAt: now + }; + + if (options.patch && typeof options.patch === 'object') { + if (typeof options.patch.presetName === 'string') { + sidecar.presetName = options.patch.presetName.trim(); + } + if (typeof options.patch.description === 'string') { + sidecar.description = options.patch.description.trim(); + } + if (options.patch.tags !== undefined) { + sidecar.tags = normalizeTags(options.patch.tags); + } + sidecar.updatedAt = now; + } + + await fs.writeFile(sidecarPath, JSON.stringify(sidecar, null, 2), 'utf-8'); + return { sidecarPath, sidecar, existed: !!existing }; + } + + function notifyKnowledgeSidecarChanged(sidecarPath) { + if (!sidecarPath) return; + if (!vectorDBManager || typeof vectorDBManager.notifyFileChanged !== 'function') return; + try { + vectorDBManager.notifyFileChanged(sidecarPath); + } catch (error) { + console.error('[AdminPanelRoutes API] Failed to notify knowledge sidecar change:', error.message); + } + } + + async function buildKnowledgeMediaList(knowledgeRootPath, keyword = '') { + const mediaFiles = await walkKnowledgeMediaFiles(knowledgeRootPath); + const loweredKeyword = (keyword || '').trim().toLowerCase(); + const sidecarSuffix = getKnowledgeMediaSidecarSuffix(); + + const items = []; + for (const mediaPath of mediaFiles) { + let stats; + try { + stats = await fs.stat(mediaPath); + } catch (_) { + continue; + } + + const sidecarPath = `${mediaPath}${sidecarSuffix}`; + const sidecar = await safeReadJson(sidecarPath); + const relativePath = path.relative(knowledgeRootPath, mediaPath); + const description = sidecar && typeof sidecar.description === 'string' ? sidecar.description : ''; + const presetName = sidecar && typeof sidecar.presetName === 'string' ? sidecar.presetName : ''; + const tags = sidecar && Array.isArray(sidecar.tags) ? normalizeTags(sidecar.tags) : []; + + const item = { + mediaPath: toFileUrl(mediaPath), + relativePath, + extension: path.extname(mediaPath).toLowerCase(), + size: stats.size, + modifiedAt: stats.mtime.toISOString(), + sidecarPath: toFileUrl(sidecarPath), + hasSidecar: !!sidecar, + presetName, + description, + tags + }; + + if (loweredKeyword) { + const searchable = [ + relativePath.toLowerCase(), + description.toLowerCase(), + presetName.toLowerCase(), + tags.join(',').toLowerCase() + ].join('\n'); + if (!searchable.includes(loweredKeyword)) { + continue; + } + } + + items.push(item); + } + + items.sort((a, b) => new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime()); + return items; + } + + adminApiRouter.get('/knowledge-media/list', async (req, res) => { + try { + const knowledgeRootPath = getKnowledgeRootPath(); + await fs.access(knowledgeRootPath); + + const keyword = typeof req.query.keyword === 'string' ? req.query.keyword : ''; + const items = await buildKnowledgeMediaList(knowledgeRootPath, keyword); + + res.json({ + success: true, + rootPath: toFileUrl(knowledgeRootPath), + sidecarSuffix: getKnowledgeMediaSidecarSuffix(), + total: items.length, + items + }); + } catch (error) { + console.error('[AdminPanelRoutes API] Error listing knowledge media:', error); + res.status(500).json({ success: false, error: 'Failed to list knowledge media', details: error.message }); + } + }); + + adminApiRouter.post('/knowledge-media/update', async (req, res) => { + const { mediaPath, description, presetName, tags } = req.body || {}; + try { + const knowledgeRootPath = getKnowledgeRootPath(); + await fs.access(knowledgeRootPath); + + const resolved = await resolveKnowledgeMediaPath(mediaPath, knowledgeRootPath); + const patch = { description, presetName, tags }; + const { sidecarPath, sidecar } = await ensureSidecarForMedia(resolved.mediaPath, { patch }); + notifyKnowledgeSidecarChanged(sidecarPath); + + res.json({ + success: true, + message: '媒体侧车已更新。', + mediaPath: toFileUrl(resolved.mediaPath), + sidecarPath: toFileUrl(sidecarPath), + sidecar + }); + } catch (error) { + console.error('[AdminPanelRoutes API] Error updating knowledge media sidecar:', error); + res.status(400).json({ success: false, error: 'Failed to update knowledge media sidecar', details: error.message }); + } + }); + + adminApiRouter.post('/knowledge-media/regenerate', async (req, res) => { + const { mediaPath } = req.body || {}; + try { + const knowledgeRootPath = getKnowledgeRootPath(); + await fs.access(knowledgeRootPath); + + const resolved = await resolveKnowledgeMediaPath(mediaPath, knowledgeRootPath); + const { sidecarPath, sidecar } = await ensureSidecarForMedia(resolved.mediaPath, { regenerate: true }); + notifyKnowledgeSidecarChanged(sidecarPath); + + res.json({ + success: true, + message: '媒体描述已重生成。', + mediaPath: toFileUrl(resolved.mediaPath), + sidecarPath: toFileUrl(sidecarPath), + sidecar + }); + } catch (error) { + console.error('[AdminPanelRoutes API] Error regenerating knowledge media sidecar:', error); + res.status(400).json({ success: false, error: 'Failed to regenerate knowledge media sidecar', details: error.message }); + } + }); + + adminApiRouter.post('/knowledge-media/rebuild', async (req, res) => { + const { regenerateExisting = false } = req.body || {}; + try { + const knowledgeRootPath = getKnowledgeRootPath(); + await fs.access(knowledgeRootPath); + + const mediaFiles = await walkKnowledgeMediaFiles(knowledgeRootPath); + let created = 0; + let updated = 0; + + for (const mediaPath of mediaFiles) { + const sidecarSuffix = getKnowledgeMediaSidecarSuffix(); + const sidecarPath = `${mediaPath}${sidecarSuffix}`; + const sidecarExists = !!(await safeReadJson(sidecarPath)); + + if (!sidecarExists || regenerateExisting) { + const { sidecarPath } = await ensureSidecarForMedia(mediaPath, { regenerate: !!regenerateExisting }); + notifyKnowledgeSidecarChanged(sidecarPath); + if (sidecarExists) { + updated += 1; + } else { + created += 1; + } + } + } + + res.json({ + success: true, + message: '知识库多媒体侧车重建完成。', + scanned: mediaFiles.length, + created, + updated + }); + } catch (error) { + console.error('[AdminPanelRoutes API] Error rebuilding knowledge media sidecars:', error); + res.status(500).json({ success: false, error: 'Failed to rebuild knowledge media sidecars', details: error.message }); + } + }); + + adminApiRouter.post('/knowledge-media/export', async (req, res) => { + try { + const knowledgeRootPath = getKnowledgeRootPath(); + await fs.access(knowledgeRootPath); + + const items = await buildKnowledgeMediaList(knowledgeRootPath); + const exportedAt = new Date().toISOString(); + const payload = { + exportedAt, + rootPath: toFileUrl(knowledgeRootPath), + sidecarSuffix: getKnowledgeMediaSidecarSuffix(), + itemCount: items.length, + items + }; + + const fileName = `knowledge_media_export_${Date.now()}.json`; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`); + res.status(200).send(JSON.stringify(payload, null, 2)); + } catch (error) { + console.error('[AdminPanelRoutes API] Error exporting knowledge media:', error); + res.status(500).json({ success: false, error: 'Failed to export knowledge media', details: error.message }); + } + }); + // --- End Knowledge Media Describer API --- + // --- Image Cache API (Legacy, for backward compatibility) --- adminApiRouter.get('/image-cache', async (req, res) => { const imageCachePath = path.join(__dirname, '..', 'Plugin', 'ImageProcessor', 'image_cache.json'); From e043a2159fcc7623b589af5c653ab21c06a32171 Mon Sep 17 00:00:00 2001 From: Oxyel <74046599+TyChest@users.noreply.github.com> Date: Thu, 26 Feb 2026 17:32:27 +0800 Subject: [PATCH 3/6] Update RAGDiaryPlugin.js --- Plugin/RAGDiaryPlugin/RAGDiaryPlugin.js | 197 ++++++++++++++++++++---- 1 file changed, 167 insertions(+), 30 deletions(-) diff --git a/Plugin/RAGDiaryPlugin/RAGDiaryPlugin.js b/Plugin/RAGDiaryPlugin/RAGDiaryPlugin.js index ee61853bf..69758d2b7 100644 --- a/Plugin/RAGDiaryPlugin/RAGDiaryPlugin.js +++ b/Plugin/RAGDiaryPlugin/RAGDiaryPlugin.js @@ -410,6 +410,134 @@ class RAGDiaryPlugin { return `${mediaFilePath}${this._getSidecarSuffix()}`; } + _guessMediaMimeTypeForTransbase64(mediaFilePath) { + const ext = (path.extname(mediaFilePath || '').toLowerCase() || ''); + const mimeMap = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.bmp': 'image/bmp', + '.tiff': 'image/tiff', + '.tif': 'image/tiff', + '.avif': 'image/avif', + '.svg': 'image/svg+xml', + '.mp3': 'audio/mpeg', + '.wav': 'audio/wav', + '.flac': 'audio/flac', + '.m4a': 'audio/mp4', + '.aac': 'audio/aac', + '.ogg': 'audio/ogg', + '.mp4': 'video/mp4', + '.mov': 'video/quicktime', + '.mkv': 'video/x-matroska', + '.webm': 'video/webm', + '.avi': 'video/x-msvideo', + '.pdf': 'application/pdf' + }; + return mimeMap[ext] || 'application/octet-stream'; + } + + async _regenerateSidecarDescriptionWithDefaultAgent(mediaFilePath, mediaFileName = '', cognitoAgentName = '') { + const apiKey = process.env.API_Key; + const apiUrl = process.env.API_URL; + const multiModalModelName = process.env.MultiModalModel; + const defaultPromptText = process.env.MultiModalPrompt; + const maxTokens = parseInt(process.env.MultiModalModelOutputMaxTokens, 10) || 50000; + const thinkingBudget = parseInt(process.env.MultiModalModelThinkingBudget, 10); + + let promptText = defaultPromptText; + let effectiveAgentName = 'Cognito-Core'; + + const requestedAgent = typeof cognitoAgentName === 'string' ? cognitoAgentName.trim() : ''; + if (requestedAgent) { + try { + const presetsDir = this._getMultimediaPresetsDir(); + const presetPath = path.join(presetsDir, `${requestedAgent}.json`); + const presetRaw = await fs.readFile(presetPath, 'utf-8'); + const presetJson = JSON.parse(presetRaw); + const presetPrompt = typeof presetJson.systemPrompt === 'string' + ? presetJson.systemPrompt.trim() + : (typeof presetJson.prompt === 'string' ? presetJson.prompt.trim() : ''); + if (presetPrompt) { + promptText = presetPrompt; + effectiveAgentName = requestedAgent; + } else { + console.warn(`[RAGDiaryPlugin] Cognito预设缺少 systemPrompt/prompt,回退默认Agent: ${requestedAgent}`); + } + } catch (error) { + console.warn(`[RAGDiaryPlugin] Cognito预设加载失败(${requestedAgent}),回退默认Agent: ${error.message}`); + } + } + + if (!apiKey || !apiUrl || !multiModalModelName || !promptText) { + console.warn(`[RAGDiaryPlugin] 侧车描述为空且缺少多模态识别配置,无法重生成(${mediaFileName || mediaFilePath})`); + return ''; + } + + try { + const fileBuffer = await fs.readFile(mediaFilePath); + const mimeType = this._guessMediaMimeTypeForTransbase64(mediaFilePath); + const dataUri = `data:${mimeType};base64,${fileBuffer.toString('base64')}`; + + const payload = { + model: multiModalModelName, + messages: [ + { + role: 'user', + content: [ + { type: 'text', text: promptText }, + { type: 'image_url', image_url: { url: dataUri } } + ] + } + ], + max_tokens: maxTokens + }; + + if (!Number.isNaN(thinkingBudget) && thinkingBudget > 0) { + payload.extra_body = { + thinking_config: { thinking_budget: thinkingBudget } + }; + } + + const response = await axios.post( + `${apiUrl}/v1/chat/completions`, + payload, + { + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json' + }, + timeout: 45000 + } + ); + + const regenerated = response?.data?.choices?.[0]?.message?.content; + const finalText = typeof regenerated === 'string' ? regenerated.trim() : ''; + if (!finalText) { + console.warn(`[RAGDiaryPlugin] 识别Agent返回空描述(${effectiveAgentName} | ${mediaFileName || mediaFilePath})`); + } + return finalText; + } catch (error) { + console.warn(`[RAGDiaryPlugin] 识别Agent重生成失败(${effectiveAgentName} | ${mediaFileName || mediaFilePath}): ${error.message}`); + return ''; + } + } + + _mergeDescriptionAndTags(description, tags) { + let merged = typeof description === 'string' ? description.trim() : ''; + const normalizedTags = Array.isArray(tags) + ? tags.map(item => (typeof item === 'string' ? item.trim() : '')).filter(Boolean) + : []; + + if (normalizedTags.length > 0 && !/^\s*Tag:/im.test(merged)) { + merged = `${merged}${merged ? '\n' : ''}Tag: ${normalizedTags.join(', ')}`; + } + + return merged; + } + _shouldIncludeFileByOptions(fileName, retrievalOptions) { const opts = retrievalOptions || {}; const lower = (fileName || '').toLowerCase(); @@ -431,35 +559,43 @@ class RAGDiaryPlugin { return true; } - async _buildMultimodalStructuredText(mediaFilePath, mediaFileName) { - const fallback = JSON.stringify({ - description: '[多模态文件暂无侧车描述信息]', - filePath: `file://${mediaFilePath}` - }, null, 2); - + async _buildMultimodalStructuredText(mediaFilePath, mediaFileName, retrievalOptions = null) { + const fallback = '[多模态文件暂无描述]'; const sidecarPath = this._getSidecarPathForMedia(mediaFilePath); + const options = retrievalOptions || {}; + const requestedAgents = Array.isArray(options.transCognitoAgents) + ? options.transCognitoAgents.map(item => (typeof item === 'string' ? item.trim() : '')).filter(Boolean) + : []; + + let sidecar = {}; try { const raw = await fs.readFile(sidecarPath, 'utf-8'); - const sidecar = JSON.parse(raw); - let description = typeof sidecar.description === 'string' ? sidecar.description.trim() : ''; - const tags = Array.isArray(sidecar.tags) - ? sidecar.tags.map(t => (typeof t === 'string' ? t.trim() : '')).filter(Boolean) - : []; - - if (tags.length > 0 && !/^\s*Tag:/im.test(description)) { - description = `${description}${description ? '\n' : ''}Tag: ${tags.join(', ')}`; - } - - return JSON.stringify({ - description: description || '[多模态文件暂无描述]', - filePath: (typeof sidecar.filePath === 'string' && sidecar.filePath.trim()) - ? sidecar.filePath.trim() - : `file://${mediaFilePath}` - }, null, 2); + sidecar = JSON.parse(raw); } catch (error) { console.warn(`[RAGDiaryPlugin] 读取侧车失败(${mediaFileName}): ${error.message}`); - return fallback; + sidecar = {}; + } + + const tags = Array.isArray(sidecar.tags) ? sidecar.tags : []; + let description = typeof sidecar.description === 'string' ? sidecar.description.trim() : ''; + + if (requestedAgents.length > 0) { + const generatedChunks = []; + for (const agentName of requestedAgents) { + const generated = await this._regenerateSidecarDescriptionWithDefaultAgent(mediaFilePath, mediaFileName, agentName); + if (generated) { + generatedChunks.push(`[${agentName}]\n${generated}`); + } + } + if (generatedChunks.length > 0) { + description = generatedChunks.join('\n\n'); + } + } else if (!description) { + description = await this._regenerateSidecarDescriptionWithDefaultAgent(mediaFilePath, mediaFileName); } + + const merged = this._mergeDescriptionAndTags(description, tags); + return merged || fallback; } async getDiaryContentByOptions(characterName, retrievalOptions = null, mediaFileCollector = null) { @@ -491,8 +627,8 @@ class RAGDiaryPlugin { if (options.transMode === null) { rendered.push(`[MULTIMODAL_FILE:${file}]\n{"filePath":"${fileUrl}"}`); } else { - const structured = await this._buildMultimodalStructuredText(filePath, file); - rendered.push(`[MULTIMODAL:${file}]\n${structured}`); + const structured = await this._buildMultimodalStructuredText(filePath, file, options); + rendered.push(structured); } if ((options.transMode === null || options.transMode === 'plus') && mediaFileCollector && typeof mediaFileCollector.add === 'function') { @@ -967,7 +1103,7 @@ class RAGDiaryPlugin { } // 检查 RAG/Meta/AIMemo 占位符 - if (/\[\[.*日记本.*\]\]|<<.*日记本.*>>|《《.*日记本.*》》|\{\{.*日记本\}\}|\[\[VCP元思考.*\]\]|\[\[AIMemo=True\]\]/.test(m.content)) { + if (/\[\[.*日记本.*\]\]|<<.*日记本.*>>|《《.*日记本.*》》|\{\{.*日记本.*\}\}|\[\[VCP元思考.*\]\]|\[\[AIMemo=True\]\]/.test(m.content)) { // 确保每个包含占位符的 system 消息都被处理 if (!acc.includes(index)) { acc.push(index); @@ -1067,8 +1203,9 @@ class RAGDiaryPlugin { for (const index of targetSystemMessageIndices) { newMessages[index].content = newMessages[index].content .replace(/\[\[.*日记本.*\]\]/g, '') - .replace(/<<.*日记本>>/g, '') - .replace(/《《.*日记本.*》》/g, ''); + .replace(/<<.*日记本.*>>/g, '') + .replace(/《《.*日记本.*》》/g, '') + .replace(/\{\{.*日记本.*\}\}/g, ''); } return newMessages; } @@ -1138,9 +1275,9 @@ class RAGDiaryPlugin { if (msg.role === 'system' && typeof msg.content === 'string') { msg.content = msg.content .replace(/\[\[.*日记本.*\]\]/g, '[RAG处理失败]') - .replace(/<<.*日记本>>/g, '[RAG处理失败]') + .replace(/<<.*日记本.*>>/g, '[RAG处理失败]') .replace(/《《.*日记本.*》》/g, '[RAG处理失败]') - .replace(/\{\{.*日记本\}\}/g, '[RAG处理失败]'); + .replace(/\{\{.*日记本.*\}\}/g, '[RAG处理失败]'); } }); return safeMessages; From 7759dacbe38348d5f1c6182019ccb75574585b83 Mon Sep 17 00:00:00 2001 From: Oxyel <74046599+TyChest@users.noreply.github.com> Date: Thu, 26 Feb 2026 20:00:32 +0800 Subject: [PATCH 4/6] KnowledgeMediaDescriber --- .../knowledge-media-describer.js | 708 ++++++++++++------ .../plugin-manifest.json | 30 +- 2 files changed, 507 insertions(+), 231 deletions(-) diff --git a/Plugin/KnowledgeMediaDescriber/knowledge-media-describer.js b/Plugin/KnowledgeMediaDescriber/knowledge-media-describer.js index f841c5e4c..19e4859d5 100644 --- a/Plugin/KnowledgeMediaDescriber/knowledge-media-describer.js +++ b/Plugin/KnowledgeMediaDescriber/knowledge-media-describer.js @@ -1,12 +1,13 @@ const fs = require('fs').promises; const path = require('path'); +const crypto = require('crypto'); require('dotenv').config({ path: path.join(__dirname, '..', '..', 'config.env') }); require('dotenv').config({ path: path.join(__dirname, 'config.env') }); const DEBUG_MODE = (process.env.DebugMode || '').toLowerCase() === 'true'; -const KNOWLEDGE_MEDIA_EXTENSIONS = new Set([ +const SUPPORTED_EXTENSIONS = new Set([ '.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.tiff', '.tif', '.avif', '.svg', '.mp3', '.wav', '.flac', '.m4a', '.aac', '.ogg', '.mp4', '.mov', '.mkv', '.webm', '.avi', @@ -32,7 +33,7 @@ function getSidecarSuffix() { } function toFileUrl(absPath) { - return `file://${absPath}`; + return `file://${path.normalize(absPath)}`; } function decodeFileUrlToPath(fileUrlOrPath) { @@ -42,12 +43,17 @@ function decodeFileUrlToPath(fileUrlOrPath) { if (input.startsWith('file://')) { const rawPath = input.replace(/^file:\/\//i, ''); try { - return decodeURIComponent(rawPath); + return path.normalize(decodeURIComponent(rawPath)); } catch (_) { - return rawPath; + return path.normalize(rawPath); } } - return input; + return path.normalize(input); +} + +function isPathInsideRoot(targetPath, rootPath) { + const relative = path.relative(rootPath, targetPath); + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); } function normalizeTags(tags) { @@ -65,328 +71,590 @@ function normalizeTags(tags) { return []; } -function isPathInsideRoot(targetPath, rootPath) { - const relative = path.relative(rootPath, targetPath); - return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); +function normalizeStringArray(input) { + if (Array.isArray(input)) { + return input + .map(item => (typeof item === 'string' ? item.trim() : '')) + .filter(Boolean); + } + if (typeof input === 'string' && input.trim()) { + return input + .split(/[;,]/) + .map(item => item.trim()) + .filter(Boolean); + } + return []; +} + +function guessMimeType(filePath) { + const ext = path.extname(filePath).toLowerCase(); + const map = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.bmp': 'image/bmp', + '.tiff': 'image/tiff', + '.tif': 'image/tiff', + '.avif': 'image/avif', + '.svg': 'image/svg+xml', + '.mp3': 'audio/mpeg', + '.wav': 'audio/wav', + '.flac': 'audio/flac', + '.m4a': 'audio/mp4', + '.aac': 'audio/aac', + '.ogg': 'audio/ogg', + '.mp4': 'video/mp4', + '.mov': 'video/quicktime', + '.mkv': 'video/x-matroska', + '.webm': 'video/webm', + '.avi': 'video/x-msvideo', + '.pdf': 'application/pdf' + }; + return map[ext] || 'application/octet-stream'; } -async function safeReadJson(jsonPath) { +async function safeReadJson(filePath) { try { - const raw = await fs.readFile(jsonPath, 'utf-8'); + const raw = await fs.readFile(filePath, 'utf-8'); return JSON.parse(raw); } catch (_) { return null; } } -function buildAutoDescription(mediaPath, stats) { - const ext = path.extname(mediaPath).toLowerCase() || 'unknown'; - const fileName = path.basename(mediaPath); - const sizeBytes = stats && typeof stats.size === 'number' ? stats.size : 0; - const modifiedAt = stats && stats.mtime ? stats.mtime.toISOString() : new Date().toISOString(); - return [ - `文件名:${fileName}`, - `类型:${ext}`, - `大小:${sizeBytes} bytes`, - `最近修改:${modifiedAt}`, - `路径:${toFileUrl(mediaPath)}` - ].join('\n'); +async function ensureDirectory(dirPath) { + await fs.mkdir(dirPath, { recursive: true }); } -async function resolveKnowledgeMediaPath(inputPath, knowledgeRootPath) { - const decoded = decodeFileUrlToPath(inputPath); - if (!decoded) { - throw new Error('mediaPath 不能为空。'); - } +async function computeSha256(filePath) { + const data = await fs.readFile(filePath); + return `sha256:${crypto.createHash('sha256').update(data).digest('hex')}`; +} + +async function resolveMediaPath(inputMediaPath) { + const decoded = decodeFileUrlToPath(inputMediaPath); + if (!decoded) throw new Error('mediaPath 不能为空。'); - const resolvedPath = path.isAbsolute(decoded) + const absolutePath = path.isAbsolute(decoded) ? path.normalize(decoded) - : path.normalize(path.join(knowledgeRootPath, decoded)); + : path.resolve(decoded); - if (!isPathInsideRoot(resolvedPath, knowledgeRootPath)) { - throw new Error('mediaPath 不在 KNOWLEDGEBASE_ROOT_PATH 范围内。'); + const stats = await fs.stat(absolutePath); + if (!stats.isFile()) throw new Error('mediaPath 必须指向文件。'); + + const ext = path.extname(absolutePath).toLowerCase(); + if (!SUPPORTED_EXTENSIONS.has(ext)) { + throw new Error(`不支持的媒体类型: ${ext}`); } - const stats = await fs.stat(resolvedPath); - if (!stats.isFile()) { - throw new Error('mediaPath 必须指向文件。'); + return { mediaPath: absolutePath, stats }; +} + +function resolveTargetDiaryPath(targetDiaryName, knowledgeRootPath) { + if (typeof targetDiaryName !== 'string' || !targetDiaryName.trim()) { + return ''; } - const extension = path.extname(resolvedPath).toLowerCase(); - if (!KNOWLEDGE_MEDIA_EXTENSIONS.has(extension)) { - throw new Error(`不支持的媒体类型: ${extension}`); + const name = targetDiaryName.trim(); + if (name.includes('/') || name.includes('\\') || name === '.' || name === '..') { + throw new Error('targetDiaryName 只能是日记本名称,不能包含路径分隔符。'); } - return { mediaPath: resolvedPath, stats }; + const absDiaryPath = path.normalize(path.join(knowledgeRootPath, name)); + if (!isPathInsideRoot(absDiaryPath, knowledgeRootPath)) { + throw new Error('targetDiaryName 解析后的路径不在 KNOWLEDGEBASE_ROOT_PATH 范围内。'); + } + return absDiaryPath; } -async function walkKnowledgeMediaFiles(knowledgeRootPath) { - const result = []; - const stack = [knowledgeRootPath]; - const sidecarSuffix = getSidecarSuffix().toLowerCase(); +async function resolveWritableTargetPath(targetPath, overwrite = false) { + if (overwrite) return targetPath; - while (stack.length > 0) { - const currentDir = stack.pop(); - let entries = []; + let candidate = targetPath; + const dir = path.dirname(targetPath); + const ext = path.extname(targetPath); + const name = path.basename(targetPath, ext); + let index = 1; + + while (true) { try { - entries = await fs.readdir(currentDir, { withFileTypes: true }); + await fs.access(candidate); + candidate = path.join(dir, `${name}_${index}${ext}`); + index += 1; } catch (_) { - continue; - } - - for (const entry of entries) { - if (!entry || !entry.name || entry.name.startsWith('.')) { - continue; - } - - const fullPath = path.join(currentDir, entry.name); - - if (entry.isDirectory()) { - stack.push(fullPath); - continue; - } - - if (!entry.isFile()) { - continue; - } - - const lowerName = entry.name.toLowerCase(); - if (lowerName.endsWith(sidecarSuffix)) { - continue; - } - - const ext = path.extname(entry.name).toLowerCase(); - if (KNOWLEDGE_MEDIA_EXTENSIONS.has(ext)) { - result.push(fullPath); - } + return candidate; } } +} - return result; +async function moveFileCrossDeviceSafe(sourcePath, targetPath) { + try { + await fs.rename(sourcePath, targetPath); + } catch (error) { + if (error.code !== 'EXDEV') throw error; + await fs.copyFile(sourcePath, targetPath); + await fs.unlink(sourcePath); + } } -async function ensureSidecarForMedia(mediaPath, options = {}) { +async function buildAndWriteSidecar(mediaPath, knowledgeRootPath, options = {}) { const sidecarSuffix = getSidecarSuffix(); const sidecarPath = `${mediaPath}${sidecarSuffix}`; const existing = await safeReadJson(sidecarPath); - const stats = await fs.stat(mediaPath); + const mediaHash = await computeSha256(mediaPath); const now = new Date().toISOString(); - const regenerate = !!options.regenerate; - const nextDescription = regenerate - ? buildAutoDescription(mediaPath, stats) - : (existing && typeof existing.description === 'string' && existing.description.trim() - ? existing.description.trim() - : buildAutoDescription(mediaPath, stats)); + const presetName = typeof options.presetName === 'string' && options.presetName.trim() + ? options.presetName.trim() + : (typeof existing?.presetName === 'string' && existing.presetName.trim() ? existing.presetName.trim() : 'Cognito-Core'); + + const tags = options.tags !== undefined + ? normalizeTags(options.tags) + : normalizeTags(existing?.tags); + + const generator = options.generator !== undefined + ? normalizeStringArray(options.generator) + : normalizeStringArray(existing?.generator); + + const description = options.description !== undefined + ? String(options.description || '').trim() + : (typeof existing?.description === 'string' ? existing.description.trim() : ''); + + const source = typeof options.source === 'string' && options.source.trim() + ? options.source.trim() + : (typeof existing?.source === 'string' && existing.source.trim() ? existing.source.trim() : 'manual'); const sidecar = { version: 1, + mediaHash, filePath: toFileUrl(mediaPath), mediaPath: toFileUrl(mediaPath), - presetName: existing && typeof existing.presetName === 'string' ? existing.presetName : '', - description: nextDescription, - tags: existing && Array.isArray(existing.tags) ? normalizeTags(existing.tags) : [], + relativePath: isPathInsideRoot(mediaPath, knowledgeRootPath) ? path.relative(knowledgeRootPath, mediaPath) : '', + mimeType: guessMimeType(mediaPath), + description, + presetName, + tags, + generator: generator.length > 0 ? generator : [presetName], + source, + createdAt: existing?.createdAt || now, updatedAt: now }; - if (options.patch && typeof options.patch === 'object') { - if (typeof options.patch.presetName === 'string') { - sidecar.presetName = options.patch.presetName.trim(); - } - if (typeof options.patch.description === 'string') { - sidecar.description = options.patch.description.trim(); - } - if (options.patch.tags !== undefined) { - sidecar.tags = normalizeTags(options.patch.tags); - } - sidecar.updatedAt = now; - } - await fs.writeFile(sidecarPath, JSON.stringify(sidecar, null, 2), 'utf-8'); - return { sidecarPath, sidecar, existed: !!existing }; + return { sidecarPath, sidecar }; } -async function buildKnowledgeMediaList(knowledgeRootPath, keyword = '') { - const mediaFiles = await walkKnowledgeMediaFiles(knowledgeRootPath); - const loweredKeyword = (keyword || '').trim().toLowerCase(); - const sidecarSuffix = getSidecarSuffix(); +async function traceAndPersistMedia(request) { + const knowledgeRootPath = getKnowledgeRootPath(); + await fs.access(knowledgeRootPath); + + const { + mediaPath, + description, + tags, + presetName, + generator, + source, + targetDiaryName, + targetFileName, + relocateMode = 'copy', + overwrite = false + } = request || {}; + + const resolved = await resolveMediaPath(mediaPath); + debugLog('Resolved source media', resolved); + + const sourceSidecarResult = await buildAndWriteSidecar(resolved.mediaPath, knowledgeRootPath, { + description, + tags, + presetName, + generator, + source + }); + + const result = { + action: 'TraceKnowledgeMedia', + source: { + mediaPath: toFileUrl(resolved.mediaPath), + sidecarPath: toFileUrl(sourceSidecarResult.sidecarPath), + sidecar: sourceSidecarResult.sidecar + }, + target: null + }; - const items = []; - for (const mediaPath of mediaFiles) { - let stats; - try { - stats = await fs.stat(mediaPath); - } catch (_) { - continue; - } + const normalizedDiaryName = (typeof targetDiaryName === 'string' && targetDiaryName.trim()) + ? targetDiaryName.trim() + : (typeof request?.targetDiary === 'string' ? request.targetDiary.trim() : ''); + const targetDiaryPath = resolveTargetDiaryPath(normalizedDiaryName, knowledgeRootPath); + if (!targetDiaryPath) { + return result; + } - const sidecarPath = `${mediaPath}${sidecarSuffix}`; - const sidecar = await safeReadJson(sidecarPath); - const relativePath = path.relative(knowledgeRootPath, mediaPath); - const description = sidecar && typeof sidecar.description === 'string' ? sidecar.description : ''; - const presetName = sidecar && typeof sidecar.presetName === 'string' ? sidecar.presetName : ''; - const tags = sidecar && Array.isArray(sidecar.tags) ? normalizeTags(sidecar.tags) : []; - - const item = { - mediaPath: toFileUrl(mediaPath), - relativePath, - extension: path.extname(mediaPath).toLowerCase(), - size: stats.size, - modifiedAt: stats.mtime.toISOString(), - sidecarPath: toFileUrl(sidecarPath), - hasSidecar: !!sidecar, - presetName, - description, - tags - }; + await ensureDirectory(targetDiaryPath); - if (loweredKeyword) { - const searchable = [ - relativePath.toLowerCase(), - description.toLowerCase(), - presetName.toLowerCase(), - tags.join(',').toLowerCase() - ].join('\n'); - if (!searchable.includes(loweredKeyword)) { - continue; - } - } + const isCopy = String(relocateMode || 'copy').toLowerCase() === 'copy'; + const sourceFileName = path.basename(resolved.mediaPath); + const finalFileName = (isCopy && typeof targetFileName === 'string' && targetFileName.trim()) + ? targetFileName.trim() + : sourceFileName; - items.push(item); + const requestedTargetMediaPath = path.join(targetDiaryPath, finalFileName); + const writableTargetMediaPath = await resolveWritableTargetPath(requestedTargetMediaPath, !!overwrite); + + const sidecarSuffix = getSidecarSuffix(); + const sourceSidecarPath = sourceSidecarResult.sidecarPath; + const writableTargetSidecarPath = `${writableTargetMediaPath}${sidecarSuffix}`; + + const shouldMove = String(relocateMode || 'copy').toLowerCase() === 'move'; + if (shouldMove) { + await moveFileCrossDeviceSafe(resolved.mediaPath, writableTargetMediaPath); + await moveFileCrossDeviceSafe(sourceSidecarPath, writableTargetSidecarPath); + } else { + await fs.copyFile(resolved.mediaPath, writableTargetMediaPath); + await fs.copyFile(sourceSidecarPath, writableTargetSidecarPath); } - items.sort((a, b) => new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime()); - return items; + const targetSidecarResult = await buildAndWriteSidecar(writableTargetMediaPath, knowledgeRootPath, { + description, + tags, + presetName, + generator, + source: source || 'trace' + }); + + result.target = { + mode: shouldMove ? 'move' : 'copy', + diaryName: normalizedDiaryName, + diaryPath: toFileUrl(targetDiaryPath), + mediaPath: toFileUrl(writableTargetMediaPath), + sidecarPath: toFileUrl(targetSidecarResult.sidecarPath), + sidecar: targetSidecarResult.sidecar + }; + + return result; } function convertToVCPFormat(response) { if (response.success) { const data = response.data || {}; - let contentArray = []; - - if (data.content) { - if (Array.isArray(data.content)) { - contentArray.push(...data.content); - } else { - contentArray.push({ type: 'text', text: String(data.content) }); + const content = []; + + if (Array.isArray(data.content) && data.content.length > 0) { + content.push(...data.content); + if (data.message) { + const hasMessage = content.some(item => item?.type === 'text' && typeof item.text === 'string' && item.text.includes(data.message)); + if (!hasMessage) { + content.unshift({ type: 'text', text: data.message }); + } } - } - - if (data.message) { - contentArray.unshift({ type: 'text', text: String(data.message) }); - } - - if (contentArray.length === 0) { - const { content, ...rest } = data; - contentArray.push({ type: 'text', text: JSON.stringify(rest, null, 2) }); + } else { + content.push({ + type: 'text', + text: data.message || '操作成功。' + }); } return { status: 'success', result: { - content: contentArray, + content, details: data } }; } - return { status: 'error', error: response.error || 'Unknown error' }; } -async function processRequest(request) { - const action = request.command; +async function updateSidecarParams(request) { const knowledgeRootPath = getKnowledgeRootPath(); + const { diaryName, fileName, description, tags, presetName, generator, source } = request || {}; - debugLog('Processing request', { action, request }); + if (!diaryName || !fileName) { + throw new Error('diaryName 和 fileName 不能为空。'); + } - try { - await fs.access(knowledgeRootPath); - } catch (error) { - return { - success: false, - error: `知识库根目录不可用: ${knowledgeRootPath} (${error.message})` - }; + const diaryPath = resolveTargetDiaryPath(diaryName, knowledgeRootPath); + const mediaPath = path.join(diaryPath, fileName); + + const stats = await fs.stat(mediaPath); + if (!stats.isFile()) { + throw new Error(`文件不存在: ${mediaPath}`); + } + + const sidecarResult = await buildAndWriteSidecar(mediaPath, knowledgeRootPath, { + description, + tags, + presetName, + generator, + source + }); + + return { + action: 'UpdateSidecarParams', + diaryName, + fileName, + sidecarPath: toFileUrl(sidecarResult.sidecarPath), + sidecar: sidecarResult.sidecar + }; +} + +async function listSidecars(request) { + const knowledgeRootPath = getKnowledgeRootPath(); + const { diaryName } = request || {}; + if (!diaryName) { + throw new Error('diaryName 不能为空。'); + } + + const diaryPath = resolveTargetDiaryPath(diaryName, knowledgeRootPath); + const sidecarSuffix = getSidecarSuffix(); + + const entries = await fs.readdir(diaryPath, { withFileTypes: true }); + const sidecarFiles = entries + .filter(entry => entry.isFile() && entry.name.endsWith(sidecarSuffix)) + .map(entry => entry.name) + .sort((a, b) => a.localeCompare(b, 'zh-Hans-CN')); + + const sidecars = []; + for (const sidecarFileName of sidecarFiles) { + const sidecarAbsPath = path.join(diaryPath, sidecarFileName); + const parsed = await safeReadJson(sidecarAbsPath); + sidecars.push({ + sidecarFileName, + sidecarPath: toFileUrl(sidecarAbsPath), + mediaFileName: sidecarFileName.slice(0, -sidecarSuffix.length), + sidecar: parsed || null + }); + } + + return { + action: 'ListSidecars', + diaryName, + diaryPath: toFileUrl(diaryPath), + count: sidecars.length, + sidecars + }; +} + +async function getSidecar(request) { + const knowledgeRootPath = getKnowledgeRootPath(); + const { diaryName, fileName } = request || {}; + if (!diaryName || !fileName) { + throw new Error('diaryName 和 fileName 不能为空。'); + } + + const diaryPath = resolveTargetDiaryPath(diaryName, knowledgeRootPath); + const sidecarSuffix = getSidecarSuffix(); + const sidecarAbsPath = path.join(diaryPath, `${fileName}${sidecarSuffix}`); + + const stats = await fs.stat(sidecarAbsPath); + if (!stats.isFile()) { + throw new Error(`侧车文件不存在: ${sidecarAbsPath}`); + } + + const parsed = await safeReadJson(sidecarAbsPath); + if (!parsed) { + throw new Error(`侧车文件内容无效: ${sidecarAbsPath}`); + } + + return { + action: 'GetSidecar', + diaryName, + fileName, + sidecarPath: toFileUrl(sidecarAbsPath), + sidecar: parsed + }; +} + +async function readDiaryFile(request) { + const knowledgeRootPath = getKnowledgeRootPath(); + const { diaryName, fileName, encoding = 'utf8' } = request || {}; + + if (!diaryName || !fileName) { + throw new Error('diaryName 和 fileName 不能为空。'); + } + + const diaryPath = resolveTargetDiaryPath(diaryName, knowledgeRootPath); + const targetFilePath = path.join(diaryPath, fileName); + + const stats = await fs.stat(targetFilePath); + if (!stats.isFile()) { + throw new Error(`文件不存在: ${targetFilePath}`); + } + + const extension = path.extname(targetFilePath).toLowerCase(); + const imageExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.tif', '.tiff', '.avif']; + const audioExtensions = ['.mp3', '.wav', '.ogg', '.flac', '.aac', '.m4a']; + const videoExtensions = ['.mp4', '.webm', '.mov', '.mkv', '.avi']; + + let content = []; + let responseEncoding = encoding; + + if (imageExtensions.includes(extension) || audioExtensions.includes(extension) || videoExtensions.includes(extension)) { + const fileBuffer = await fs.readFile(targetFilePath); + const mimeType = guessMimeType(targetFilePath); + const base64Url = `data:${mimeType};base64,${fileBuffer.toString('base64')}`; + + responseEncoding = 'base64'; + content = [ + { + type: 'text', + text: `已读取文件 '${fileName}' (${stats.size} Bytes)。` + }, + { + type: 'image_url', + image_url: { + url: base64Url + } + } + ]; + } else { + const rawContent = await fs.readFile(targetFilePath, encoding); + const textContent = Buffer.isBuffer(rawContent) ? rawContent.toString(encoding) : String(rawContent); + content = [ + { + type: 'text', + text: `已读取文件 '${fileName}' (${stats.size} Bytes)。\n\`\`\`\n${textContent}\n\`\`\`` + } + ]; + } + + return { + action: 'ReadFile', + diaryName, + fileName, + filePath: toFileUrl(targetFilePath), + size: stats.size, + sizeFormatted: `${stats.size} Bytes`, + encoding: responseEncoding, + lastModified: stats.mtime.toISOString(), + content + }; +} + +async function renameDiaryMedia(request) { + const knowledgeRootPath = getKnowledgeRootPath(); + const sidecarSuffix = getSidecarSuffix(); + + const { + diaryName, + fileName, + newFileName, + overwrite = false + } = request || {}; + + if (!diaryName || !fileName || !newFileName) { + throw new Error('diaryName、fileName、newFileName 不能为空。'); + } + + const diaryPath = resolveTargetDiaryPath(diaryName, knowledgeRootPath); + const sourceMediaPath = path.join(diaryPath, fileName); + const requestedTargetMediaPath = path.join(diaryPath, newFileName); + + const sourceStats = await fs.stat(sourceMediaPath); + if (!sourceStats.isFile()) { + throw new Error(`媒体文件不存在: ${sourceMediaPath}`); + } + + const targetMediaPath = await resolveWritableTargetPath(requestedTargetMediaPath, !!overwrite); + const sourceSidecarPath = `${sourceMediaPath}${sidecarSuffix}`; + const targetSidecarPath = `${targetMediaPath}${sidecarSuffix}`; + + const hasSidecar = await fs.access(sourceSidecarPath).then(() => true).catch(() => false); + + await fs.rename(sourceMediaPath, targetMediaPath); + if (hasSidecar) { + await fs.rename(sourceSidecarPath, targetSidecarPath); } + const refreshed = await buildAndWriteSidecar(targetMediaPath, knowledgeRootPath, {}); + + return { + action: 'RenameDiaryMedia', + diaryName, + sourceMediaPath: toFileUrl(sourceMediaPath), + targetMediaPath: toFileUrl(targetMediaPath), + sourceSidecarPath: hasSidecar ? toFileUrl(sourceSidecarPath) : null, + targetSidecarPath: toFileUrl(refreshed.sidecarPath), + sidecar: refreshed.sidecar + }; +} + +async function processRequest(request) { + const action = request?.command; + debugLog('Processing request', request); + try { switch (action) { - case 'ListKnowledgeMedia': { - const keyword = typeof request.keyword === 'string' ? request.keyword : ''; - const items = await buildKnowledgeMediaList(knowledgeRootPath, keyword); + case 'TraceKnowledgeMedia': { + const traceResult = await traceAndPersistMedia(request); + return { + success: true, + data: { + message: traceResult.target + ? '媒体已完成:侧车写入 + 入库同步完成。' + : '媒体已完成:侧车写入完成。', + ...traceResult + } + }; + } + + case 'UpdateSidecarParams': { + const updateResult = await updateSidecarParams(request); return { success: true, data: { - message: `知识库多媒体列表获取成功,共 ${items.length} 项。`, - rootPath: toFileUrl(knowledgeRootPath), - sidecarSuffix: getSidecarSuffix(), - total: items.length, - items + message: '侧车参数更新完成。', + ...updateResult } }; } - case 'UpdateKnowledgeMedia': { - const { mediaPath, description, presetName, tags } = request; - const resolved = await resolveKnowledgeMediaPath(mediaPath, knowledgeRootPath); - const patch = { description, presetName, tags }; - const { sidecarPath, sidecar } = await ensureSidecarForMedia(resolved.mediaPath, { patch }); + case 'ListSidecars': { + const listResult = await listSidecars(request); return { success: true, data: { - message: '媒体侧车已更新。', - mediaPath: toFileUrl(resolved.mediaPath), - sidecarPath: toFileUrl(sidecarPath), - sidecar + message: `侧车文件列出完成,共 ${listResult.count} 个。`, + ...listResult } }; } - case 'RegenerateKnowledgeMedia': { - const { mediaPath } = request; - const resolved = await resolveKnowledgeMediaPath(mediaPath, knowledgeRootPath); - const { sidecarPath, sidecar } = await ensureSidecarForMedia(resolved.mediaPath, { regenerate: true }); + case 'GetSidecar': { + const getResult = await getSidecar(request); return { success: true, data: { - message: '媒体描述已重生成。', - mediaPath: toFileUrl(resolved.mediaPath), - sidecarPath: toFileUrl(sidecarPath), - sidecar + message: '侧车文件读取完成。', + ...getResult } }; } - case 'RebuildKnowledgeMedia': { - const regenerateExisting = !!request.regenerateExisting; - const mediaFiles = await walkKnowledgeMediaFiles(knowledgeRootPath); - - let created = 0; - let updated = 0; - for (const mediaPath of mediaFiles) { - const sidecarPath = `${mediaPath}${getSidecarSuffix()}`; - const sidecarExists = !!(await safeReadJson(sidecarPath)); - - if (!sidecarExists || regenerateExisting) { - await ensureSidecarForMedia(mediaPath, { regenerate: regenerateExisting }); - if (sidecarExists) { - updated += 1; - } else { - created += 1; - } + case 'ReadFile': { + const fileResult = await readDiaryFile(request); + return { + success: true, + data: { + message: '文件读取完成。', + ...fileResult } - } + }; + } + case 'RenameDiaryMedia': { + const renameResult = await renameDiaryMedia(request); return { success: true, data: { - message: '知识库多媒体侧车重建完成。', - scanned: mediaFiles.length, - created, - updated + message: '多模态文件重命名完成,侧车已联动刷新。', + ...renameResult } }; } diff --git a/Plugin/KnowledgeMediaDescriber/plugin-manifest.json b/Plugin/KnowledgeMediaDescriber/plugin-manifest.json index e0d6ce12f..25237bb2a 100644 --- a/Plugin/KnowledgeMediaDescriber/plugin-manifest.json +++ b/Plugin/KnowledgeMediaDescriber/plugin-manifest.json @@ -1,9 +1,9 @@ { "manifestVersion": "1.0.0", "name": "KnowledgeMediaDescriber", - "version": "1.0.0", - "displayName": "知识库多媒体描述管理器", - "description": "管理 KNOWLEDGEBASE_ROOT_PATH 下多媒体文件侧车描述,支持列表、更新、重生成与重建。", + "version": "2.0.1", + "displayName": "知识库多媒体超栈追踪器", + "description": "基于已知 file:// 地址执行多媒体“超栈追踪”的一体化插件。可完成:媒体定位、SHA256哈希计算、侧车创建/更新(含描述与Tag写入)、以及在同一请求中将媒体与侧车复制/移动到指定日记本目录并即时刷新 filePath / mediaPath / relativePath / mediaHash。", "author": "System", "pluginType": "synchronous", "entryPoint": { @@ -16,20 +16,28 @@ "capabilities": { "invocationCommands": [ { - "command": "ListKnowledgeMedia", - "description": "列出知识库中的多媒体文件与侧车信息。参数: keyword(可选,关键字过滤)。" + "command": "TraceKnowledgeMedia", + "description": "功能: 通过一个已知的媒体 file:// 地址执行“超栈追踪”主流程。\n主流程包含两段,可一次性完成:\n1) 追踪并写侧车:解析媒体文件、校验类型、计算 SHA256 哈希,生成或更新 sidecar,并可写入 description / tags / presetName / generator / source。\n2) 入库同步(可选):当提供 targetDiaryName 后,在同一请求内把媒体文件与侧车文件一起复制或移动到目标日记本目录,并即时刷新侧车中的 filePath、mediaPath、relativePath、mediaHash。\n\n参数:\n- mediaPath (字符串, 必需): 媒体文件地址,推荐 file:// 绝对路径。\n- description (字符串, 可选): 主动写入侧车描述。\n- tags (字符串或数组, 可选): 主动写入侧车 Tag。字符串可用逗号分隔。\n- presetName (字符串, 可选): 侧车预设名。\n- generator (字符串或数组, 可选): 侧车 generator 字段。\n- source (字符串, 可选): 侧车 source 字段。\n- targetDiaryName (字符串, 可选): 目标日记本名称(只写 name,不写路径)。插件会自动保存到 KNOWLEDGEBASE_ROOT_PATH/targetDiaryName 下。\n- targetFileName (字符串, 可选): 目标文件名(含扩展名)。仅在 relocateMode 为 copy 时有效,用于在复制时重命名。\n- relocateMode (字符串, 可选, 默认 copy): copy 或 move。\n- overwrite (布尔, 可选, 默认 false): 是否允许覆盖同名文件;false 时自动改名避免覆盖。\n\n调用格式(仅写侧车):\n<<<[TOOL_REQUEST]>>>\ntool_name:「始」KnowledgeMediaDescriber「末」,\ncommand:「始」TraceKnowledgeMedia「末」,\nmediaPath:「始」file:///abs/path/IMG_1202.JPG「末」,\ndescription:「始」会议白板照片,核心结论已拍摄留档。「末」,\ntags:「始」会议,白板,记录「末」,\npresetName:「始」CognitoName01「末」\n<<<[END_TOOL_REQUEST]>>>\n\n调用格式(一次性:写侧车 + 复制到目标日记本并重命名):\n<<<[TOOL_REQUEST]>>>\ntool_name:「始」KnowledgeMediaDescriber「末」,\ncommand:「始」TraceKnowledgeMedia「末」,\nmediaPath:「始」file:///abs/path/IMG_1202.JPG「末」,\ndescription:「始」项目A现场截图。「末」,\ntags:「始」项目A,现场,截图「末」,\ntargetDiaryName:「始」Nova「末」,\ntargetFileName:「始」ProjectA_Site_01.jpg「末」,\nrelocateMode:「始」copy「末」,\noverwrite:「始」false「末」\n<<<[END_TOOL_REQUEST]>>>\n\n调用格式(一次性:写侧车 + 移动到目标日记本):\n<<<[TOOL_REQUEST]>>>\ntool_name:「始」KnowledgeMediaDescriber「末」,\ncommand:「始」TraceKnowledgeMedia「末」,\nmediaPath:「始」file:///abs/path/IMG_1202.JPG「末」,\ntargetDiaryName:「始」Nova「末」,\nrelocateMode:「始」move「末」,\noverwrite:「始」false「末」\n<<<[END_TOOL_REQUEST]>>>" }, { - "command": "UpdateKnowledgeMedia", - "description": "更新指定媒体文件侧车信息。参数: mediaPath(必需), description(可选), presetName(可选), tags(可选,字符串或数组)。" + "command": "UpdateSidecarParams", + "description": "功能: 更改指定日记本中某个文件的侧车文件参数。\n\n参数:\n- diaryName (字符串, 必需): 日记本名称。\n- fileName (字符串, 必需): 媒体文件名(含扩展名)。\n- description (字符串, 可选): 更新描述。\n- tags (字符串或数组, 可选): 更新 Tag。\n- presetName (字符串, 可选): 更新预设名。\n- generator (字符串或数组, 可选): 更新 generator 字段。\n- source (字符串, 可选): 更新 source 字段。\n\n调用格式:\n<<<[TOOL_REQUEST]>>>\ntool_name:「始」KnowledgeMediaDescriber「末」,\ncommand:「始」UpdateSidecarParams「末」,\ndiaryName:「始」Nova「末」,\nfileName:「始」ProjectA_Site_01.jpg「末」,\ndescription:「始」更新后的描述内容「末」,\ntags:「始」新标签1,新标签2「末」\n<<<[END_TOOL_REQUEST]>>>" }, { - "command": "RegenerateKnowledgeMedia", - "description": "重生成指定媒体文件描述。参数: mediaPath(必需)。" + "command": "ListSidecars", + "description": "功能: 列出指定日记本中的侧车文件。\n\n参数:\n- diaryName (字符串, 必需): 日记本名称。\n\n调用格式:\n<<<[TOOL_REQUEST]>>>\ntool_name:「始」KnowledgeMediaDescriber「末」,\ncommand:「始」ListSidecars「末」,\ndiaryName:「始」Nova「末」\n<<<[END_TOOL_REQUEST]>>>" }, { - "command": "RebuildKnowledgeMedia", - "description": "批量重建侧车文件。参数: regenerateExisting(可选,布尔)。" + "command": "GetSidecar", + "description": "功能: 查看指定日记本中某个文件的侧车文件内容。\n\n参数:\n- diaryName (字符串, 必需): 日记本名称。\n- fileName (字符串, 必需): 媒体文件名(含扩展名)。\n\n调用格式:\n<<<[TOOL_REQUEST]>>>\ntool_name:「始」KnowledgeMediaDescriber「末」,\ncommand:「始」GetSidecar「末」,\ndiaryName:「始」Nova「末」,\nfileName:「始」ProjectA_Site_01.jpg「末」\n<<<[END_TOOL_REQUEST]>>>" + }, + { + "command": "ReadFile", + "description": "功能: 读取指定日记本中的指定文件内容(同 FileOperator 的 ReadFile 语义,默认不设大小限制)。\n参数:\n- diaryName (字符串, 必需): 日记本名称。\n- fileName (字符串, 必需): 文件名(含扩展名)。\n- encoding (字符串, 可选, 默认 utf8): 文本读取编码。\n\n调用格式:\n<<<[TOOL_REQUEST]>>>\ntool_name:「始」KnowledgeMediaDescriber「末」,\ncommand:「始」ReadFile「末」,\ndiaryName:「始」Nova「末」,\nfileName:「始」notes.txt「末」,\nencoding:「始」utf8「末」\n<<<[END_TOOL_REQUEST]>>>" + }, + { + "command": "RenameDiaryMedia", + "description": "功能: 重命名指定日记本中的指定多模态文件,并联动重命名对应侧车文件,同时刷新侧车中的 filePath / mediaPath / relativePath / mediaHash。\n参数:\n- diaryName (字符串, 必需): 日记本名称。\n- fileName (字符串, 必需): 原媒体文件名(含扩展名)。\n- newFileName (字符串, 必需): 新媒体文件名(含扩展名)。\n- overwrite (布尔, 可选, 默认 false): 是否允许覆盖同名文件;false 时自动改名避免覆盖。\n\n调用格式:\n<<<[TOOL_REQUEST]>>>\ntool_name:「始」KnowledgeMediaDescriber「末」,\ncommand:「始」RenameDiaryMedia「末」,\ndiaryName:「始」Nova「末」,\nfileName:「始」ProjectA_Site_01.jpg「末」,\nnewFileName:「始」ProjectA_Site_Final.jpg「末」,\noverwrite:「始」false「末」\n<<<[END_TOOL_REQUEST]>>>" } ] }, From f1862333ded711586e03f311c2d4f99d6598c411 Mon Sep 17 00:00:00 2001 From: Oxyel <74046599+TyChest@users.noreply.github.com> Date: Thu, 26 Feb 2026 23:50:59 +0800 Subject: [PATCH 5/6] update --- AdminPanel/image_cache_editor.html | 262 +++++++++++++- AdminPanel/index.html | 7 + AdminPanel/js/multimedia-presets-editor.js | 388 +++++++++++++++++++++ AdminPanel/multimedia_presets_editor.html | 339 ++++++++++++++++++ Plugin/ImageProcessor/image-processor.js | 251 +++++++++++-- Plugin/ImageProcessor/reidentify_image.js | 156 ++++++++- Plugin/RAGDiaryPlugin/RAGDiaryPlugin.js | 108 ++++-- modules/chatCompletionHandler.js | 44 +++ routes/adminPanelRoutes.js | 155 +++++++- 9 files changed, 1642 insertions(+), 68 deletions(-) create mode 100644 AdminPanel/js/multimedia-presets-editor.js create mode 100644 AdminPanel/multimedia_presets_editor.html diff --git a/AdminPanel/image_cache_editor.html b/AdminPanel/image_cache_editor.html index 777ccfbbf..81cc12483 100644 --- a/AdminPanel/image_cache_editor.html +++ b/AdminPanel/image_cache_editor.html @@ -66,6 +66,111 @@

    多媒体缓存列表

    return 'application/octet-stream'; // Default for unknown } + function generateId() { + if (window.crypto && typeof window.crypto.randomUUID === 'function') { + return window.crypto.randomUUID(); + } + return `id_${Date.now()}_${Math.floor(Math.random() * 1000)}`; + } + + function normalizeCacheEntry(entry, now) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + const baseDesc = typeof entry === 'string' ? entry : ''; + const baseId = generateId(); + return { + id: baseId, + description: baseDesc, + timestamp: now, + variants: { + 'Cognito-Core': { + id: baseId, + description: baseDesc, + timestamp: now, + cognitoAgent: 'Cognito-Core' + } + } + }; + } + if (!entry.variants || typeof entry.variants !== 'object') { + entry.variants = {}; + } + return entry; + } + + function updateDefaultDescription(entry, description, now) { + if (!entry || typeof entry !== 'object') return; + if (!entry.variants || typeof entry.variants !== 'object') { + entry.variants = {}; + } + const existingVariant = entry.variants['Cognito-Core'] || {}; + entry.variants['Cognito-Core'] = { + ...existingVariant, + id: existingVariant.id || entry.id || generateId(), + description, + timestamp: now, + mimeType: existingVariant.mimeType || entry.mimeType, + cognitoAgent: 'Cognito-Core' + }; + + const isDefaultAgent = !entry.cognitoAgent || entry.cognitoAgent === 'Cognito-Core'; + if (isDefaultAgent) { + entry.description = description; + entry.timestamp = now; + } + } + + function getPresetOptions(entry) { + const options = new Set(); + if (entry && typeof entry === 'object') { + if (entry.cognitoAgent) options.add(entry.cognitoAgent); + if (entry.variants && typeof entry.variants === 'object') { + Object.keys(entry.variants).forEach(key => options.add(key)); + } + } + options.add('Cognito-Core'); + return Array.from(options).filter(Boolean).map(name => ({ name, label: name })); + } + + function resolveDescriptionByPreset(entry, presetName) { + if (!entry || typeof entry !== 'object') return ''; + const normalized = (presetName || '').trim(); + + if (!normalized) { + const isDefaultAgent = !entry.cognitoAgent || entry.cognitoAgent === 'Cognito-Core'; + if (isDefaultAgent && entry.description) return entry.description; + if (entry.variants && entry.variants['Cognito-Core'] && entry.variants['Cognito-Core'].description) { + return entry.variants['Cognito-Core'].description; + } + return entry.description || ''; + } + + if (entry.variants && entry.variants[normalized] && entry.variants[normalized].description) { + return entry.variants[normalized].description; + } + if (entry.cognitoAgent === normalized && entry.description) { + return entry.description; + } + return ''; + } + + function applyPresetSelection(entryDiv, entry, presetName, presetInput, descriptionTextarea) { + const normalized = (presetName || '').trim(); + entryDiv.dataset.presetName = normalized; + if (presetInput) { + presetInput.value = normalized; + } + descriptionTextarea.value = resolveDescriptionByPreset(entry, normalized); + const buttons = entryDiv.querySelectorAll('.preset-toggle-btn'); + buttons.forEach(btn => { + const btnPreset = btn.dataset.presetName || ''; + if (btnPreset === normalized) { + btn.classList.add('active'); + } else { + btn.classList.remove('active'); + } + }); + } + async function loadMediaCache() { try { const response = await fetch('/admin_api/multimodal-cache'); // UPDATED API ENDPOINT @@ -89,8 +194,37 @@

    多媒体缓存列表

    const entryDiv = entries[i]; const base64Key = entryDiv.dataset.base64Key; const textarea = entryDiv.querySelector('textarea'); + const presetInput = entryDiv.querySelector('.preset-input'); + const presetName = entryDiv.dataset.presetName !== undefined + ? entryDiv.dataset.presetName + : (presetInput ? presetInput.value.trim() : ''); + const now = new Date().toISOString(); + if (mediaCacheData[base64Key]) { - mediaCacheData[base64Key].description = textarea.value; + const entry = normalizeCacheEntry(mediaCacheData[base64Key], now); + + if (presetName) { + const existingVariant = entry.variants[presetName] || {}; + entry.variants[presetName] = { + ...existingVariant, + id: existingVariant.id || entry.id || generateId(), + description: textarea.value, + timestamp: now, + mimeType: existingVariant.mimeType || entry.mimeType, + cognitoAgent: presetName + }; + if (!entry.cognitoAgent) { + entry.cognitoAgent = presetName; + } + if (entry.cognitoAgent === presetName) { + entry.description = textarea.value; + entry.timestamp = now; + } + } else { + updateDefaultDescription(entry, textarea.value, now); + } + + mediaCacheData[base64Key] = entry; } } @@ -185,12 +319,16 @@

    多媒体缓存列表

    try { // Call the new backend API endpoint + const presetInput = entryDiv.querySelector('.preset-input'); + const presetName = entryDiv.dataset.presetName !== undefined + ? entryDiv.dataset.presetName + : (presetInput ? presetInput.value.trim() : ''); const response = await fetch('/admin_api/multimodal-cache/reidentify', { // UPDATED API ENDPOINT method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ base64Key: base64Key }) + body: JSON.stringify({ base64Key: base64Key, presetName }) }); const result = await response.json(); @@ -202,10 +340,33 @@

    多媒体缓存列表

    // Update the mediaCacheData in memory as well if (mediaCacheData[base64Key]) { - mediaCacheData[base64Key].description = result.newDescription || ''; - mediaCacheData[base64Key].timestamp = result.newTimestamp || mediaCacheData[base64Key].timestamp; // Keep old timestamp if new one is missing - } + const presetName = (result.presetName || entryDiv.dataset.presetName || (entryDiv.querySelector('.preset-input')?.value || '')).trim(); + const now = result.newTimestamp || new Date().toISOString(); + const entry = normalizeCacheEntry(mediaCacheData[base64Key], now); + + if (presetName) { + const existingVariant = entry.variants[presetName] || {}; + entry.variants[presetName] = { + ...existingVariant, + id: existingVariant.id || entry.id || generateId(), + description: result.newDescription || '', + timestamp: now, + mimeType: existingVariant.mimeType || entry.mimeType, + cognitoAgent: presetName + }; + if (!entry.cognitoAgent) { + entry.cognitoAgent = presetName; + } + if (entry.cognitoAgent === presetName) { + entry.description = result.newDescription || ''; + entry.timestamp = now; + } + } else { + updateDefaultDescription(entry, result.newDescription || '', now); + } + mediaCacheData[base64Key] = entry; + } statusSpan.textContent = '重新识别成功!'; statusSpan.style.color = '#2ecc71'; // Success color @@ -334,13 +495,102 @@

    多媒体缓存列表

    }; entryDiv.appendChild(mediaElement); + const presetLabel = document.createElement('label'); + presetLabel.textContent = '署名/预设名:'; + entryDiv.appendChild(presetLabel); + + const presetInput = document.createElement('input'); + presetInput.type = 'text'; + presetInput.className = 'preset-input'; + const presetOptions = getPresetOptions(entry); + const defaultPreset = (entry && typeof entry === 'object' && entry.cognitoAgent) + ? entry.cognitoAgent + : 'Cognito-Core'; + entryDiv.dataset.presetName = defaultPreset || ''; + presetInput.value = defaultPreset; + const presetListId = `preset-options-${mediaKeys.indexOf(base64Key)}`; + presetInput.setAttribute('list', presetListId); + + const presetList = document.createElement('datalist'); + presetList.id = presetListId; + presetOptions + .filter(option => option.name) + .forEach(option => { + const opt = document.createElement('option'); + opt.value = option.name; + presetList.appendChild(opt); + }); + + presetInput.addEventListener('change', () => { + const selectedPreset = presetInput.value.trim(); + applyPresetSelection(entryDiv, entry, selectedPreset, presetInput, descriptionTextarea); + }); + + entryDiv.appendChild(presetInput); + entryDiv.appendChild(presetList); + + const presetButtons = document.createElement('div'); + presetButtons.className = 'preset-toggle'; + presetOptions.forEach(option => { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'preset-toggle-btn'; + btn.dataset.presetName = option.name; + btn.textContent = option.label; + btn.style.marginRight = '6px'; + btn.style.marginBottom = '6px'; + btn.addEventListener('click', () => { + applyPresetSelection(entryDiv, entry, option.name, presetInput, descriptionTextarea); + }); + btn.addEventListener('contextmenu', (event) => { + event.preventDefault(); + if (option.name === 'Cognito-Core') { + alert('Cognito-Core 为默认描述,不能删除。'); + return; + } + if (!confirm(`确定要删除署名「${option.label}」的描述吗?`)) { + return; + } + + const now = new Date().toISOString(); + const currentEntry = normalizeCacheEntry(mediaCacheData[base64Key], now); + if (currentEntry.variants && currentEntry.variants[option.name]) { + delete currentEntry.variants[option.name]; + } + + if (currentEntry.cognitoAgent === option.name) { + currentEntry.cognitoAgent = 'Cognito-Core'; + if (currentEntry.variants['Cognito-Core'] && currentEntry.variants['Cognito-Core'].description) { + currentEntry.description = currentEntry.variants['Cognito-Core'].description; + currentEntry.timestamp = currentEntry.variants['Cognito-Core'].timestamp || currentEntry.timestamp; + } + } + + mediaCacheData[base64Key] = currentEntry; + + const presetListOption = presetList.querySelector(`option[value="${option.name}"]`); + if (presetListOption) { + presetListOption.remove(); + } + btn.remove(); + + const currentPreset = entryDiv.dataset.presetName || ''; + if (currentPreset === option.name) { + applyPresetSelection(entryDiv, currentEntry, 'Cognito-Core', presetInput, descriptionTextarea); + } + }); + presetButtons.appendChild(btn); + }); + entryDiv.appendChild(presetButtons); + const descriptionLabel = document.createElement('label'); descriptionLabel.textContent = '媒体描述 (可编辑):'; entryDiv.appendChild(descriptionLabel); const descriptionTextarea = document.createElement('textarea'); - descriptionTextarea.value = entry.description || ''; + descriptionTextarea.value = resolveDescriptionByPreset(entry, entryDiv.dataset.presetName || ''); entryDiv.appendChild(descriptionTextarea); + applyPresetSelection(entryDiv, entry, entryDiv.dataset.presetName || '', presetInput, descriptionTextarea); const keyInfo = document.createElement('div'); keyInfo.className = 'base64-key'; diff --git a/AdminPanel/index.html b/AdminPanel/index.html index 1f4d09454..20034bb75 100644 --- a/AdminPanel/index.html +++ b/AdminPanel/index.html @@ -440,6 +440,8 @@

    控制中心

    class="material-symbols-outlined">hub语义组编辑器
  • casinoVCPTavern预设编辑
  • +
  • rule_settings多媒体预设编辑器
  • smart_toyAgent管理器
  • VCPTavern预设编辑 +
    +

    多媒体预设编辑器

    + +
    +

    Agent 管理

    管理 Agent 的定义名称与对应的 .txt 文件。在这里可以添加、删除和修改 Agent 映射,并直接编辑关联的文本文件。

    diff --git a/AdminPanel/js/multimedia-presets-editor.js b/AdminPanel/js/multimedia-presets-editor.js new file mode 100644 index 000000000..ab80c1e52 --- /dev/null +++ b/AdminPanel/js/multimedia-presets-editor.js @@ -0,0 +1,388 @@ +(function () { + const API_BASE = '/admin_api/multimedia-presets'; + + const state = { + items: [], + currentFileName: '', + loadedSnapshot: null + }; + + function qs(selector) { + return document.querySelector(selector); + } + + function setStatus(message, type = 'info') { + const el = qs('#mpe-status'); + if (!el) return; + el.textContent = message || ''; + el.className = `status-message ${type}`; + } + + function escapeHtml(value) { + return String(value || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + async function apiFetch(url, options = {}) { + const response = await fetch(url, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...(options.headers || {}) + } + }); + + const text = await response.text(); + let data = {}; + try { + data = text ? JSON.parse(text) : {}; + } catch (_) { + data = { error: text || `HTTP ${response.status}` }; + } + + if (!response.ok) { + throw new Error(data.error || data.details || `HTTP ${response.status}`); + } + return data; + } + + function normalizeFileName(name) { + const raw = String(name || '').trim(); + if (!raw) return ''; + return raw.toLowerCase().endsWith('.json') ? raw : `${raw}.json`; + } + + function parseOptionalNumber(inputId, asInteger = false) { + const raw = String(qs(inputId)?.value || '').trim(); + if (!raw) return undefined; + const num = asInteger ? parseInt(raw, 10) : parseFloat(raw); + if (Number.isNaN(num)) return undefined; + return num; + } + + function clearRulesList() { + const list = qs('#mpe-rules-list'); + if (list) list.innerHTML = ''; + } + + function createRuleCard(rule = {}) { + const list = qs('#mpe-rules-list'); + if (!list) return; + + const card = document.createElement('div'); + card.className = 'rule-card'; + card.innerHTML = ` +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    + +
    + `; + + card.querySelector('.rule-delete-btn')?.addEventListener('click', () => { + card.remove(); + }); + + list.appendChild(card); + } + + function getRulesFromEditor() { + const list = qs('#mpe-rules-list'); + if (!list) return []; + + const cards = Array.from(list.querySelectorAll('.rule-card')); + return cards + .map(card => ({ + pattern: String(card.querySelector('.rule-pattern')?.value || '').trim(), + flags: String(card.querySelector('.rule-flags')?.value || '').trim(), + replace: String(card.querySelector('.rule-replace')?.value || '') + })) + .filter(rule => rule.pattern); + } + + function toggleTypeUI(type) { + const isRegex = type === 'regexRule'; + const promptGroup = qs('#mpe-prompt-group'); + const rulesBlock = qs('#mpe-rules-block'); + + if (promptGroup) { + promptGroup.style.display = isRegex ? 'none' : ''; + } + if (rulesBlock) { + rulesBlock.style.display = isRegex ? '' : 'none'; + } + } + + function fillEditor(fileName, presetData) { + const data = presetData && typeof presetData === 'object' ? presetData : {}; + const type = data.type === 'regexRule' ? 'regexRule' : 'cognito'; + const requestParams = data.requestParams && typeof data.requestParams === 'object' + ? data.requestParams + : {}; + + qs('#mpe-file-name').value = fileName || ''; + qs('#mpe-type').value = type; + qs('#mpe-name').value = data.name || ''; + qs('#mpe-description').value = data.description || ''; + qs('#mpe-prompt').value = data.prompt || ''; + + qs('#mpe-model').value = requestParams.model || data.model || ''; + qs('#mpe-temperature').value = requestParams.temperature ?? data.temperature ?? ''; + qs('#mpe-top-p').value = requestParams.top_p ?? data.top_p ?? ''; + qs('#mpe-top-k').value = requestParams.top_k ?? data.top_k ?? ''; + + clearRulesList(); + const rules = Array.isArray(data.rules) ? data.rules : []; + if (rules.length > 0) { + rules.forEach(rule => createRuleCard(rule)); + } + + toggleTypeUI(type); + + state.currentFileName = fileName || ''; + state.loadedSnapshot = JSON.parse(JSON.stringify(data)); + } + + function clearEditor(type = 'cognito') { + qs('#mpe-file-name').value = ''; + qs('#mpe-type').value = type; + qs('#mpe-name').value = ''; + qs('#mpe-description').value = ''; + qs('#mpe-prompt').value = ''; + qs('#mpe-model').value = ''; + qs('#mpe-temperature').value = ''; + qs('#mpe-top-p').value = ''; + qs('#mpe-top-k').value = ''; + clearRulesList(); + if (type === 'regexRule') { + createRuleCard({ pattern: '', flags: 'g', replace: '' }); + } + toggleTypeUI(type); + state.currentFileName = ''; + state.loadedSnapshot = null; + } + + function collectPayload() { + const type = qs('#mpe-type').value === 'regexRule' ? 'regexRule' : 'cognito'; + const name = String(qs('#mpe-name')?.value || '').trim(); + const description = String(qs('#mpe-description')?.value || '').trim(); + + const payload = { + name: name || '', + type, + description + }; + + if (type === 'regexRule') { + payload.rules = getRulesFromEditor(); + } else { + payload.prompt = String(qs('#mpe-prompt')?.value || ''); + } + + const requestParams = {}; + const model = String(qs('#mpe-model')?.value || '').trim(); + if (model) requestParams.model = model; + + const temperature = parseOptionalNumber('#mpe-temperature'); + const topP = parseOptionalNumber('#mpe-top-p'); + const topK = parseOptionalNumber('#mpe-top-k', true); + + if (typeof temperature === 'number') requestParams.temperature = temperature; + if (typeof topP === 'number') requestParams.top_p = topP; + if (typeof topK === 'number') requestParams.top_k = topK; + + if (Object.keys(requestParams).length > 0) { + payload.requestParams = requestParams; + } + + return payload; + } + + async function loadList() { + const filter = String(qs('#mpe-type-filter')?.value || 'all'); + const search = String(qs('#mpe-search-input')?.value || '').trim().toLowerCase(); + + setStatus('加载预设列表中...', 'info'); + try { + const query = filter !== 'all' ? `?type=${encodeURIComponent(filter)}` : ''; + const data = await apiFetch(`${API_BASE}${query}`); + const items = Array.isArray(data.items) ? data.items : []; + + state.items = items.filter(item => { + if (!search) return true; + const haystack = `${item.fileName || ''}\n${item.name || ''}\n${item.description || ''}`.toLowerCase(); + return haystack.includes(search); + }); + + renderList(); + setStatus(`已加载 ${state.items.length} 个预设`, 'success'); + } catch (error) { + setStatus(`加载失败:${error.message}`, 'error'); + } + } + + function renderList() { + const listEl = qs('#mpe-preset-list'); + if (!listEl) return; + + if (!state.items.length) { + listEl.innerHTML = '
  • 没有匹配的预设
  • '; + return; + } + + listEl.innerHTML = state.items.map(item => { + const active = item.fileName === state.currentFileName ? 'active' : ''; + return ` +
  • +
    ${escapeHtml(item.name || item.fileName || '')}
    +
    + ${escapeHtml(item.type || '')} + ${escapeHtml(item.fileName || '')} +
    +
  • + `; + }).join(''); + } + + async function loadPreset(fileName) { + if (!fileName) return; + setStatus(`加载 ${fileName} ...`, 'info'); + try { + const data = await apiFetch(`${API_BASE}/${encodeURIComponent(fileName)}`); + fillEditor(fileName, data.data || {}); + renderList(); + setStatus(`已加载 ${fileName}`, 'success'); + } catch (error) { + setStatus(`读取失败:${error.message}`, 'error'); + } + } + + async function savePreset() { + const fileName = normalizeFileName(qs('#mpe-file-name')?.value); + if (!fileName) { + setStatus('请填写文件名(.json)', 'error'); + return; + } + + const payload = collectPayload(); + if (payload.type === 'regexRule' && (!Array.isArray(payload.rules) || payload.rules.length === 0)) { + setStatus('RegexRule 至少需要一条规则(pattern)', 'error'); + return; + } + if (payload.type === 'cognito' && !String(payload.prompt || '').trim()) { + setStatus('Cognito 预设需要 prompt 内容', 'error'); + return; + } + + setStatus(`保存 ${fileName} 中...`, 'info'); + try { + await apiFetch(`${API_BASE}/${encodeURIComponent(fileName)}`, { + method: 'POST', + body: JSON.stringify(payload) + }); + + state.currentFileName = fileName; + state.loadedSnapshot = JSON.parse(JSON.stringify(payload)); + + await loadList(); + renderList(); + setStatus(`保存成功:${fileName}`, 'success'); + } catch (error) { + setStatus(`保存失败:${error.message}`, 'error'); + } + } + + async function deletePreset() { + const fileName = normalizeFileName(qs('#mpe-file-name')?.value || state.currentFileName); + if (!fileName) { + setStatus('请先选择或填写要删除的文件名', 'error'); + return; + } + + if (!window.confirm(`确定删除预设 ${fileName} 吗?`)) return; + + setStatus(`删除 ${fileName} 中...`, 'info'); + try { + await apiFetch(`${API_BASE}/${encodeURIComponent(fileName)}`, { method: 'DELETE' }); + + if (state.currentFileName === fileName) { + clearEditor('cognito'); + } + await loadList(); + setStatus(`已删除:${fileName}`, 'success'); + } catch (error) { + setStatus(`删除失败:${error.message}`, 'error'); + } + } + + function resetEditor() { + if (!state.loadedSnapshot) { + setStatus('当前没有可重置的已加载内容', 'info'); + return; + } + fillEditor(state.currentFileName, state.loadedSnapshot); + setStatus('已重置为已加载内容', 'success'); + } + + function bindEvents() { + qs('#mpe-refresh-btn')?.addEventListener('click', loadList); + qs('#mpe-search-input')?.addEventListener('keydown', (event) => { + if (event.key === 'Enter') { + event.preventDefault(); + loadList(); + } + }); + qs('#mpe-type-filter')?.addEventListener('change', loadList); + + qs('#mpe-type')?.addEventListener('change', (event) => { + toggleTypeUI(event.target.value); + }); + + qs('#mpe-add-rule-btn')?.addEventListener('click', () => { + createRuleCard({ pattern: '', flags: 'g', replace: '' }); + }); + + qs('#mpe-new-cognito-btn')?.addEventListener('click', () => { + clearEditor('cognito'); + setStatus('已创建 Cognito 草稿,请填写并保存', 'info'); + }); + + qs('#mpe-new-regex-btn')?.addEventListener('click', () => { + clearEditor('regexRule'); + setStatus('已创建 RegexRule 草稿,请填写并保存', 'info'); + }); + + qs('#mpe-save-btn')?.addEventListener('click', savePreset); + qs('#mpe-delete-btn')?.addEventListener('click', deletePreset); + qs('#mpe-reset-btn')?.addEventListener('click', resetEditor); + + qs('#mpe-preset-list')?.addEventListener('click', async (event) => { + const item = event.target.closest('.preset-item'); + if (!item) return; + const fileName = item.dataset.fileName || ''; + await loadPreset(fileName); + }); + } + + document.addEventListener('DOMContentLoaded', async () => { + bindEvents(); + clearEditor('cognito'); + await loadList(); + }); +})(); \ No newline at end of file diff --git a/AdminPanel/multimedia_presets_editor.html b/AdminPanel/multimedia_presets_editor.html new file mode 100644 index 000000000..b9b3c2673 --- /dev/null +++ b/AdminPanel/multimedia_presets_editor.html @@ -0,0 +1,339 @@ + + + + + + 多媒体预设编辑器 + + + + + + + +
    +
    +
    +

    预设列表

    +
    +
    +
    + + + +
    + +
      +
    • 加载中...
    • +
    +
    +
    + +
    +
    +

    编辑器

    +
    +
    +
    + + +
    + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + + +
    + + + +
    +
    +
    +
    +
    + + + + \ No newline at end of file diff --git a/Plugin/ImageProcessor/image-processor.js b/Plugin/ImageProcessor/image-processor.js index c054065c7..b8b266cd6 100644 --- a/Plugin/ImageProcessor/image-processor.js +++ b/Plugin/ImageProcessor/image-processor.js @@ -7,6 +7,7 @@ let mediaBase64Cache = {}; // Cache file will be stored inside the plugin's directory for better encapsulation const mediaCacheFilePath = path.join(__dirname, 'multimodal_cache.json'); let pluginConfig = {}; // To store config passed from Plugin.js +const cognitoPresetPromptCache = new Map(); // --- Debug logging (simplified for plugin) --- function debugLog(message, data) { @@ -64,28 +65,176 @@ function _formatStructuredMediaInfo(description, cacheEntry, mediaIndexForLabel) ); } -async function translateMediaAndCacheInternal(base64DataWithPrefix, mediaIndexForLabel, currentConfig) { +function _getMultimediaPresetsDir() { + const explicit = process.env.MULTIMEDIA_PRESETS_PATH || process.env.MULTIMEDIA_PRESETS_DIR_PATH; + if (explicit && explicit.trim()) return explicit.trim(); + if (process.env.PROJECT_BASE_PATH) return path.join(process.env.PROJECT_BASE_PATH, 'MultimediaPresets'); + return path.join(__dirname, '..', '..', 'MultimediaPresets'); +} + +function _toOptionalNumber(value) { + if (value === null || value === undefined || value === '') return undefined; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function _toOptionalInteger(value) { + const parsed = _toOptionalNumber(value); + if (parsed === undefined) return undefined; + return Number.isInteger(parsed) ? parsed : Math.round(parsed); +} + +function _extractPresetRequestParams(presetJson) { + const requestParams = (presetJson && typeof presetJson.requestParams === 'object' && !Array.isArray(presetJson.requestParams)) + ? presetJson.requestParams + : {}; + + const model = typeof requestParams.model === 'string' && requestParams.model.trim() + ? requestParams.model.trim() + : (typeof presetJson?.model === 'string' && presetJson.model.trim() ? presetJson.model.trim() : undefined); + + const temperature = _toOptionalNumber(requestParams.temperature ?? presetJson?.temperature); + const topP = _toOptionalNumber(requestParams.top_p ?? requestParams.topP ?? presetJson?.top_p ?? presetJson?.topP); + const topK = _toOptionalInteger(requestParams.top_k ?? requestParams.topK ?? presetJson?.top_k ?? presetJson?.topK); + + return { + model, + temperature, + top_p: topP, + top_k: topK + }; +} + +async function _resolveCognitoPrompt(currentConfig, cognitoAgents = []) { + const defaultPrompt = currentConfig.MultiModalPrompt; + if (!Array.isArray(cognitoAgents) || cognitoAgents.length === 0) { + return { prompt: defaultPrompt, agentName: 'Cognito-Core', requestParams: {} }; + } + + const presetsDir = _getMultimediaPresetsDir(); + for (const rawName of cognitoAgents) { + const agentName = (rawName || '').trim(); + if (!agentName) continue; + + const cacheKey = `${presetsDir}::${agentName}`; + if (cognitoPresetPromptCache.has(cacheKey)) { + const cachedPreset = cognitoPresetPromptCache.get(cacheKey); + if (cachedPreset && cachedPreset.prompt) { + return cachedPreset; + } + continue; + } + + try { + const presetPath = path.join(presetsDir, `${agentName}.json`); + const presetRaw = await fs.readFile(presetPath, 'utf-8'); + const presetJson = JSON.parse(presetRaw); + const presetPrompt = typeof presetJson.systemPrompt === 'string' + ? presetJson.systemPrompt.trim() + : (typeof presetJson.prompt === 'string' ? presetJson.prompt.trim() : ''); + + if (presetPrompt) { + const resolvedPreset = { + prompt: presetPrompt, + agentName, + requestParams: _extractPresetRequestParams(presetJson) + }; + cognitoPresetPromptCache.set(cacheKey, resolvedPreset); + return resolvedPreset; + } + + cognitoPresetPromptCache.set(cacheKey, null); + console.warn(`[MultiModalProcessor] Cognito预设缺少 systemPrompt/prompt,跳过: ${agentName}`); + } catch (error) { + cognitoPresetPromptCache.set(cacheKey, null); + console.warn(`[MultiModalProcessor] Cognito预设加载失败,跳过: ${agentName} (${error.message})`); + } + } + + return { prompt: defaultPrompt, agentName: 'Cognito-Core', requestParams: {} }; +} + +async function translateMediaAndCacheInternal(base64DataWithPrefix, mediaIndexForLabel, currentConfig, cognitoAgents = []) { const { default: fetch } = await import('node-fetch'); const base64PrefixPattern = /^data:(image|audio|video)\/[^;]+;base64,/; const pureBase64Data = base64DataWithPrefix.replace(base64PrefixPattern, ''); const mediaMimeType = (base64DataWithPrefix.match(base64PrefixPattern) || ['data:application/octet-stream;base64,'])[0].replace('base64,', ''); - const cachedEntry = mediaBase64Cache[pureBase64Data]; + const cacheKey = pureBase64Data; + const hasExplicitPreset = Array.isArray(cognitoAgents) && cognitoAgents.length > 0; + + const cachedEntry = mediaBase64Cache[cacheKey]; + if (cachedEntry && hasExplicitPreset && cachedEntry && typeof cachedEntry === 'object') { + for (const rawName of cognitoAgents) { + const agentName = (rawName || '').trim(); + if (!agentName) continue; + + if (cachedEntry.variants && cachedEntry.variants[agentName] && typeof cachedEntry.variants[agentName].description === 'string' && cachedEntry.variants[agentName].description.trim()) { + const selectedEntry = cachedEntry.variants[agentName]; + console.log(`[MultiModalProcessor] Cache hit for media ${mediaIndexForLabel + 1}. agent=${agentName}`); + return { + inlineText: `[MULTIMODAL_DATA_${mediaIndexForLabel + 1}_Info: ${selectedEntry.description}]`, + structuredText: _formatStructuredMediaInfo(selectedEntry.description, selectedEntry, mediaIndexForLabel), + cacheEntry: selectedEntry + }; + } + + if (cachedEntry.cognitoAgent === agentName && typeof cachedEntry.description === 'string' && cachedEntry.description.trim()) { + const selectedEntry = cachedEntry; + console.log(`[MultiModalProcessor] Cache hit for media ${mediaIndexForLabel + 1}. agent=${agentName}`); + return { + inlineText: `[MULTIMODAL_DATA_${mediaIndexForLabel + 1}_Info: ${selectedEntry.description}]`, + structuredText: _formatStructuredMediaInfo(selectedEntry.description, selectedEntry, mediaIndexForLabel), + cacheEntry: selectedEntry + }; + } + } + } + + const { prompt: effectivePrompt, agentName: effectiveAgentName, requestParams } = await _resolveCognitoPrompt(currentConfig, cognitoAgents); + const presetSignature = effectiveAgentName || 'Cognito-Core'; + const requirePresetVariant = hasExplicitPreset && presetSignature !== 'Cognito-Core'; + if (cachedEntry) { - const description = typeof cachedEntry === 'string' ? cachedEntry : cachedEntry.description; - const normalizedEntry = typeof cachedEntry === 'string' - ? { id: crypto.randomUUID(), description, timestamp: new Date().toISOString(), mimeType: mediaMimeType } - : cachedEntry; - console.log(`[MultiModalProcessor] Cache hit for media ${mediaIndexForLabel + 1}.`); - return { - inlineText: `[MULTIMODAL_DATA_${mediaIndexForLabel + 1}_Info: ${description}]`, - structuredText: _formatStructuredMediaInfo(description, normalizedEntry, mediaIndexForLabel), - cacheEntry: normalizedEntry - }; + let selectedEntry = null; + + if (typeof cachedEntry === 'string') { + if (!requirePresetVariant) { + selectedEntry = { + id: crypto.randomUUID(), + description: cachedEntry, + timestamp: new Date().toISOString(), + mimeType: mediaMimeType, + cognitoAgent: 'Cognito-Core' + }; + } + } else if (cachedEntry && typeof cachedEntry === 'object') { + if (cachedEntry.variants && cachedEntry.variants[presetSignature]) { + selectedEntry = cachedEntry.variants[presetSignature]; + } else if (requirePresetVariant && cachedEntry.cognitoAgent === presetSignature && typeof cachedEntry.description === 'string' && cachedEntry.description.trim()) { + selectedEntry = cachedEntry; + } else if (!requirePresetVariant) { + const isDefaultAgent = !cachedEntry.cognitoAgent || cachedEntry.cognitoAgent === 'Cognito-Core'; + if (isDefaultAgent && typeof cachedEntry.description === 'string' && cachedEntry.description.trim()) { + selectedEntry = cachedEntry; + } else if (cachedEntry.variants && cachedEntry.variants['Cognito-Core']) { + selectedEntry = cachedEntry.variants['Cognito-Core']; + } + } + } + + if (selectedEntry && typeof selectedEntry.description === 'string' && selectedEntry.description.trim()) { + console.log(`[MultiModalProcessor] Cache hit for media ${mediaIndexForLabel + 1}. agent=${presetSignature}`); + return { + inlineText: `[MULTIMODAL_DATA_${mediaIndexForLabel + 1}_Info: ${selectedEntry.description}]`, + structuredText: _formatStructuredMediaInfo(selectedEntry.description, selectedEntry, mediaIndexForLabel), + cacheEntry: selectedEntry + }; + } } - console.log(`[MultiModalProcessor] Translating media ${mediaIndexForLabel + 1}...`); - if (!currentConfig.MultiModalModel || !currentConfig.MultiModalPrompt || !currentConfig.API_Key || !currentConfig.API_URL) { + console.log(`[MultiModalProcessor] Translating media ${mediaIndexForLabel + 1} with agent=${presetSignature}...`); + if (!currentConfig.MultiModalModel || !effectivePrompt || !currentConfig.API_Key || !currentConfig.API_URL) { console.error('[MultiModalProcessor] Multimodal translation config incomplete.'); const failText = '[多模态数据转译服务配置不完整]'; return { @@ -103,16 +252,27 @@ async function translateMediaAndCacheInternal(base64DataWithPrefix, mediaIndexFo attempt++; try { const payload = { - model: currentConfig.MultiModalModel, + model: requestParams?.model || currentConfig.MultiModalModel, messages: [{ role: "user", content: [ - { type: "text", text: currentConfig.MultiModalPrompt }, + { type: "text", text: effectivePrompt }, { type: "image_url", image_url: { url: `${mediaMimeType}base64,${pureBase64Data}` } } ] }], max_tokens: currentConfig.MultiModalModelOutputMaxTokens || 50000, }; + + if (typeof requestParams?.temperature === 'number') { + payload.temperature = requestParams.temperature; + } + if (typeof requestParams?.top_p === 'number') { + payload.top_p = requestParams.top_p; + } + if (typeof requestParams?.top_k === 'number') { + payload.top_k = requestParams.top_k; + } + if (currentConfig.MultiModalModelThinkingBudget && currentConfig.MultiModalModelThinkingBudget > 0) { payload.extra_body = { thinking_config: { thinking_budget: currentConfig.MultiModalModelThinkingBudget } }; } @@ -137,9 +297,52 @@ async function translateMediaAndCacheInternal(base64DataWithPrefix, mediaIndexFo id: crypto.randomUUID(), description: cleanedDescription, timestamp: new Date().toISOString(), - mimeType: mediaMimeType + mimeType: mediaMimeType, + cognitoAgent: presetSignature }; - mediaBase64Cache[pureBase64Data] = newCacheEntry; + + const existing = mediaBase64Cache[cacheKey]; + if (existing && typeof existing === 'object' && !Array.isArray(existing)) { + const mergedVariants = { + ...(existing.variants && typeof existing.variants === 'object' ? existing.variants : {}) + }; + mergedVariants[presetSignature] = newCacheEntry; + mediaBase64Cache[cacheKey] = { + ...existing, + variants: mergedVariants, + description: existing.description || newCacheEntry.description, + id: existing.id || newCacheEntry.id, + timestamp: existing.timestamp || newCacheEntry.timestamp, + mimeType: existing.mimeType || newCacheEntry.mimeType, + cognitoAgent: existing.cognitoAgent || newCacheEntry.cognitoAgent + }; + } else if (typeof existing === 'string') { + mediaBase64Cache[cacheKey] = { + id: crypto.randomUUID(), + description: existing, + timestamp: new Date().toISOString(), + mimeType: mediaMimeType, + cognitoAgent: 'Cognito-Core', + variants: { + 'Cognito-Core': { + id: crypto.randomUUID(), + description: existing, + timestamp: new Date().toISOString(), + mimeType: mediaMimeType, + cognitoAgent: 'Cognito-Core' + }, + [presetSignature]: newCacheEntry + } + }; + } else { + mediaBase64Cache[cacheKey] = { + ...newCacheEntry, + variants: { + [presetSignature]: newCacheEntry + } + }; + } + await saveMediaCacheToFile(); return { inlineText: `[MULTIMODAL_DATA_${mediaIndexForLabel + 1}_Info: ${cleanedDescription}]`, @@ -204,27 +407,26 @@ module.exports = { } if (mediaPartsToTranslate.length > 0) { - const translatedInlineTexts = []; const translatedStructuredTexts = []; const asyncLimit = currentConfig.MultiModalModelAsynchronousLimit || 1; for (let j = 0; j < mediaPartsToTranslate.length; j += asyncLimit) { const chunkToTranslate = mediaPartsToTranslate.slice(j, j + asyncLimit); const translationPromisesInChunk = chunkToTranslate.map((base64Url) => - translateMediaAndCacheInternal(base64Url, globalMediaIndexForLabel++, currentConfig) + translateMediaAndCacheInternal(base64Url, globalMediaIndexForLabel++, currentConfig, cognitoAgents) ); const translatedResultsInChunk = await Promise.all(translationPromisesInChunk); for (const result of translatedResultsInChunk) { - translatedInlineTexts.push(result.inlineText); translatedStructuredTexts.push(result.structuredText); } } + const agentsLine = `[CognitoAgents: ${cognitoAgents.length > 0 ? cognitoAgents.join(', ') : 'Cognito-Core'}]`; + if (transMode === 'minus') { const markerId = crypto.randomUUID(); const beginMarker = `[TRANSBASE64_MINUS_BEGIN_${markerId}]`; const endMarker = `[TRANSBASE64_MINUS_END_${markerId}]`; - const agentsLine = `[CognitoAgents: ${cognitoAgents.length > 0 ? cognitoAgents.join(', ') : 'Cognito-Core'}]`; const hiddenBlock = `${beginMarker}\n${agentsLine}\n${translatedStructuredTexts.join('\n')}\n${endMarker}`; let userTextPart = contentWithoutMedia.find(p => p.type === 'text'); @@ -253,9 +455,8 @@ module.exports = { } const insertPrompt = currentConfig.MediaInsertPrompt || "[多模态数据信息已提取:]"; - userTextPart.text = (userTextPart.text ? userTextPart.text.trim() + '\n' : '') + - insertPrompt + '\n' + - translatedInlineTexts.join('\n'); + const injectedBlock = `${insertPrompt}\n${agentsLine}\n${translatedStructuredTexts.join('\n')}`; + userTextPart.text = (userTextPart.text ? userTextPart.text.trim() + '\n' : '') + injectedBlock; if (transMode === 'default') { msg.content = contentWithoutMedia; diff --git a/Plugin/ImageProcessor/reidentify_image.js b/Plugin/ImageProcessor/reidentify_image.js index 4a6230a02..801e989e0 100644 --- a/Plugin/ImageProcessor/reidentify_image.js +++ b/Plugin/ImageProcessor/reidentify_image.js @@ -18,13 +18,81 @@ const multiModalPromptText = process.env.MultiModalPrompt; // 使用新的配置 const multiModalModelOutputMaxTokens = parseInt(process.env.MultiModalModelOutputMaxTokens, 10) || 50000; const multiModalModelThinkingBudget = parseInt(process.env.MultiModalModelThinkingBudget, 10); +function getMultimediaPresetsDir() { + const explicit = process.env.MULTIMEDIA_PRESETS_PATH || process.env.MULTIMEDIA_PRESETS_DIR_PATH; + if (explicit && explicit.trim()) return explicit.trim(); + if (process.env.PROJECT_BASE_PATH) return path.join(process.env.PROJECT_BASE_PATH, 'MultimediaPresets'); + return path.join(__dirname, '..', '..', 'MultimediaPresets'); +} + +function toOptionalNumber(value) { + if (value === null || value === undefined || value === '') return undefined; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function toOptionalInteger(value) { + const parsed = toOptionalNumber(value); + if (parsed === undefined) return undefined; + return Number.isInteger(parsed) ? parsed : Math.round(parsed); +} + +function extractPresetRequestParams(presetJson) { + const requestParams = (presetJson && typeof presetJson.requestParams === 'object' && !Array.isArray(presetJson.requestParams)) + ? presetJson.requestParams + : {}; + + const model = typeof requestParams.model === 'string' && requestParams.model.trim() + ? requestParams.model.trim() + : (typeof presetJson?.model === 'string' && presetJson.model.trim() ? presetJson.model.trim() : undefined); + + const temperature = toOptionalNumber(requestParams.temperature ?? presetJson?.temperature); + const topP = toOptionalNumber(requestParams.top_p ?? requestParams.topP ?? presetJson?.top_p ?? presetJson?.topP); + const topK = toOptionalInteger(requestParams.top_k ?? requestParams.topK ?? presetJson?.top_k ?? presetJson?.topK); + + return { + model, + temperature, + top_p: topP, + top_k: topK + }; +} + +async function resolveCognitoPrompt(presetName) { + const normalized = (presetName || '').trim(); + if (!normalized) { + return { prompt: multiModalPromptText, agentName: 'Cognito-Core', requestParams: {} }; + } + + const presetsDir = getMultimediaPresetsDir(); + try { + const presetPath = path.join(presetsDir, `${normalized}.json`); + const presetRaw = await fs.readFile(presetPath, 'utf-8'); + const presetJson = JSON.parse(presetRaw); + const presetPrompt = typeof presetJson.systemPrompt === 'string' + ? presetJson.systemPrompt.trim() + : (typeof presetJson.prompt === 'string' ? presetJson.prompt.trim() : ''); + const requestParams = extractPresetRequestParams(presetJson); + + if (presetPrompt) { + return { prompt: presetPrompt, agentName: normalized, requestParams }; + } + } catch (error) { + console.warn(`[Reidentify] Cognito预设加载失败,回退默认提示词: ${normalized} (${error.message})`); + } + + return { prompt: multiModalPromptText, agentName: 'Cognito-Core', requestParams: {} }; +} + /** * 根据 Base64 Key 重新识别多模态数据并更新缓存。 * @param {string} base64Key - 要重新识别的媒体缓存条目的 Base64 Key (纯 Base64 字符串)。 -* @returns {Promise<{newDescription: string, newTimestamp: string}>} 包含新描述和时间戳的对象。 +* @param {object} options +* @param {string} options.presetName - 署名/预设名(可选)。 +* @returns {Promise<{newDescription: string, newTimestamp: string, presetName: string}>} 包含新描述和时间戳的对象。 * @throws {Error} 如果重新识别或更新缓存失败。 */ -async function reidentifyMediaByBase64Key(base64Key) { +async function reidentifyMediaByBase64Key(base64Key, options = {}) { if (!base64Key) { throw new Error('错误:请输入要重新识别的媒体缓存条目的 Base64 Key。'); } @@ -36,6 +104,9 @@ async function reidentifyMediaByBase64Key(base64Key) { throw new Error('错误:必要的 API 配置 (API_Key, API_URL, MultiModalModel, MultiModalPrompt) 未在 config.env 中设置。'); } + const { prompt: effectivePrompt, agentName: effectiveAgentName, requestParams } = await resolveCognitoPrompt(options.presetName); + const presetSignature = effectiveAgentName || 'Cognito-Core'; + // 2. 加载缓存 let mediaBase64Cache; try { @@ -53,9 +124,32 @@ async function reidentifyMediaByBase64Key(base64Key) { } // 3. 查找 Base64 数据条目 - const entryToUpdate = mediaBase64Cache[base64Key]; + let entryToUpdate = mediaBase64Cache[base64Key]; - if (!entryToUpdate || typeof entryToUpdate !== 'object') { + if (!entryToUpdate) { + throw new Error(`错误:在缓存中未找到 Base64 Key (部分): ${base64Key.substring(0, 30)}... 对应的有效条目。`); + } + + if (typeof entryToUpdate === 'string') { + const now = new Date().toISOString(); + entryToUpdate = { + id: crypto.randomUUID(), + description: entryToUpdate, + timestamp: now, + cognitoAgent: 'Cognito-Core', + variants: { + 'Cognito-Core': { + id: crypto.randomUUID(), + description: entryToUpdate, + timestamp: now, + cognitoAgent: 'Cognito-Core' + } + } + }; + mediaBase64Cache[base64Key] = entryToUpdate; + } + + if (typeof entryToUpdate !== 'object') { throw new Error(`错误:在缓存中未找到 Base64 Key (部分): ${base64Key.substring(0, 30)}... 对应的有效条目。`); } @@ -68,7 +162,13 @@ async function reidentifyMediaByBase64Key(base64Key) { console.log(`[Reidentify] 对 Base64 Key (部分): ${base64Key.substring(0, 30)}... 进行重新识别...`); // 从缓存中获取准确的 MIME 类型 - const mimeType = entryToUpdate.mimeType || 'application/octet-stream'; // 如果旧缓存没有mimeType,则使用通用二进制流 + let mimeType = entryToUpdate.mimeType || 'application/octet-stream'; // 如果旧缓存没有mimeType,则使用通用二进制流 + if (!entryToUpdate.mimeType) { + if (base64Key.startsWith('/9j/')) mimeType = 'image/jpeg'; + else if (base64Key.startsWith('iVBOR')) mimeType = 'image/png'; + else if (base64Key.startsWith('R0lGOD')) mimeType = 'image/gif'; + else if (base64Key.startsWith('UklGR')) mimeType = 'image/webp'; + } console.log(`[Reidentify] 使用缓存的 MIME 类型: ${mimeType}`); @@ -80,12 +180,12 @@ async function reidentifyMediaByBase64Key(base64Key) { const fetch = (await import('node-fetch')).default; const payload = { - model: multiModalModelName, + model: requestParams?.model || multiModalModelName, messages: [ { role: "user", content: [ - { type: "text", text: multiModalPromptText }, + { type: "text", text: effectivePrompt }, { type: "image_url", image_url: { url: `${mimeType}base64,${base64Key}` } } ] } @@ -93,6 +193,16 @@ async function reidentifyMediaByBase64Key(base64Key) { max_tokens: multiModalModelOutputMaxTokens, }; + if (typeof requestParams?.temperature === 'number') { + payload.temperature = requestParams.temperature; + } + if (typeof requestParams?.top_p === 'number') { + payload.top_p = requestParams.top_p; + } + if (typeof requestParams?.top_k === 'number') { + payload.top_k = requestParams.top_k; + } + if (multiModalModelThinkingBudget && !isNaN(multiModalModelThinkingBudget) && multiModalModelThinkingBudget > 0) { payload.extra_body = { thinking_config: { @@ -152,18 +262,42 @@ async function reidentifyMediaByBase64Key(base64Key) { // 5. 更新缓存 try { - entryToUpdate.description = cleanedNewDescription; // 使用清理后的描述 - entryToUpdate.timestamp = new Date().toISOString(); // 更新时间戳 + const now = new Date().toISOString(); + + if (!entryToUpdate.variants || typeof entryToUpdate.variants !== 'object') { + entryToUpdate.variants = {}; + } + + const existingVariant = entryToUpdate.variants[presetSignature] || {}; + entryToUpdate.variants[presetSignature] = { + ...existingVariant, + id: existingVariant.id || entryToUpdate.id || crypto.randomUUID(), + description: cleanedNewDescription, + timestamp: now, + mimeType: existingVariant.mimeType || entryToUpdate.mimeType || mimeType, + cognitoAgent: presetSignature + }; + + if (!entryToUpdate.cognitoAgent) { + entryToUpdate.cognitoAgent = presetSignature; + } + if (!entryToUpdate.description || entryToUpdate.cognitoAgent === presetSignature) { + entryToUpdate.description = cleanedNewDescription; + entryToUpdate.timestamp = now; + } else if (!entryToUpdate.timestamp) { + entryToUpdate.timestamp = now; + } + // 如果旧条目没有mimeType,也一并更新 if (!entryToUpdate.mimeType) { entryToUpdate.mimeType = mimeType; } await fs.writeFile(mediaCacheFilePath, JSON.stringify(mediaBase64Cache, null, 2)); - console.log(`[Reidentify] 缓存中 Base64 Key (部分): ${base64Key.substring(0, 30)}... 的条目已成功更新描述和时间戳。`); + console.log(`[Reidentify] 缓存中 Base64 Key (部分): ${base64Key.substring(0, 30)}... 的条目已成功更新描述和时间戳。署名: ${presetSignature}`); console.log("[Reidentify] 新描述:", cleanedNewDescription); - return { newDescription: cleanedNewDescription, newTimestamp: entryToUpdate.timestamp }; + return { newDescription: cleanedNewDescription, newTimestamp: entryToUpdate.timestamp, presetName: presetSignature }; } catch (error) { console.error(`[Reidentify] 错误:写入更新后的媒体缓存文件 ${mediaCacheFilePath} 失败:`, error); diff --git a/Plugin/RAGDiaryPlugin/RAGDiaryPlugin.js b/Plugin/RAGDiaryPlugin/RAGDiaryPlugin.js index 69758d2b7..41449f569 100644 --- a/Plugin/RAGDiaryPlugin/RAGDiaryPlugin.js +++ b/Plugin/RAGDiaryPlugin/RAGDiaryPlugin.js @@ -449,6 +449,10 @@ class RAGDiaryPlugin { let promptText = defaultPromptText; let effectiveAgentName = 'Cognito-Core'; + let effectiveModelName = multiModalModelName; + let presetTemperature; + let presetTopP; + let presetTopK; const requestedAgent = typeof cognitoAgentName === 'string' ? cognitoAgentName.trim() : ''; if (requestedAgent) { @@ -460,9 +464,35 @@ class RAGDiaryPlugin { const presetPrompt = typeof presetJson.systemPrompt === 'string' ? presetJson.systemPrompt.trim() : (typeof presetJson.prompt === 'string' ? presetJson.prompt.trim() : ''); + + const requestParams = (presetJson && typeof presetJson.requestParams === 'object' && !Array.isArray(presetJson.requestParams)) + ? presetJson.requestParams + : {}; + const parseOptionalNumber = (value) => { + if (value === null || value === undefined || value === '') return undefined; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; + }; + const parseOptionalInteger = (value) => { + const parsed = parseOptionalNumber(value); + if (parsed === undefined) return undefined; + return Number.isInteger(parsed) ? parsed : Math.round(parsed); + }; + + const presetModel = typeof requestParams.model === 'string' && requestParams.model.trim() + ? requestParams.model.trim() + : (typeof presetJson?.model === 'string' && presetJson.model.trim() ? presetJson.model.trim() : ''); + const presetTemp = parseOptionalNumber(requestParams.temperature ?? presetJson?.temperature); + const presetTopPValue = parseOptionalNumber(requestParams.top_p ?? requestParams.topP ?? presetJson?.top_p ?? presetJson?.topP); + const presetTopKValue = parseOptionalInteger(requestParams.top_k ?? requestParams.topK ?? presetJson?.top_k ?? presetJson?.topK); + if (presetPrompt) { promptText = presetPrompt; effectiveAgentName = requestedAgent; + if (presetModel) effectiveModelName = presetModel; + if (typeof presetTemp === 'number') presetTemperature = presetTemp; + if (typeof presetTopPValue === 'number') presetTopP = presetTopPValue; + if (typeof presetTopKValue === 'number') presetTopK = presetTopKValue; } else { console.warn(`[RAGDiaryPlugin] Cognito预设缺少 systemPrompt/prompt,回退默认Agent: ${requestedAgent}`); } @@ -471,7 +501,7 @@ class RAGDiaryPlugin { } } - if (!apiKey || !apiUrl || !multiModalModelName || !promptText) { + if (!apiKey || !apiUrl || !effectiveModelName || !promptText) { console.warn(`[RAGDiaryPlugin] 侧车描述为空且缺少多模态识别配置,无法重生成(${mediaFileName || mediaFilePath})`); return ''; } @@ -482,7 +512,7 @@ class RAGDiaryPlugin { const dataUri = `data:${mimeType};base64,${fileBuffer.toString('base64')}`; const payload = { - model: multiModalModelName, + model: effectiveModelName, messages: [ { role: 'user', @@ -495,6 +525,16 @@ class RAGDiaryPlugin { max_tokens: maxTokens }; + if (typeof presetTemperature === 'number') { + payload.temperature = presetTemperature; + } + if (typeof presetTopP === 'number') { + payload.top_p = presetTopP; + } + if (typeof presetTopK === 'number') { + payload.top_k = presetTopK; + } + if (!Number.isNaN(thinkingBudget) && thinkingBudget > 0) { payload.extra_body = { thinking_config: { thinking_budget: thinkingBudget } @@ -1257,9 +1297,11 @@ class RAGDiaryPlugin { } if (pendingUserMediaDirectives.size > 0) { - const targetUserMessageIndex = firstUserMessageIndex > -1 ? firstUserMessageIndex : lastUserMessageIndex; - if (targetUserMessageIndex > -1) { - this._appendFileDirectivesToUserMessage(newMessages[targetUserMessageIndex], Array.from(pendingUserMediaDirectives)); + const injectionMessage = this._buildDiaryMediaInjectionMessage(Array.from(pendingUserMediaDirectives)); + if (injectionMessage) { + // 按“历史记忆层”语义插入到首条真实用户消息之前,避免与用户最新上传文件混淆 + const insertionIndex = firstUserMessageIndex > -1 ? firstUserMessageIndex : 0; + newMessages.splice(Math.max(0, insertionIndex), 0, injectionMessage); } } @@ -1843,32 +1885,52 @@ class RAGDiaryPlugin { return processedContent; } - _appendFileDirectivesToUserMessage(message, fileUrls = []) { - if (!message || !Array.isArray(fileUrls) || fileUrls.length === 0) return; + _buildDiaryMediaInjectionMessage(fileUrls = []) { + if (!Array.isArray(fileUrls) || fileUrls.length === 0) return null; const normalizedUrls = fileUrls .map(item => (typeof item === 'string' ? item.trim() : '')) .filter(item => item.startsWith('file://')); - if (normalizedUrls.length === 0) return; + if (normalizedUrls.length === 0) return null; - const lines = normalizedUrls.map(url => `{{VCP@${url}}}`).join('\n'); + const uniqueUrls = Array.from(new Set(normalizedUrls)); + const fileLines = uniqueUrls.map((url, index) => { + const decodedPath = decodeURIComponent(url.replace(/^file:\/\//i, '')); + const fileName = path.basename(decodedPath || `media_${index + 1}`); + return `- ${fileName} | ${url}`; + }); - if (typeof message.content === 'string') { - const suffix = message.content && !message.content.endsWith('\n') ? '\n' : ''; - message.content = `${message.content || ''}${suffix}${lines}`; - return; - } + const diaryNames = Array.from(new Set(uniqueUrls.map(url => { + const decodedPath = decodeURIComponent(url.replace(/^file:\/\//i, '')); + const normalizedRoot = path.normalize(dailyNoteRootPath || ''); + const normalizedPath = path.normalize(decodedPath || ''); + if (!normalizedRoot || !normalizedPath.startsWith(normalizedRoot)) return ''; + const rel = path.relative(normalizedRoot, normalizedPath); + const firstSegment = rel.split(path.sep)[0]; + return firstSegment || ''; + }).filter(Boolean))); + + const sourceLine = diaryNames.length > 0 + ? `来源日记本: ${diaryNames.join(', ')}` + : '来源日记本: [未知,来自日记检索链路]'; + + const directiveLines = uniqueUrls.map(url => `{{VCP@${url}}}`).join('\n'); + + const declaration = [ + '[系统提示: 日记本历史多模态文件注入层]', + '以下文件由“……日记本::TransBase64+”召回注入,属于历史记忆,不是当前用户本轮上传。', + sourceLine, + '文件清单(文件名 | 绝对路径):', + ...fileLines, + '', + directiveLines + ].join('\n'); - if (Array.isArray(message.content)) { - let textPart = message.content.find(part => part && part.type === 'text' && typeof part.text === 'string'); - if (!textPart) { - textPart = { type: 'text', text: '' }; - message.content.unshift(textPart); - } - const suffix = textPart.text && !textPart.text.endsWith('\n') ? '\n' : ''; - textPart.text = `${textPart.text || ''}${suffix}${lines}`; - } + return { + role: 'user', + content: declaration + }; } _extractMediaFileUrlFromResult(result) { diff --git a/modules/chatCompletionHandler.js b/modules/chatCompletionHandler.js index af5562513..6cb09f4c9 100644 --- a/modules/chatCompletionHandler.js +++ b/modules/chatCompletionHandler.js @@ -663,6 +663,50 @@ class ChatCompletionHandler { ); if (DEBUG_MODE) await writeDebugLog('LogAfterVariableProcessing', processedMessages); + // 二次解析:变量替换后再次检查 TransBase64 占位符(避免通过变量注入时漏解析) + if (processedMessages && Array.isArray(processedMessages)) { + let foundPlaceholderAfterVars = false; + + for (const msg of processedMessages) { + const normalizedRole = typeof msg.role === 'string' ? msg.role.toLowerCase() : ''; + if (normalizedRole !== 'user' && normalizedRole !== 'system') continue; + + if (typeof msg.content === 'string') { + const parsed = parseTransBase64Directives(msg.content); + if (parsed.found) { + foundPlaceholderAfterVars = true; + msg.content = parsed.cleanedText; + if (!transBase64Mode && parsed.mode) transBase64Mode = parsed.mode; + if (transBase64CognitoAgents.length === 0 && parsed.agents.length > 0) { + transBase64CognitoAgents = parsed.agents; + } + } + } else if (Array.isArray(msg.content)) { + for (const part of msg.content) { + if (typeof part.text !== 'string') continue; + const parsed = parseTransBase64Directives(part.text); + if (parsed.found) { + foundPlaceholderAfterVars = true; + part.text = parsed.cleanedText; + if (!transBase64Mode && parsed.mode) transBase64Mode = parsed.mode; + if (transBase64CognitoAgents.length === 0 && parsed.agents.length > 0) { + transBase64CognitoAgents = parsed.agents; + } + } + } + } + } + + if (foundPlaceholderAfterVars) { + shouldProcessMedia = true; + if (DEBUG_MODE) { + console.log( + `[Server] TransBase64 detected after variable processing. mode=${transBase64Mode || 'default'} agents=${transBase64CognitoAgents.join(',') || 'Cognito-Core'}` + ); + } + } + } + // --- 媒体处理器 --- if (shouldProcessMedia) { const processorName = pluginManager.messagePreprocessors.has('MultiModalProcessor') diff --git a/routes/adminPanelRoutes.js b/routes/adminPanelRoutes.js index 6a81d4e7d..e17adb036 100644 --- a/routes/adminPanelRoutes.js +++ b/routes/adminPanelRoutes.js @@ -906,16 +906,17 @@ module.exports = function (DEBUG_MODE, dailyNoteRootPath, pluginManager, getCurr }); adminApiRouter.post('/multimodal-cache/reidentify', async (req, res) => { - const { base64Key } = req.body; + const { base64Key, presetName } = req.body; if (typeof base64Key !== 'string' || !base64Key) { return res.status(400).json({ error: 'Invalid request body. Expected { base64Key: string }.' }); } try { - const result = await reidentifyMediaByBase64Key(base64Key); + const result = await reidentifyMediaByBase64Key(base64Key, { presetName }); res.json({ message: '媒体重新识别成功。', newDescription: result.newDescription, - newTimestamp: result.newTimestamp + newTimestamp: result.newTimestamp, + presetName: result.presetName || presetName || 'Cognito-Core' }); } catch (error) { console.error('[AdminPanelRoutes API] Error reidentifying media:', error); @@ -924,6 +925,154 @@ module.exports = function (DEBUG_MODE, dailyNoteRootPath, pluginManager, getCurr }); // --- End MultiModal Cache API --- + // --- Multimedia Presets API --- + const MULTIMEDIA_PRESETS_DIR = path.join(__dirname, '..', 'MultimediaPresets'); + + function normalizePresetFileName(inputName) { + const decoded = decodeURIComponent(String(inputName || '')).trim(); + if (!decoded) { + throw new Error('预设文件名不能为空。'); + } + + const withExt = decoded.toLowerCase().endsWith('.json') ? decoded : `${decoded}.json`; + const safeName = path.basename(withExt); + + if (!/^[A-Za-z0-9._\-\u4e00-\u9fa5]+\.json$/i.test(safeName)) { + throw new Error('预设文件名包含非法字符。'); + } + + return safeName; + } + + function inferPresetType(presetData) { + if (presetData && typeof presetData === 'object') { + if (presetData.type === 'regexRule') return 'regexRule'; + if (presetData.type === 'cognito') return 'cognito'; + if (Array.isArray(presetData.rules)) return 'regexRule'; + } + return 'cognito'; + } + + adminApiRouter.get('/multimedia-presets', async (req, res) => { + try { + await fs.mkdir(MULTIMEDIA_PRESETS_DIR, { recursive: true }); + const onlyTypeRaw = typeof req.query.type === 'string' ? req.query.type.trim() : ''; + const onlyType = (onlyTypeRaw === 'cognito' || onlyTypeRaw === 'regexRule') ? onlyTypeRaw : ''; + + const files = await fs.readdir(MULTIMEDIA_PRESETS_DIR); + const jsonFiles = files.filter(file => file.toLowerCase().endsWith('.json')); + + const items = []; + for (const fileName of jsonFiles) { + const fullPath = path.join(MULTIMEDIA_PRESETS_DIR, fileName); + try { + const raw = await fs.readFile(fullPath, 'utf-8'); + const data = JSON.parse(raw); + const stats = await fs.stat(fullPath); + + const type = inferPresetType(data); + if (onlyType && type !== onlyType) { + continue; + } + + items.push({ + fileName, + name: (data && typeof data.name === 'string' && data.name.trim()) ? data.name.trim() : path.basename(fileName, '.json'), + type, + description: (data && typeof data.description === 'string') ? data.description : '', + updatedAt: stats.mtime.toISOString(), + size: stats.size + }); + } catch (fileError) { + console.warn(`[AdminPanelRoutes API] Skip invalid multimedia preset ${fileName}:`, fileError.message); + } + } + + items.sort((a, b) => a.fileName.localeCompare(b.fileName, 'zh-Hans-CN')); + res.json({ success: true, total: items.length, items }); + } catch (error) { + console.error('[AdminPanelRoutes API] Error listing multimedia presets:', error); + res.status(500).json({ success: false, error: 'Failed to list multimedia presets', details: error.message }); + } + }); + + adminApiRouter.get('/multimedia-presets/:fileName', async (req, res) => { + try { + const fileName = normalizePresetFileName(req.params.fileName); + const fullPath = path.join(MULTIMEDIA_PRESETS_DIR, fileName); + + const raw = await fs.readFile(fullPath, 'utf-8'); + const data = JSON.parse(raw); + + res.json({ + success: true, + fileName, + data: { + ...data, + type: inferPresetType(data) + } + }); + } catch (error) { + if (error.code === 'ENOENT') { + return res.status(404).json({ success: false, error: 'Preset file not found' }); + } + console.error('[AdminPanelRoutes API] Error reading multimedia preset:', error); + res.status(500).json({ success: false, error: 'Failed to read multimedia preset', details: error.message }); + } + }); + + adminApiRouter.post('/multimedia-presets/:fileName', async (req, res) => { + try { + const fileName = normalizePresetFileName(req.params.fileName); + const fullPath = path.join(MULTIMEDIA_PRESETS_DIR, fileName); + + const payload = (req.body && typeof req.body === 'object' && req.body.data && typeof req.body.data === 'object') + ? req.body.data + : req.body; + + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + return res.status(400).json({ success: false, error: 'Invalid preset payload. Expected JSON object.' }); + } + + const normalized = { ...payload }; + normalized.name = (typeof normalized.name === 'string' && normalized.name.trim()) + ? normalized.name.trim() + : path.basename(fileName, '.json'); + + normalized.type = inferPresetType(normalized); + + await fs.mkdir(MULTIMEDIA_PRESETS_DIR, { recursive: true }); + await fs.writeFile(fullPath, JSON.stringify(normalized, null, 2), 'utf-8'); + + res.json({ + success: true, + message: 'Preset saved successfully.', + fileName, + type: normalized.type + }); + } catch (error) { + console.error('[AdminPanelRoutes API] Error saving multimedia preset:', error); + res.status(500).json({ success: false, error: 'Failed to save multimedia preset', details: error.message }); + } + }); + + adminApiRouter.delete('/multimedia-presets/:fileName', async (req, res) => { + try { + const fileName = normalizePresetFileName(req.params.fileName); + const fullPath = path.join(MULTIMEDIA_PRESETS_DIR, fileName); + + await fs.unlink(fullPath); + res.json({ success: true, message: 'Preset deleted successfully.', fileName }); + } catch (error) { + if (error.code === 'ENOENT') { + return res.status(404).json({ success: false, error: 'Preset file not found' }); + } + console.error('[AdminPanelRoutes API] Error deleting multimedia preset:', error); + res.status(500).json({ success: false, error: 'Failed to delete multimedia preset', details: error.message }); + } + }); + // --- End Multimedia Presets API --- + // --- Knowledge Media Describer API --- const KNOWLEDGE_MEDIA_EXTENSIONS = new Set([ '.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.tiff', '.tif', '.avif', '.svg', From 8195cb1a05088d396df71c3d181a84f9f2fd4057 Mon Sep 17 00:00:00 2001 From: Oxyel <74046599+TyChest@users.noreply.github.com> Date: Fri, 27 Feb 2026 00:29:37 +0800 Subject: [PATCH 6/6] update --- AdminPanel/image_cache_editor.html | 44 --- AdminPanel/js/knowledge-media-describer.js | 262 ++++++++----- AdminPanel/knowledge_media_describer.html | 345 ++++++++++++------ .../knowledge-media-describer.js | 60 ++- .../plugin-manifest.json | 8 +- Plugin/RAGDiaryPlugin/RAGDiaryPlugin.js | 235 +++++++++++- modules/mediaSidecarManager.js | 1 + routes/adminPanelRoutes.js | 92 ++--- 8 files changed, 724 insertions(+), 323 deletions(-) diff --git a/AdminPanel/image_cache_editor.html b/AdminPanel/image_cache_editor.html index 81cc12483..0b432113a 100644 --- a/AdminPanel/image_cache_editor.html +++ b/AdminPanel/image_cache_editor.html @@ -38,7 +38,6 @@
    -
    @@ -52,10 +51,7 @@

    多媒体缓存列表

    let mediaCacheData = {}; const mediaListDiv = document.getElementById('mediaList'); const saveButton = document.getElementById('saveButton'); - const exportButton = document.getElementById('exportButton'); - saveButton.addEventListener('click', handleSave); - exportButton.addEventListener('click', handleExport); function guessMimeType(base64String) { if (base64String.startsWith('/9j/')) return 'image/jpeg'; @@ -251,46 +247,6 @@

    多媒体缓存列表

    } } - async function handleExport() { - exportButton.disabled = true; - const originalText = exportButton.textContent; - exportButton.textContent = '导出中...'; - - try { - const response = await fetch('/admin_api/multimodal-cache/export'); - if (!response.ok) { - let errorMsg = `HTTP ${response.status}`; - try { - const errJson = await response.json(); - errorMsg = errJson.error || errJson.details || errorMsg; - } catch (_) {} - throw new Error(errorMsg); - } - - const blob = await response.blob(); - const disposition = response.headers.get('Content-Disposition') || ''; - const fileNameMatch = disposition.match(/filename="([^"]+)"/i); - const fileName = fileNameMatch ? fileNameMatch[1] : `multimodal_cache_export_${Date.now()}.json`; - - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = fileName; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(url); - - alert(`导出完成:${fileName}`); - } catch (error) { - console.error('导出多媒体缓存失败:', error); - alert(`导出失败: ${error.message}`); - } finally { - exportButton.disabled = false; - exportButton.textContent = originalText; - } - } - async function handleReidentify(base64Key, entryDiv) { const reidentifyBtn = entryDiv.querySelector('.reidentify-btn'); const descriptionTextarea = entryDiv.querySelector('textarea'); diff --git a/AdminPanel/js/knowledge-media-describer.js b/AdminPanel/js/knowledge-media-describer.js index 527aa9fb9..28e19351f 100644 --- a/AdminPanel/js/knowledge-media-describer.js +++ b/AdminPanel/js/knowledge-media-describer.js @@ -1,12 +1,37 @@ (function () { const apiBase = '/admin_api/knowledge-media'; + const presetApi = '/admin_api/multimedia-presets?type=cognito'; let currentItems = []; + let filteredItems = []; + let presetNames = []; + + const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.tiff', '.tif', '.avif', '.svg']); + const AUDIO_EXTS = new Set(['.mp3', '.wav', '.flac', '.m4a', '.aac', '.ogg']); + const VIDEO_EXTS = new Set(['.mp4', '.mov', '.mkv', '.webm', '.avi']); + const DOC_EXTS = new Set(['.pdf']); function qs(selector) { return document.querySelector(selector); } + function formatSize(size) { + const value = Number(size || 0); + if (value < 1024) return `${value} B`; + if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`; + if (value < 1024 * 1024 * 1024) return `${(value / (1024 * 1024)).toFixed(1)} MB`; + return `${(value / (1024 * 1024 * 1024)).toFixed(2)} GB`; + } + + function classifyType(ext) { + const lower = String(ext || '').toLowerCase(); + if (IMAGE_EXTS.has(lower)) return 'image'; + if (AUDIO_EXTS.has(lower)) return 'audio'; + if (VIDEO_EXTS.has(lower)) return 'video'; + if (DOC_EXTS.has(lower)) return 'document'; + return 'other'; + } + function escapeHtml(str) { return String(str || '') .replace(/&/g, '&') @@ -30,43 +55,116 @@ .filter(Boolean); } - function renderTable(items) { - const tbody = qs('#kms-table-body'); - if (!tbody) return; + function ensurePresetDatalist() { + let datalist = qs('#kms-preset-suggestions'); + if (!datalist) { + datalist = document.createElement('datalist'); + datalist.id = 'kms-preset-suggestions'; + document.body.appendChild(datalist); + } + datalist.innerHTML = presetNames.map(name => ``).join(''); + } + + function updateStats() { + const total = currentItems.length; + const shown = filteredItems.length; + const hasSidecar = currentItems.filter(item => item.hasSidecar).length; + const missingSidecar = total - hasSidecar; + + const countEl = qs('#kms-count'); + const sidecarCountEl = qs('#kms-sidecar-count'); + const missingCountEl = qs('#kms-missing-count'); + + if (countEl) countEl.textContent = `显示 ${shown} / 共 ${total} 项`; + if (sidecarCountEl) sidecarCountEl.textContent = `有侧车 ${hasSidecar} 项`; + if (missingCountEl) missingCountEl.textContent = `缺侧车 ${missingSidecar} 项`; + } + + function renderList(items) { + const list = qs('#kms-list'); + if (!list) return; if (!items || items.length === 0) { - tbody.innerHTML = '暂无数据'; + list.innerHTML = '
    暂无匹配项
    '; return; } - tbody.innerHTML = items.map((item, index) => { + list.innerHTML = items.map((item, index) => { const tagsText = Array.isArray(item.tags) ? item.tags.join(', ') : ''; + const itemType = classifyType(item.extension); + const modifiedText = item.modifiedAt ? new Date(item.modifiedAt).toLocaleString() : '-'; + return ` - - ${index + 1} - ${escapeHtml(item.relativePath)} - ${escapeHtml(item.extension || '')} - ${Number(item.size || 0).toLocaleString()} - ${item.hasSidecar ? '✅' : '❌'} - - - - - -
    - - +
    +
    +
    +
    ${escapeHtml(item.relativePath)}
    +
    + ${escapeHtml(itemType)} + ${escapeHtml(item.extension || '')} + ${item.hasSidecar ? '有侧车' : '缺侧车'} +
    +
    + +
    + +
    - - - - - - +
    +
    + +
    +
    +
    ${escapeHtml(formatSize(item.size))}
    +
    ${escapeHtml(modifiedText)}
    +
    +
    + + +
    +
    `; }).join(''); } + function applyFilters() { + const typeFilter = qs('#kms-type-filter')?.value || 'all'; + const sidecarFilter = qs('#kms-sidecar-filter')?.value || 'all'; + const sortValue = qs('#kms-sort-select')?.value || 'modified_desc'; + + let next = [...currentItems]; + + if (typeFilter !== 'all') { + next = next.filter(item => classifyType(item.extension) === typeFilter); + } + + if (sidecarFilter === 'has') { + next = next.filter(item => !!item.hasSidecar); + } else if (sidecarFilter === 'missing') { + next = next.filter(item => !item.hasSidecar); + } + + next.sort((a, b) => { + switch (sortValue) { + case 'modified_asc': + return new Date(a.modifiedAt).getTime() - new Date(b.modifiedAt).getTime(); + case 'size_desc': + return Number(b.size || 0) - Number(a.size || 0); + case 'size_asc': + return Number(a.size || 0) - Number(b.size || 0); + case 'path_asc': + return String(a.relativePath || '').localeCompare(String(b.relativePath || ''), 'zh-Hans-CN'); + case 'modified_desc': + default: + return new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime(); + } + }); + + filteredItems = next; + renderList(filteredItems); + updateStats(); + } + async function apiFetch(url, options = {}) { const response = await fetch(url, { ...options, @@ -92,6 +190,20 @@ return data; } + async function loadPresets() { + try { + const data = await apiFetch(presetApi); + const items = Array.isArray(data.items) ? data.items : []; + presetNames = items + .map(item => (typeof item?.name === 'string' ? item.name.trim() : '')) + .filter(Boolean); + ensurePresetDatalist(); + } catch (_) { + presetNames = []; + ensurePresetDatalist(); + } + } + async function loadList() { const keyword = qs('#kms-search-input')?.value?.trim() || ''; const query = keyword ? `?keyword=${encodeURIComponent(keyword)}` : ''; @@ -99,26 +211,27 @@ try { const data = await apiFetch(`${apiBase}/list${query}`); - currentItems = data.items || []; - renderTable(currentItems); - const countEl = qs('#kms-count'); - if (countEl) { - countEl.textContent = `共 ${data.total || currentItems.length} 项`; - } + currentItems = Array.isArray(data.items) ? data.items : []; + applyFilters(); setStatus('加载完成', 'success'); } catch (error) { + currentItems = []; + filteredItems = []; + renderList([]); + updateStats(); setStatus(`加载失败:${error.message}`, 'error'); } } async function saveOne(row) { const index = Number(row.dataset.index); - const item = currentItems[index]; + const item = filteredItems[index]; if (!item) return; const presetName = row.querySelector('.preset-input')?.value || ''; const description = row.querySelector('.desc-input')?.value || ''; const tagsText = row.querySelector('.tags-input')?.value || ''; + const agentSignature = row.querySelector('.agent-signature-input')?.value || ''; try { await apiFetch(`${apiBase}/update`, { @@ -127,7 +240,8 @@ mediaPath: item.mediaPath, presetName, description, - tags: parseTags(tagsText) + tags: parseTags(tagsText), + agentSignature }) }); setStatus(`已保存:${item.relativePath}`, 'success'); @@ -139,13 +253,17 @@ async function regenerateOne(row) { const index = Number(row.dataset.index); - const item = currentItems[index]; + const item = filteredItems[index]; if (!item) return; + const presetName = row.querySelector('.preset-input')?.value || ''; try { await apiFetch(`${apiBase}/regenerate`, { method: 'POST', - body: JSON.stringify({ mediaPath: item.mediaPath }) + body: JSON.stringify({ + mediaPath: item.mediaPath, + presetName + }) }); setStatus(`已重生成:${item.relativePath}`, 'success'); await loadList(); @@ -154,71 +272,16 @@ } } - async function rebuildAll(regenerateExisting) { - const message = regenerateExisting - ? '将重建并覆盖已有描述,确定继续吗?' - : '将只为缺失侧车文件的媒体创建描述,确定继续吗?'; - - if (!window.confirm(message)) return; - - setStatus('重建中,请稍候...', 'info'); - try { - const result = await apiFetch(`${apiBase}/rebuild`, { - method: 'POST', - body: JSON.stringify({ regenerateExisting }) - }); - setStatus( - `重建完成:扫描 ${result.scanned || 0},新建 ${result.created || 0},更新 ${result.updated || 0}`, - 'success' - ); - await loadList(); - } catch (error) { - setStatus(`重建失败:${error.message}`, 'error'); - } - } - - async function exportAll() { - setStatus('导出中...', 'info'); - try { - const response = await fetch(`${apiBase}/export`, { method: 'POST' }); - if (!response.ok) { - const err = await response.text(); - throw new Error(err || `HTTP ${response.status}`); - } - - const blob = await response.blob(); - const disposition = response.headers.get('Content-Disposition') || ''; - const match = disposition.match(/filename="([^"]+)"/i); - const fileName = match ? match[1] : `knowledge_media_export_${Date.now()}.json`; - - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = fileName; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(url); - - setStatus(`导出完成:${fileName}`, 'success'); - } catch (error) { - setStatus(`导出失败:${error.message}`, 'error'); - } - } - function bindEvents() { const reloadBtn = qs('#kms-reload-btn'); - const rebuildMissingBtn = qs('#kms-rebuild-missing-btn'); - const rebuildAllBtn = qs('#kms-rebuild-all-btn'); - const exportBtn = qs('#kms-export-btn'); const searchBtn = qs('#kms-search-btn'); const searchInput = qs('#kms-search-input'); - const tableBody = qs('#kms-table-body'); + const typeFilter = qs('#kms-type-filter'); + const sidecarFilter = qs('#kms-sidecar-filter'); + const sortSelect = qs('#kms-sort-select'); + const list = qs('#kms-list'); if (reloadBtn) reloadBtn.addEventListener('click', loadList); - if (rebuildMissingBtn) rebuildMissingBtn.addEventListener('click', () => rebuildAll(false)); - if (rebuildAllBtn) rebuildAllBtn.addEventListener('click', () => rebuildAll(true)); - if (exportBtn) exportBtn.addEventListener('click', exportAll); if (searchBtn) searchBtn.addEventListener('click', loadList); if (searchInput) { @@ -230,9 +293,13 @@ }); } - if (tableBody) { - tableBody.addEventListener('click', async (event) => { - const row = event.target.closest('tr[data-index]'); + if (typeFilter) typeFilter.addEventListener('change', applyFilters); + if (sidecarFilter) sidecarFilter.addEventListener('change', applyFilters); + if (sortSelect) sortSelect.addEventListener('change', applyFilters); + + if (list) { + list.addEventListener('click', async (event) => { + const row = event.target.closest('.kms-item[data-index]'); if (!row) return; if (event.target.classList.contains('save-one-btn')) { @@ -246,6 +313,7 @@ document.addEventListener('DOMContentLoaded', async () => { bindEvents(); + await loadPresets(); await loadList(); }); })(); \ No newline at end of file diff --git a/AdminPanel/knowledge_media_describer.html b/AdminPanel/knowledge_media_describer.html index 7d308865b..42e7031aa 100644 --- a/AdminPanel/knowledge_media_describer.html +++ b/AdminPanel/knowledge_media_describer.html @@ -6,65 +6,107 @@ 知识库多媒体描述管理 - +
    +
    +
    +

    知识库多媒体描述管理

    +

    按类型分类筛选、就地编辑描述与标签,支持按预设重生成。

    +
    +
    + 共 0 项 + 有侧车 0 项 + 缺侧车 0 项 +
    +
    -
    - - - - - - -
    +
    + + + + + + +
    -
    - 共 0 项 - -
    +
    +
    +
    -
    - - - - - - - - - - - - - - - - -
    #相对路径类型大小(bytes)侧车预设名描述与Tag操作
    加载中...
    +
    +
    +
    文件 / 描述 / Tag / 署名
    +
    预设
    +
    大小 / 修改时间
    +
    操作
    +
    +
    +
    加载中...
    +
    +
    diff --git a/Plugin/KnowledgeMediaDescriber/knowledge-media-describer.js b/Plugin/KnowledgeMediaDescriber/knowledge-media-describer.js index 19e4859d5..4c5270ec4 100644 --- a/Plugin/KnowledgeMediaDescriber/knowledge-media-describer.js +++ b/Plugin/KnowledgeMediaDescriber/knowledge-media-describer.js @@ -227,6 +227,10 @@ async function buildAndWriteSidecar(mediaPath, knowledgeRootPath, options = {}) ? options.source.trim() : (typeof existing?.source === 'string' && existing.source.trim() ? existing.source.trim() : 'manual'); + const agentSignature = options.agentSignature !== undefined + ? String(options.agentSignature || '').trim() + : (typeof existing?.agentSignature === 'string' ? existing.agentSignature.trim() : ''); + const sidecar = { version: 1, mediaHash, @@ -239,6 +243,7 @@ async function buildAndWriteSidecar(mediaPath, knowledgeRootPath, options = {}) tags, generator: generator.length > 0 ? generator : [presetName], source, + agentSignature, createdAt: existing?.createdAt || now, updatedAt: now }; @@ -258,6 +263,7 @@ async function traceAndPersistMedia(request) { presetName, generator, source, + agentSignature, targetDiaryName, targetFileName, relocateMode = 'copy', @@ -272,7 +278,8 @@ async function traceAndPersistMedia(request) { tags, presetName, generator, - source + source, + agentSignature }); const result = { @@ -322,7 +329,8 @@ async function traceAndPersistMedia(request) { tags, presetName, generator, - source: source || 'trace' + source: source || 'trace', + agentSignature }); result.target = { @@ -373,7 +381,7 @@ function convertToVCPFormat(response) { async function updateSidecarParams(request) { const knowledgeRootPath = getKnowledgeRootPath(); - const { diaryName, fileName, description, tags, presetName, generator, source } = request || {}; + const { diaryName, fileName, description, tags, presetName, generator, source, agentSignature } = request || {}; if (!diaryName || !fileName) { throw new Error('diaryName 和 fileName 不能为空。'); @@ -392,7 +400,8 @@ async function updateSidecarParams(request) { tags, presetName, generator, - source + source, + agentSignature }); return { @@ -404,6 +413,38 @@ async function updateSidecarParams(request) { }; } +async function updateSidecarAgentSignature(request) { + const knowledgeRootPath = getKnowledgeRootPath(); + const { diaryName, fileName, agentSignature } = request || {}; + + if (!diaryName || !fileName) { + throw new Error('diaryName 和 fileName 不能为空。'); + } + if (agentSignature !== undefined && typeof agentSignature !== 'string') { + throw new Error('agentSignature 必须是字符串。'); + } + + const diaryPath = resolveTargetDiaryPath(diaryName, knowledgeRootPath); + const mediaPath = path.join(diaryPath, fileName); + + const stats = await fs.stat(mediaPath); + if (!stats.isFile()) { + throw new Error(`文件不存在: ${mediaPath}`); + } + + const sidecarResult = await buildAndWriteSidecar(mediaPath, knowledgeRootPath, { + agentSignature + }); + + return { + action: 'UpdateSidecarAgentSignature', + diaryName, + fileName, + sidecarPath: toFileUrl(sidecarResult.sidecarPath), + sidecar: sidecarResult.sidecar + }; +} + async function listSidecars(request) { const knowledgeRootPath = getKnowledgeRootPath(); const { diaryName } = request || {}; @@ -615,6 +656,17 @@ async function processRequest(request) { }; } + case 'UpdateSidecarAgentSignature': { + const updateAgentSignatureResult = await updateSidecarAgentSignature(request); + return { + success: true, + data: { + message: '侧车 Agent 署名更新完成。', + ...updateAgentSignatureResult + } + }; + } + case 'ListSidecars': { const listResult = await listSidecars(request); return { diff --git a/Plugin/KnowledgeMediaDescriber/plugin-manifest.json b/Plugin/KnowledgeMediaDescriber/plugin-manifest.json index 25237bb2a..c47dd0689 100644 --- a/Plugin/KnowledgeMediaDescriber/plugin-manifest.json +++ b/Plugin/KnowledgeMediaDescriber/plugin-manifest.json @@ -17,11 +17,11 @@ "invocationCommands": [ { "command": "TraceKnowledgeMedia", - "description": "功能: 通过一个已知的媒体 file:// 地址执行“超栈追踪”主流程。\n主流程包含两段,可一次性完成:\n1) 追踪并写侧车:解析媒体文件、校验类型、计算 SHA256 哈希,生成或更新 sidecar,并可写入 description / tags / presetName / generator / source。\n2) 入库同步(可选):当提供 targetDiaryName 后,在同一请求内把媒体文件与侧车文件一起复制或移动到目标日记本目录,并即时刷新侧车中的 filePath、mediaPath、relativePath、mediaHash。\n\n参数:\n- mediaPath (字符串, 必需): 媒体文件地址,推荐 file:// 绝对路径。\n- description (字符串, 可选): 主动写入侧车描述。\n- tags (字符串或数组, 可选): 主动写入侧车 Tag。字符串可用逗号分隔。\n- presetName (字符串, 可选): 侧车预设名。\n- generator (字符串或数组, 可选): 侧车 generator 字段。\n- source (字符串, 可选): 侧车 source 字段。\n- targetDiaryName (字符串, 可选): 目标日记本名称(只写 name,不写路径)。插件会自动保存到 KNOWLEDGEBASE_ROOT_PATH/targetDiaryName 下。\n- targetFileName (字符串, 可选): 目标文件名(含扩展名)。仅在 relocateMode 为 copy 时有效,用于在复制时重命名。\n- relocateMode (字符串, 可选, 默认 copy): copy 或 move。\n- overwrite (布尔, 可选, 默认 false): 是否允许覆盖同名文件;false 时自动改名避免覆盖。\n\n调用格式(仅写侧车):\n<<<[TOOL_REQUEST]>>>\ntool_name:「始」KnowledgeMediaDescriber「末」,\ncommand:「始」TraceKnowledgeMedia「末」,\nmediaPath:「始」file:///abs/path/IMG_1202.JPG「末」,\ndescription:「始」会议白板照片,核心结论已拍摄留档。「末」,\ntags:「始」会议,白板,记录「末」,\npresetName:「始」CognitoName01「末」\n<<<[END_TOOL_REQUEST]>>>\n\n调用格式(一次性:写侧车 + 复制到目标日记本并重命名):\n<<<[TOOL_REQUEST]>>>\ntool_name:「始」KnowledgeMediaDescriber「末」,\ncommand:「始」TraceKnowledgeMedia「末」,\nmediaPath:「始」file:///abs/path/IMG_1202.JPG「末」,\ndescription:「始」项目A现场截图。「末」,\ntags:「始」项目A,现场,截图「末」,\ntargetDiaryName:「始」Nova「末」,\ntargetFileName:「始」ProjectA_Site_01.jpg「末」,\nrelocateMode:「始」copy「末」,\noverwrite:「始」false「末」\n<<<[END_TOOL_REQUEST]>>>\n\n调用格式(一次性:写侧车 + 移动到目标日记本):\n<<<[TOOL_REQUEST]>>>\ntool_name:「始」KnowledgeMediaDescriber「末」,\ncommand:「始」TraceKnowledgeMedia「末」,\nmediaPath:「始」file:///abs/path/IMG_1202.JPG「末」,\ntargetDiaryName:「始」Nova「末」,\nrelocateMode:「始」move「末」,\noverwrite:「始」false「末」\n<<<[END_TOOL_REQUEST]>>>" + "description": "功能: 通过一个已知的媒体 file:// 地址执行“超栈追踪”主流程。\n主流程包含两段,可一次性完成:\n1) 追踪并写侧车:解析媒体文件、校验类型、计算 SHA256 哈希,生成或更新 sidecar,并可写入 description / tags / presetName / generator / source / agentSignature。\n2) 入库同步(可选):当提供 targetDiaryName 后,在同一请求内把媒体文件与侧车文件一起复制或移动到目标日记本目录,并即时刷新侧车中的 filePath、mediaPath、relativePath、mediaHash。\n\n参数:\n- mediaPath (字符串, 必需): 媒体文件地址,推荐 file:// 绝对路径。\n- description (字符串, 可选): 主动写入侧车描述。\n- tags (字符串或数组, 可选): 主动写入侧车 Tag。字符串可用逗号分隔。\n- presetName (字符串, 可选): 侧车预设名。\n- generator (字符串或数组, 可选): 侧车 generator 字段。\n- source (字符串, 可选): 侧车 source 字段。\n- agentSignature (字符串, 可选): 侧车 Agent 署名字段。\n- targetDiaryName (字符串, 可选): 目标日记本名称(只写 name,不写路径)。插件会自动保存到 KNOWLEDGEBASE_ROOT_PATH/targetDiaryName 下。\n- targetFileName (字符串, 可选): 目标文件名(含扩展名)。仅在 relocateMode 为 copy 时有效,用于在复制时重命名。\n- relocateMode (字符串, 可选, 默认 copy): copy 或 move。\n- overwrite (布尔, 可选, 默认 false): 是否允许覆盖同名文件;false 时自动改名避免覆盖。\n\n调用格式(仅写侧车):\n<<<[TOOL_REQUEST]>>>\ntool_name:「始」KnowledgeMediaDescriber「末」,\ncommand:「始」TraceKnowledgeMedia「末」,\nmediaPath:「始」file:///abs/path/IMG_1202.JPG「末」,\ndescription:「始」会议白板照片,核心结论已拍摄留档。「末」,\ntags:「始」会议,白板,记录「末」,\npresetName:「始」CognitoName01「末」,\nagentSignature:「始」Nova「末」\n<<<[END_TOOL_REQUEST]>>>\n\n调用格式(一次性:写侧车 + 复制到目标日记本并重命名):\n<<<[TOOL_REQUEST]>>>\ntool_name:「始」KnowledgeMediaDescriber「末」,\ncommand:「始」TraceKnowledgeMedia「末」,\nmediaPath:「始」file:///abs/path/IMG_1202.JPG「末」,\ndescription:「始」项目A现场截图。「末」,\ntags:「始」项目A,现场,截图「末」,\ntargetDiaryName:「始」Nova「末」,\ntargetFileName:「始」ProjectA_Site_01.jpg「末」,\nrelocateMode:「始」copy「末」,\noverwrite:「始」false「末」\n<<<[END_TOOL_REQUEST]>>>\n\n调用格式(一次性:写侧车 + 移动到目标日记本):\n<<<[TOOL_REQUEST]>>>\ntool_name:「始」KnowledgeMediaDescriber「末」,\ncommand:「始」TraceKnowledgeMedia「末」,\nmediaPath:「始」file:///abs/path/IMG_1202.JPG「末」,\ntargetDiaryName:「始」Nova「末」,\nrelocateMode:「始」move「末」,\noverwrite:「始」false「末」\n<<<[END_TOOL_REQUEST]>>>" }, { "command": "UpdateSidecarParams", - "description": "功能: 更改指定日记本中某个文件的侧车文件参数。\n\n参数:\n- diaryName (字符串, 必需): 日记本名称。\n- fileName (字符串, 必需): 媒体文件名(含扩展名)。\n- description (字符串, 可选): 更新描述。\n- tags (字符串或数组, 可选): 更新 Tag。\n- presetName (字符串, 可选): 更新预设名。\n- generator (字符串或数组, 可选): 更新 generator 字段。\n- source (字符串, 可选): 更新 source 字段。\n\n调用格式:\n<<<[TOOL_REQUEST]>>>\ntool_name:「始」KnowledgeMediaDescriber「末」,\ncommand:「始」UpdateSidecarParams「末」,\ndiaryName:「始」Nova「末」,\nfileName:「始」ProjectA_Site_01.jpg「末」,\ndescription:「始」更新后的描述内容「末」,\ntags:「始」新标签1,新标签2「末」\n<<<[END_TOOL_REQUEST]>>>" + "description": "功能: 更改指定日记本中某个文件的侧车文件参数。\n\n参数:\n- diaryName (字符串, 必需): 日记本名称。\n- fileName (字符串, 必需): 媒体文件名(含扩展名)。\n- description (字符串, 可选): 更新描述。\n- tags (字符串或数组, 可选): 更新 Tag。\n- presetName (字符串, 可选): 更新预设名。\n- generator (字符串或数组, 可选): 更新 generator 字段。\n- source (字符串, 可选): 更新 source 字段。\n- agentSignature (字符串, 可选): 更新 Agent 署名字段。\n\n调用格式:\n<<<[TOOL_REQUEST]>>>\ntool_name:「始」KnowledgeMediaDescriber「末」,\ncommand:「始」UpdateSidecarParams「末」,\ndiaryName:「始」Nova「末」,\nfileName:「始」ProjectA_Site_01.jpg「末」,\ndescription:「始」更新后的描述内容「末」,\ntags:「始」新标签1,新标签2「末」,\nagentSignature:「始」Nova「末」\n<<<[END_TOOL_REQUEST]>>>" }, { "command": "ListSidecars", @@ -38,6 +38,10 @@ { "command": "RenameDiaryMedia", "description": "功能: 重命名指定日记本中的指定多模态文件,并联动重命名对应侧车文件,同时刷新侧车中的 filePath / mediaPath / relativePath / mediaHash。\n参数:\n- diaryName (字符串, 必需): 日记本名称。\n- fileName (字符串, 必需): 原媒体文件名(含扩展名)。\n- newFileName (字符串, 必需): 新媒体文件名(含扩展名)。\n- overwrite (布尔, 可选, 默认 false): 是否允许覆盖同名文件;false 时自动改名避免覆盖。\n\n调用格式:\n<<<[TOOL_REQUEST]>>>\ntool_name:「始」KnowledgeMediaDescriber「末」,\ncommand:「始」RenameDiaryMedia「末」,\ndiaryName:「始」Nova「末」,\nfileName:「始」ProjectA_Site_01.jpg「末」,\nnewFileName:「始」ProjectA_Site_Final.jpg「末」,\noverwrite:「始」false「末」\n<<<[END_TOOL_REQUEST]>>>" + }, + { + "command": "UpdateSidecarAgentSignature", + "description": "功能: 专用于更新指定日记本中某个多模态文件侧车的 Agent 署名。\n\n参数:\n- diaryName (字符串, 必需): 日记本名称。\n- fileName (字符串, 必需): 媒体文件名(含扩展名)。\n- agentSignature (字符串, 可选): Agent 署名;传空字符串可清空。\n\n调用格式:\n<<<[TOOL_REQUEST]>>>\ntool_name:「始」KnowledgeMediaDescriber「末」,\ncommand:「始」UpdateSidecarAgentSignature「末」,\ndiaryName:「始」Nova「末」,\nfileName:「始」ProjectA_Site_01.jpg「末」,\nagentSignature:「始」Nova「末」\n<<<[END_TOOL_REQUEST]>>>" } ] }, diff --git a/Plugin/RAGDiaryPlugin/RAGDiaryPlugin.js b/Plugin/RAGDiaryPlugin/RAGDiaryPlugin.js index 41449f569..c446a8b44 100644 --- a/Plugin/RAGDiaryPlugin/RAGDiaryPlugin.js +++ b/Plugin/RAGDiaryPlugin/RAGDiaryPlugin.js @@ -73,6 +73,11 @@ class RAGDiaryPlugin { this.ragParamsWatcher = null; this.regexRuleCache = new Map(); + // 召回权重统计:文件路径 -> 累计权重(用于超限时优先保留高权重) + this.recallWeights = {}; + this.recallWeightsPath = path.join(__dirname, 'diary_recall_weights.json'); + this.recallWeightsMaxEntries = parseInt(process.env.DIARY_RECALL_WEIGHTS_MAX_ENTRIES || '20000', 10); + // 注意:不在构造函数中调用 loadConfig(),而是在 initialize() 中调用 } @@ -292,6 +297,7 @@ class RAGDiaryPlugin { await this.loadConfig(); await this.loadRagParams(); this._startRagParamsWatcher(); + await this._loadRecallWeights(); // ✅ 启动缓存清理任务 this._startCacheCleanupTask(); @@ -2020,6 +2026,210 @@ class RAGDiaryPlugin { } } + _resolveResultFilePath(result, dbName = '') { + if (!result || typeof result !== 'object') return ''; + const sidecarSuffix = this._getSidecarSuffix(); + + let candidate = ''; + if (typeof result.fullPath === 'string' && result.fullPath.trim()) { + candidate = result.fullPath.trim(); + } else if (typeof result.sourceFile === 'string' && result.sourceFile.trim()) { + candidate = result.sourceFile.trim(); + } else { + const mediaUrl = this._extractMediaFileUrlFromResult(result); + if (typeof mediaUrl === 'string' && mediaUrl.startsWith('file://')) { + candidate = decodeURIComponent(mediaUrl.replace(/^file:\/\//i, '')); + } + } + + if (!candidate) return ''; + + if (candidate.startsWith('file://')) { + candidate = decodeURIComponent(candidate.replace(/^file:\/\//i, '')); + } + + if (candidate.toLowerCase().endsWith(sidecarSuffix)) { + candidate = candidate.slice(0, -sidecarSuffix.length); + } + + if (path.isAbsolute(candidate)) return path.normalize(candidate); + + const normalized = path.normalize(candidate); + const directUnderRoot = path.join(dailyNoteRootPath, normalized); + if (normalized.startsWith(`${dbName}${path.sep}`) || normalized === dbName) { + return path.normalize(directUnderRoot); + } + + if (dbName) { + return path.normalize(path.join(dailyNoteRootPath, dbName, normalized)); + } + + return path.normalize(directUnderRoot); + } + + _buildRecallWeightKey(result, dbName = '', index = 0) { + const resolvedPath = this._resolveResultFilePath(result, dbName); + if (resolvedPath) return resolvedPath.toLowerCase(); + + const source = (result?.sourceFile || result?.fullPath || '').toString().trim(); + if (source) return `${dbName || 'unknown'}::${source.toLowerCase()}`; + + const textSig = (result?.text || '').toString().trim().slice(0, 120); + if (textSig) return `${dbName || 'unknown'}::__text__${textSig}`; + + return `${dbName || 'unknown'}::__idx__${index}`; + } + + _getRecallWeightByKey(fileKey = '') { + if (!fileKey) return 0; + const raw = this.recallWeights?.[fileKey]; + return Number.isFinite(raw) ? raw : 0; + } + + async _loadRecallWeights() { + try { + const raw = await fs.readFile(this.recallWeightsPath, 'utf-8'); + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + this.recallWeights = parsed; + } else { + this.recallWeights = {}; + } + console.log(`[RAGDiaryPlugin] 召回权重已加载: ${Object.keys(this.recallWeights).length} 条`); + } catch (error) { + if (error.code !== 'ENOENT') { + console.warn(`[RAGDiaryPlugin] 读取召回权重失败,使用空表: ${error.message}`); + } + this.recallWeights = {}; + } + } + + _pruneRecallWeightsIfNeeded() { + const maxEntries = Number.isFinite(this.recallWeightsMaxEntries) && this.recallWeightsMaxEntries > 0 + ? this.recallWeightsMaxEntries + : 20000; + + const entries = Object.entries(this.recallWeights || {}); + if (entries.length <= maxEntries) return; + + entries.sort((a, b) => (Number(b[1]) || 0) - (Number(a[1]) || 0)); + this.recallWeights = Object.fromEntries(entries.slice(0, maxEntries)); + } + + async _saveRecallWeights() { + this._pruneRecallWeightsIfNeeded(); + try { + await fs.writeFile(this.recallWeightsPath, JSON.stringify(this.recallWeights, null, 2), 'utf-8'); + } catch (error) { + console.warn(`[RAGDiaryPlugin] 写入召回权重失败: ${error.message}`); + } + } + + async _recordRecallWeightsForResults(results, dbName) { + if (!Array.isArray(results) || results.length === 0) return; + + const touchedKeys = new Set(); + for (let i = 0; i < results.length; i++) { + const item = results[i]; + const key = this._buildRecallWeightKey(item, dbName, i); + if (!key || touchedKeys.has(key)) continue; + touchedKeys.add(key); + + const current = this._getRecallWeightByKey(key); + this.recallWeights[key] = current + 1; + } + + if (touchedKeys.size > 0) { + await this._saveRecallWeights(); + } + } + + async _applyFileLimitsToResults(results, dbName, retrievalOptions = null) { + if (!Array.isArray(results) || results.length === 0) return results; + const opts = retrievalOptions || {}; + const hasMaxNum = Number.isFinite(opts.maxNum) && opts.maxNum > 0; + const hasMaxSize = Number.isFinite(opts.maxSizeKb) && opts.maxSizeKb > 0; + if (!hasMaxNum && !hasMaxSize) return results; + + const maxNum = hasMaxNum ? opts.maxNum : Number.POSITIVE_INFINITY; + const maxSizeBytes = hasMaxSize ? Math.floor(opts.maxSizeKb * 1024) : Number.POSITIVE_INFINITY; + + // 先按“文件”聚合,再按权重排序;超限时优先剔除低权重文件 + const candidateMap = new Map(); + + for (let i = 0; i < results.length; i++) { + const item = results[i]; + const fileKey = this._buildRecallWeightKey(item, dbName, i); + const resolvedPath = this._resolveResultFilePath(item, dbName); + const score = Number(item?.rerank_score ?? item?.score ?? 0); + + if (!candidateMap.has(fileKey)) { + let fileSizeBytes = 0; + if (resolvedPath) { + try { + const stat = await fs.stat(resolvedPath); + if (stat && stat.isFile()) fileSizeBytes = Number(stat.size) || 0; + } catch (e) { + fileSizeBytes = 0; + } + } + + candidateMap.set(fileKey, { + fileKey, + fileSizeBytes, + recallWeight: this._getRecallWeightByKey(fileKey), + bestScore: score, + firstIndex: i, + items: [item] + }); + } else { + const existing = candidateMap.get(fileKey); + existing.items.push(item); + existing.bestScore = Math.max(existing.bestScore, score); + } + } + + const candidates = Array.from(candidateMap.values()).sort((a, b) => { + // 1) 历史权重高的优先保留 + if (b.recallWeight !== a.recallWeight) return b.recallWeight - a.recallWeight; + // 2) 同权重时相关度高优先 + if (b.bestScore !== a.bestScore) return b.bestScore - a.bestScore; + // 3) 最后保持原始顺序稳定 + return a.firstIndex - b.firstIndex; + }); + + const selectedGroups = []; + let selectedFileCount = 0; + let selectedSizeBytes = 0; + + for (const candidate of candidates) { + const exceedNum = selectedFileCount + 1 > maxNum; + const exceedSize = selectedSizeBytes + candidate.fileSizeBytes > maxSizeBytes; + if (exceedNum || exceedSize) { + continue; + } + + selectedGroups.push(candidate); + selectedFileCount += 1; + selectedSizeBytes += candidate.fileSizeBytes; + } + + // 回到原结果顺序输出(避免格式波动) + const selectedKeySet = new Set(selectedGroups.map(g => g.fileKey)); + const selected = []; + for (let i = 0; i < results.length; i++) { + const item = results[i]; + const key = this._buildRecallWeightKey(item, dbName, i); + if (selectedKeySet.has(key)) selected.push(item); + } + + if (selected.length !== results.length) { + console.log(`[RAGDiaryPlugin] 文件返回限制生效(${dbName}): 原始 ${results.length} 条 -> 保留 ${selected.length} 条, 文件数=${selectedFileCount}, 总大小=${Math.round(selectedSizeBytes / 1024)}KB(按历史权重优先保留)`); + } + + return selected; + } + _toPathOnlyMultimodalResult(result) { if (!result || typeof result !== 'object') return ''; const source = (result.fullPath || result.sourceFile || '').toString(); @@ -2057,7 +2267,9 @@ class RAGDiaryPlugin { blacklist: [], regexRulesEarly: [], regexRulesLate: [], - tagOnlyStage: null + tagOnlyStage: null, + maxNum: null, + maxSizeKb: null }; if (!modifiers || typeof modifiers !== 'string') return options; @@ -2128,6 +2340,24 @@ class RAGDiaryPlugin { options.tagOnlyStage = token.toLowerCase().includes('@laststep') ? 'late' : 'early'; continue; } + + const maxNumMatch = token.match(/^MAX_NUM:(\d+)$/i); + if (maxNumMatch) { + const parsed = parseInt(maxNumMatch[1], 10); + if (Number.isFinite(parsed) && parsed > 0) { + options.maxNum = parsed; + } + continue; + } + + const maxSizeMatch = token.match(/^MAX_SIZE:(\d+(?:\.\d+)?)$/i); + if (maxSizeMatch) { + const parsed = parseFloat(maxSizeMatch[1]); + if (Number.isFinite(parsed) && parsed > 0) { + options.maxSizeKb = parsed; + } + continue; + } } return options; @@ -2864,6 +3094,9 @@ class RAGDiaryPlugin { } } + finalResultsForBroadcast = await this._applyFileLimitsToResults(finalResultsForBroadcast, dbName, effectiveRetrievalOptions); + await this._recordRecallWeightsForResults(finalResultsForBroadcast, dbName); + const shouldDirectSendMedia = (effectiveRetrievalOptions.transMode === null || effectiveRetrievalOptions.transMode === 'plus') && pendingUserMediaDirectives && diff --git a/modules/mediaSidecarManager.js b/modules/mediaSidecarManager.js index 7ce8de890..be42a6518 100644 --- a/modules/mediaSidecarManager.js +++ b/modules/mediaSidecarManager.js @@ -286,6 +286,7 @@ class MediaSidecarManager extends EventEmitter { tags, generator: Array.isArray(currentSidecar?.generator) ? currentSidecar.generator : ['Cognito-Core'], source: currentSidecar?.source || 'auto', + agentSignature: (typeof currentSidecar?.agentSignature === 'string' ? currentSidecar.agentSignature : ''), createdAt: currentSidecar?.createdAt || now, updatedAt: now }; diff --git a/routes/adminPanelRoutes.js b/routes/adminPanelRoutes.js index e17adb036..33913ef1f 100644 --- a/routes/adminPanelRoutes.js +++ b/routes/adminPanelRoutes.js @@ -1241,13 +1241,18 @@ module.exports = function (DEBUG_MODE, dailyNoteRootPath, pluginManager, getCurr ? existing.description.trim() : buildAutoDescription(mediaPath, stats)); + const nextPresetName = (typeof options.presetName === 'string') + ? options.presetName.trim() + : (existing && typeof existing.presetName === 'string' ? existing.presetName : ''); + const sidecar = { version: 1, filePath: toFileUrl(mediaPath), mediaPath: toFileUrl(mediaPath), - presetName: existing && typeof existing.presetName === 'string' ? existing.presetName : '', + presetName: nextPresetName, description: nextDescription, tags: existing && Array.isArray(existing.tags) ? normalizeTags(existing.tags) : [], + agentSignature: existing && typeof existing.agentSignature === 'string' ? existing.agentSignature : '', updatedAt: now }; @@ -1261,6 +1266,9 @@ module.exports = function (DEBUG_MODE, dailyNoteRootPath, pluginManager, getCurr if (options.patch.tags !== undefined) { sidecar.tags = normalizeTags(options.patch.tags); } + if (typeof options.patch.agentSignature === 'string') { + sidecar.agentSignature = options.patch.agentSignature.trim(); + } sidecar.updatedAt = now; } @@ -1298,6 +1306,7 @@ module.exports = function (DEBUG_MODE, dailyNoteRootPath, pluginManager, getCurr const description = sidecar && typeof sidecar.description === 'string' ? sidecar.description : ''; const presetName = sidecar && typeof sidecar.presetName === 'string' ? sidecar.presetName : ''; const tags = sidecar && Array.isArray(sidecar.tags) ? normalizeTags(sidecar.tags) : []; + const agentSignature = sidecar && typeof sidecar.agentSignature === 'string' ? sidecar.agentSignature : ''; const item = { mediaPath: toFileUrl(mediaPath), @@ -1309,7 +1318,8 @@ module.exports = function (DEBUG_MODE, dailyNoteRootPath, pluginManager, getCurr hasSidecar: !!sidecar, presetName, description, - tags + tags, + agentSignature }; if (loweredKeyword) { @@ -1317,7 +1327,8 @@ module.exports = function (DEBUG_MODE, dailyNoteRootPath, pluginManager, getCurr relativePath.toLowerCase(), description.toLowerCase(), presetName.toLowerCase(), - tags.join(',').toLowerCase() + tags.join(',').toLowerCase(), + agentSignature.toLowerCase() ].join('\n'); if (!searchable.includes(loweredKeyword)) { continue; @@ -1353,13 +1364,13 @@ module.exports = function (DEBUG_MODE, dailyNoteRootPath, pluginManager, getCurr }); adminApiRouter.post('/knowledge-media/update', async (req, res) => { - const { mediaPath, description, presetName, tags } = req.body || {}; + const { mediaPath, description, presetName, tags, agentSignature } = req.body || {}; try { const knowledgeRootPath = getKnowledgeRootPath(); await fs.access(knowledgeRootPath); const resolved = await resolveKnowledgeMediaPath(mediaPath, knowledgeRootPath); - const patch = { description, presetName, tags }; + const patch = { description, presetName, tags, agentSignature }; const { sidecarPath, sidecar } = await ensureSidecarForMedia(resolved.mediaPath, { patch }); notifyKnowledgeSidecarChanged(sidecarPath); @@ -1377,13 +1388,16 @@ module.exports = function (DEBUG_MODE, dailyNoteRootPath, pluginManager, getCurr }); adminApiRouter.post('/knowledge-media/regenerate', async (req, res) => { - const { mediaPath } = req.body || {}; + const { mediaPath, presetName } = req.body || {}; try { const knowledgeRootPath = getKnowledgeRootPath(); await fs.access(knowledgeRootPath); const resolved = await resolveKnowledgeMediaPath(mediaPath, knowledgeRootPath); - const { sidecarPath, sidecar } = await ensureSidecarForMedia(resolved.mediaPath, { regenerate: true }); + const { sidecarPath, sidecar } = await ensureSidecarForMedia(resolved.mediaPath, { + regenerate: true, + presetName: typeof presetName === 'string' ? presetName : undefined + }); notifyKnowledgeSidecarChanged(sidecarPath); res.json({ @@ -1398,70 +1412,6 @@ module.exports = function (DEBUG_MODE, dailyNoteRootPath, pluginManager, getCurr res.status(400).json({ success: false, error: 'Failed to regenerate knowledge media sidecar', details: error.message }); } }); - - adminApiRouter.post('/knowledge-media/rebuild', async (req, res) => { - const { regenerateExisting = false } = req.body || {}; - try { - const knowledgeRootPath = getKnowledgeRootPath(); - await fs.access(knowledgeRootPath); - - const mediaFiles = await walkKnowledgeMediaFiles(knowledgeRootPath); - let created = 0; - let updated = 0; - - for (const mediaPath of mediaFiles) { - const sidecarSuffix = getKnowledgeMediaSidecarSuffix(); - const sidecarPath = `${mediaPath}${sidecarSuffix}`; - const sidecarExists = !!(await safeReadJson(sidecarPath)); - - if (!sidecarExists || regenerateExisting) { - const { sidecarPath } = await ensureSidecarForMedia(mediaPath, { regenerate: !!regenerateExisting }); - notifyKnowledgeSidecarChanged(sidecarPath); - if (sidecarExists) { - updated += 1; - } else { - created += 1; - } - } - } - - res.json({ - success: true, - message: '知识库多媒体侧车重建完成。', - scanned: mediaFiles.length, - created, - updated - }); - } catch (error) { - console.error('[AdminPanelRoutes API] Error rebuilding knowledge media sidecars:', error); - res.status(500).json({ success: false, error: 'Failed to rebuild knowledge media sidecars', details: error.message }); - } - }); - - adminApiRouter.post('/knowledge-media/export', async (req, res) => { - try { - const knowledgeRootPath = getKnowledgeRootPath(); - await fs.access(knowledgeRootPath); - - const items = await buildKnowledgeMediaList(knowledgeRootPath); - const exportedAt = new Date().toISOString(); - const payload = { - exportedAt, - rootPath: toFileUrl(knowledgeRootPath), - sidecarSuffix: getKnowledgeMediaSidecarSuffix(), - itemCount: items.length, - items - }; - - const fileName = `knowledge_media_export_${Date.now()}.json`; - res.setHeader('Content-Type', 'application/json; charset=utf-8'); - res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`); - res.status(200).send(JSON.stringify(payload, null, 2)); - } catch (error) { - console.error('[AdminPanelRoutes API] Error exporting knowledge media:', error); - res.status(500).json({ success: false, error: 'Failed to export knowledge media', details: error.message }); - } - }); // --- End Knowledge Media Describer API --- // --- Image Cache API (Legacy, for backward compatibility) ---