diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 2395161..b7dc9ea 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -46,9 +46,9 @@ export function setupIPC(): void { } }); - ipcMain.handle('fs:writeFile', async (_, filePath: string, content: string) => { + ipcMain.handle('fs:writeFile', async (_, filePath: string, content: string, encoding?: string) => { try { - await fs.promises.writeFile(filePath, content, 'utf-8'); + await fs.promises.writeFile(filePath, content, (encoding as BufferEncoding) || 'utf-8'); return { success: true }; } catch (err) { return { success: false, error: (err as Error).message }; diff --git a/src/main/preload.ts b/src/main/preload.ts index c63f44b..c7afa48 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -13,7 +13,7 @@ contextBridge.exposeInMainWorld('metonaDesktop', { }, fs: { readFile: (filePath: string) => ipcRenderer.invoke('fs:readFile', filePath), - writeFile: (filePath: string, content: string) => ipcRenderer.invoke('fs:writeFile', filePath, content) + writeFile: (filePath: string, content: string, encoding?: string) => ipcRenderer.invoke('fs:writeFile', filePath, content, encoding) }, tool: { execute: (toolName: string, args: Record) => ipcRenderer.invoke('tool:execute', toolName, args), diff --git a/src/renderer/components/input-area.ts b/src/renderer/components/input-area.ts index 1a11ced..ece36fc 100644 --- a/src/renderer/components/input-area.ts +++ b/src/renderer/components/input-area.ts @@ -3,7 +3,7 @@ */ import { state, KEYS } from '../state/state.js'; -import { fileToBase64, fileToText, detectLanguage, truncate, escapeHtml, formatSize } from '../utils/utils.js'; +import { detectLanguage, truncate, escapeHtml, formatSize } from '../utils/utils.js'; import { getSelectedModel, isThinkEnabled, isVisionAvailable, isToolCallingSupported } from './model-bar.js'; import { renderMessages, appendAssistantPlaceholder, appendSystemMessage, @@ -21,8 +21,6 @@ import type { ChatSession, ChatMessage, OllamaStreamChunk, RagSource, FileConten let chatInputEl: HTMLTextAreaElement; let btnSendEl: HTMLButtonElement; -let fileInputEl: HTMLInputElement; -let textFileInputEl: HTMLInputElement; let imagePreviewEl: HTMLElement; let filePreviewEl: HTMLElement; let pendingImages: Array<{ name: string; base64: string }> = []; @@ -50,18 +48,20 @@ function getFileIcon(filename: string): string { return map[ext] || '📄'; } -function isTextFile(file: File): boolean { - const name = file.name.toLowerCase(); - if (name === 'dockerfile' || name === 'makefile') return true; - const ext = name.split('.').pop() || ''; +function isTextFile(name: string): boolean { + const lower = name.toLowerCase(); + if (lower === 'dockerfile' || lower === 'makefile') return true; + const ext = lower.split('.').pop() || ''; return TEXT_EXTENSIONS.has(ext); } +function getBridge(): any { + return (window as any).metonaDesktop; +} + export function initInputArea(): void { chatInputEl = document.querySelector('#chatInput')!; btnSendEl = document.querySelector('#btnSend')!; - fileInputEl = document.querySelector('#fileInput')!; - textFileInputEl = document.querySelector('#textFileInput')!; imagePreviewEl = document.querySelector('#imagePreview')!; filePreviewEl = document.querySelector('#filePreview')!; @@ -82,22 +82,30 @@ export function initInputArea(): void { chatInputEl.addEventListener('input', autoResizeTextarea); - document.querySelector('#btnAttachImg')!.addEventListener('click', () => { + // ── 图片上传:原生文件对话框 ── + document.querySelector('#btnAttachImg')!.addEventListener('click', async () => { if (!isVisionAvailable()) { showToast('当前模型不支持图片分析', 'warning'); return; } - fileInputEl.click(); - }); - fileInputEl.addEventListener('change', (e) => { - handleImageSelect((e.target as HTMLInputElement).files!); - (e.target as HTMLInputElement).value = ''; + const bridge = getBridge(); + if (!bridge) return; + const paths = await bridge.dialog.openFile({ + filters: [{ name: '图片', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'] }] + }); + if (!paths || paths.length === 0) return; + await handleImagePaths(paths); }); - document.querySelector('#btnAttachFile')!.addEventListener('click', () => textFileInputEl.click()); - textFileInputEl.addEventListener('change', (e) => { - handleTextFileSelect((e.target as HTMLInputElement).files!); - (e.target as HTMLInputElement).value = ''; + // ── 文件上传:原生文件对话框 ── + document.querySelector('#btnAttachFile')!.addEventListener('click', async () => { + const bridge = getBridge(); + if (!bridge) return; + const paths = await bridge.dialog.openFile({ + filters: [{ name: '文本/代码', extensions: Array.from(TEXT_EXTENSIONS) }] + }); + if (!paths || paths.length === 0) return; + await handleTextFilePaths(paths); }); imagePreviewEl.addEventListener('click', (e) => { @@ -122,23 +130,32 @@ function autoResizeTextarea(): void { chatInputEl.style.height = Math.min(chatInputEl.scrollHeight, 120) + 'px'; } -async function handleImageSelect(files: FileList): Promise { +async function handleImagePaths(paths: string[]): Promise { + const bridge = getBridge(); const maxSize = 20 * 1024 * 1024; - for (const file of Array.from(files)) { - if (!file.type.startsWith('image/')) continue; - if (file.size > maxSize) { - appendSystemMessage(`⚠️ 图片 ${file.name} 超过 20MB 限制`); - continue; - } + for (const filePath of paths) { + const name = filePath.split(/[/\\]/).pop() || filePath; try { - const base64 = await fileToBase64(file); - pendingImages.push({ name: file.name, base64 }); - logInfo(`图片已添加: ${file.name}`, formatSize(file.size)); - renderImagePreviews(); + const result = await bridge.fs.readFile(filePath); + if (!result.success) { + appendSystemMessage(`❌ 读取 ${name} 失败: ${result.error}`); + continue; + } + // IPC 返回的 content 是 UTF-8 字符串,转为 base64 + const binary = Uint8Array.from(atob(result.content as string), c => c.charCodeAt(0)); + const base64 = btoa(String.fromCharCode(...binary)); + if (binary.length > maxSize) { + appendSystemMessage(`⚠️ 图片 ${name} 超过 20MB 限制`); + continue; + } + pendingImages.push({ name, base64 }); + logInfo(`图片已添加: ${name}`, formatSize(binary.length)); } catch (err) { console.error('[InputArea] 图片读取失败:', err); + appendSystemMessage(`❌ 读取 ${name} 失败`); } } + renderImagePreviews(); } function renderImagePreviews(): void { @@ -156,28 +173,36 @@ function renderImagePreviews(): void { `).join(''); } -async function handleTextFileSelect(files: FileList): Promise { - for (const file of Array.from(files)) { - if (!isTextFile(file)) { - appendSystemMessage(`⚠️ ${file.name} 不是支持的文本/代码文件`); - continue; - } - if (file.size > MAX_FILE_SIZE) { - appendSystemMessage(`⚠️ ${file.name} 超过 500KB 限制(${formatSize(file.size)})`); - continue; - } - if (pendingFiles.some(f => f.name === file.name && f.size === file.size)) { - appendSystemMessage(`ℹ️ ${file.name} 已添加`); +async function handleTextFilePaths(paths: string[]): Promise { + const bridge = getBridge(); + for (const filePath of paths) { + const name = filePath.split(/[/\\]/).pop() || filePath; + if (!isTextFile(name)) { + appendSystemMessage(`⚠️ ${name} 不是支持的文本/代码文件`); continue; } try { - const content = await fileToText(file); - pendingFiles.push({ name: file.name, content, language: detectLanguage(file.name), size: file.size }); - logInfo(`文件已添加: ${file.name}`, formatSize(file.size)); + const result = await bridge.fs.readFile(filePath); + if (!result.success) { + appendSystemMessage(`❌ 读取 ${name} 失败: ${result.error}`); + continue; + } + const content = result.content as string; + const size = new TextEncoder().encode(content).length; + if (size > MAX_FILE_SIZE) { + appendSystemMessage(`⚠️ ${name} 超过 500KB 限制(${formatSize(size)})`); + continue; + } + if (pendingFiles.some(f => f.name === name)) { + appendSystemMessage(`ℹ️ ${name} 已添加`); + continue; + } + pendingFiles.push({ name, content, language: detectLanguage(name), size }); + logInfo(`文件已添加: ${name}`, formatSize(size)); renderFilePreviews(); } catch (err) { console.error('[InputArea] 文件读取失败:', err); - appendSystemMessage(`❌ 读取 ${file.name} 失败`); + appendSystemMessage(`❌ 读取 ${name} 失败`); } } } diff --git a/src/renderer/components/kb-modal.ts b/src/renderer/components/kb-modal.ts index 48eb262..c282947 100644 --- a/src/renderer/components/kb-modal.ts +++ b/src/renderer/components/kb-modal.ts @@ -8,7 +8,7 @@ import { removeDocumentFromKB, getDocumentsInCollection, retrieveContext, buildRagSystemPrompt } from '../services/rag.js'; -import { fileToText, formatSize, escapeHtml, truncate } from '../utils/utils.js'; +import { formatSize, escapeHtml, truncate } from '../utils/utils.js'; import { showToast } from './toast.js'; import { showConfirm } from './prompt-modal.js'; import { OllamaAPI } from '../api/ollama.js'; @@ -31,11 +31,16 @@ export function initKBModal(): void { document.querySelector('#btnNewCollection')!.addEventListener('click', createCollection); - document.querySelector('#btnUploadDoc')!.addEventListener('click', () => { + document.querySelector('#btnUploadDoc')!.addEventListener('click', async () => { if (!currentColId) { showToast('请先选择一个知识库集合', 'warning'); return; } - (document.querySelector('#kbFileInput') as HTMLInputElement).click(); + const bridge = (window as any).metonaDesktop; + if (!bridge) return; + const paths = await bridge.dialog.openFile({ + filters: [{ name: '文档', extensions: ['txt', 'md', 'py', 'js', 'ts', 'java', 'go', 'rs', 'cpp', 'c', 'h', 'hpp', 'rb', 'php', 'sh', 'html', 'css', 'json', 'yaml', 'yml', 'toml', 'xml', 'sql', 'csv'] }] + }); + if (!paths || paths.length === 0) return; + await handleDocUpload(paths); }); - document.querySelector('#kbFileInput')!.addEventListener('change', handleDocUpload); document.querySelector('#toggleRAG')!.addEventListener('change', (e) => { ragEnabled = (e.target as HTMLInputElement).checked; @@ -216,47 +221,56 @@ async function createCollection(): Promise { await refreshDocList(); } -async function handleDocUpload(e: Event): Promise { - const fileArr = Array.from((e.target as HTMLInputElement).files || []); - (e.target as HTMLInputElement).value = ''; - - if (fileArr.length === 0) return; +async function handleDocUpload(paths: string[]): Promise { + if (paths.length === 0) return; const embedModel = (document.querySelector('#selectEmbedModel') as HTMLSelectElement).value; if (!embedModel) { showToast('请先选择一个嵌入模型', 'warning'); return; } + const bridge = (window as any).metonaDesktop; const progressEl = document.querySelector('#kbProgress') as HTMLElement; const progressTextEl = document.querySelector('#kbProgressText')!; let successCount = 0; let failCount = 0; - for (const file of fileArr) { - if (file.size > 5 * 1024 * 1024) { - showToast(`${file.name} 超过 5MB 限制`, 'warning'); - failCount++; - continue; - } + for (const filePath of paths) { + const name = filePath.split(/[/\\]/).pop() || filePath; try { progressEl.style.display = ''; - progressTextEl.textContent = `正在处理 ${file.name}...`; + progressTextEl.textContent = `正在处理 ${name}...`; - const content = await fileToText(file); - const result = await addDocumentToKB( - currentColId!, file.name, content, embedModel, + const result = await bridge.fs.readFile(filePath); + if (!result.success) { + showToast(`${name} 读取失败: ${result.error}`, 'error'); + failCount++; + continue; + } + const content = result.content as string; + const size = new TextEncoder().encode(content).length; + + if (size > 5 * 1024 * 1024) { + showToast(`${name} 超过 5MB 限制`, 'warning'); + failCount++; + continue; + } + + const kbResult = await addDocumentToKB( + currentColId!, name, content, embedModel, (done, total, msg) => { - progressTextEl.textContent = `${file.name}: ${msg} (${done}/${total})`; + progressTextEl.textContent = `${name}: ${msg} (${done}/${total})`; } ); - showToast(`${file.name} 已添加到知识库(${result.chunkCount} 个分块)`, 'success'); - logRAG(`文档上传: ${file.name}`, `${result.chunkCount} 个分块`); + showToast(`${name} 已添加到知识库(${kbResult.chunkCount} 个分块)`, 'success'); + logRAG(`文档上传: ${name}`, `${kbResult.chunkCount} 个分块`); successCount++; } catch (err) { console.error('[KB] 文档处理失败:', err); - showToast(`${file.name} 处理失败: ${(err as Error).message}`, 'error'); - logError(`RAG 文档处理失败: ${file.name}`, (err as Error).message); + const fileName = filePath.split(/[/\\]/).pop() || filePath; + showToast(`${fileName} 处理失败: ${(err as Error).message}`, 'error'); + logError(`RAG 文档处理失败: ${fileName}`, (err as Error).message); failCount++; } } diff --git a/src/renderer/components/settings-modal.ts b/src/renderer/components/settings-modal.ts index 8ed808b..8c40c1a 100644 --- a/src/renderer/components/settings-modal.ts +++ b/src/renderer/components/settings-modal.ts @@ -4,7 +4,7 @@ import { state, KEYS } from '../state/state.js'; import { debounce } from '../utils/utils.js'; -import { encryptData, decryptData, isMetonaFile } from '../services/crypto.js'; +import { encryptData, decryptData } from '../services/crypto.js'; import { setMemoryEnabled } from '../services/memory-manager.js'; import { setToolEnabled } from '../services/tool-registry.js'; import { logSetting, logInfo, logWarn, logError, logSuccess } from '../services/log-service.js'; @@ -108,13 +108,15 @@ export function initSettingsModal(): void { document.querySelector('#btnExportAll')!.addEventListener('click', exportAllSessions); - const importFileInput = document.querySelector('#importFileInput') as HTMLInputElement; - document.querySelector('#btnImportSessions')!.addEventListener('click', () => importFileInput.click()); - importFileInput.addEventListener('change', (e) => { - if ((e.target as HTMLInputElement).files!.length > 0) { - importSessions((e.target as HTMLInputElement).files![0]); - (e.target as HTMLInputElement).value = ''; - } + // ── 导入会话:原生文件对话框 ── + document.querySelector('#btnImportSessions')!.addEventListener('click', async () => { + const bridge = (window as any).metonaDesktop; + if (!bridge) return; + const paths = await bridge.dialog.openFile({ + filters: [{ name: 'Metona 备份', extensions: ['metona'] }] + }); + if (!paths || paths.length === 0) return; + await importSessions(paths[0]); }); // ── Tool Calling 设置 ── @@ -198,10 +200,35 @@ async function exportAllSessions(): Promise { try { const blob = await encryptData(backup); const ts = new Date().toISOString().slice(0, 10); - const a = document.createElement('a'); - a.href = URL.createObjectURL(blob); - a.download = `metona-backup-${ts}.metona`; - a.click(); + + // ── 原生保存对话框 ── + const bridge = (window as any).metonaDesktop; + if (bridge) { + const filePath = await bridge.dialog.saveFile({ + defaultPath: `metona-backup-${ts}.metona`, + filters: [{ name: 'Metona 备份', extensions: ['metona'] }] + }); + if (!filePath) return; + // blob → ArrayBuffer → base64 字符串,通过 base64 编码写入二进制文件 + const buffer = await blob.arrayBuffer(); + const bytes = new Uint8Array(buffer); + let binary = ''; + for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]); + const b64 = btoa(binary); + const result = await bridge.fs.writeFile(filePath, b64, 'base64'); + if (!result.success) { + showToast(`导出失败: ${result.error}`, 'error'); + logError('导出失败', result.error); + return; + } + } else { + // 回退:浏览器下载 + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = `metona-backup-${ts}.metona`; + a.click(); + } + showToast(`已导出 ${sessions.length} 个会话(已加密)`, 'success'); logSuccess(`导出完成: ${sessions.length} 个会话`); } catch (err) { @@ -211,19 +238,29 @@ async function exportAllSessions(): Promise { } } -async function importSessions(file: File): Promise { +async function importSessions(filePath: string): Promise { const db = state.get(KEYS.DB); if (!db) return; - if (!isMetonaFile(file)) { + const fileName = filePath.split(/[/\\]/).pop() || filePath; + if (!fileName.endsWith('.metona')) { showToast('仅支持导入 .metona 格式文件', 'error'); return; } try { - const buffer = await file.arrayBuffer(); - const data = await decryptData(buffer); - logInfo('导入会话', `文件: ${file.name}`); + const bridge = (window as any).metonaDesktop; + const result = await bridge.fs.readFile(filePath); + if (!result.success) { + showToast(`读取文件失败: ${result.error}`, 'error'); + return; + } + // IPC 返回 base64 编码的二进制,转 ArrayBuffer + const binary = atob(result.content as string); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + const data = await decryptData(bytes.buffer); + logInfo('导入会话', `文件: ${fileName}`); let sessions: ChatSession[]; if (Array.isArray(data)) { diff --git a/src/renderer/index.html b/src/renderer/index.html index b9b3bc7..4d5f700 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -141,8 +141,6 @@ - -