feat: 文件对话框全部改用 Electron 原生对话框

- input-area.ts: 图片/文件上传改用 dialog.showOpenDialog(通过 IPC)
- settings-modal.ts: 导入改用原生打开对话框,导出改用原生保存对话框
- kb-modal.ts: 知识库文档上传改用原生打开对话框
- ipc.ts/preload.ts/types.d.ts: writeFile 增加 encoding 参数支持二进制写入
- index.html: 移除 4 个无用的 <input type="file"> 元素
- 清理死代码: fileToBase64, fileToText, isMetonaFile
This commit is contained in:
thzxx
2026-04-07 01:33:51 +08:00
parent 58ef272061
commit 02d5ff19c7
9 changed files with 167 additions and 122 deletions
+2 -2
View File
@@ -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 };
+1 -1
View File
@@ -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<string, unknown>) => ipcRenderer.invoke('tool:execute', toolName, args),
+69 -44
View File
@@ -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();
const bridge = getBridge();
if (!bridge) return;
const paths = await bridge.dialog.openFile({
filters: [{ name: '图片', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'] }]
});
fileInputEl.addEventListener('change', (e) => {
handleImageSelect((e.target as HTMLInputElement).files!);
(e.target as HTMLInputElement).value = '';
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<void> {
async function handleImagePaths(paths: string[]): Promise<void> {
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 限制`);
for (const filePath of paths) {
const name = filePath.split(/[/\\]/).pop() || filePath;
try {
const result = await bridge.fs.readFile(filePath);
if (!result.success) {
appendSystemMessage(`❌ 读取 ${name} 失败: ${result.error}`);
continue;
}
try {
const base64 = await fileToBase64(file);
pendingImages.push({ name: file.name, base64 });
logInfo(`图片已添加: ${file.name}`, formatSize(file.size));
renderImagePreviews();
// 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<void> {
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<void> {
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} 失败`);
}
}
}
+38 -24
View File
@@ -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<void> {
await refreshDocList();
}
async function handleDocUpload(e: Event): Promise<void> {
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<void> {
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');
for (const filePath of paths) {
const name = filePath.split(/[/\\]/).pop() || filePath;
try {
progressEl.style.display = '';
progressTextEl.textContent = `正在处理 ${name}...`;
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;
}
try {
progressEl.style.display = '';
progressTextEl.textContent = `正在处理 ${file.name}...`;
const content = await fileToText(file);
const result = await addDocumentToKB(
currentColId!, file.name, content, embedModel,
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++;
}
}
+50 -13
View File
@@ -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<void> {
try {
const blob = await encryptData(backup);
const ts = new Date().toISOString().slice(0, 10);
// ── 原生保存对话框 ──
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<void> {
}
}
async function importSessions(file: File): Promise<void> {
async function importSessions(filePath: string): Promise<void> {
const db = state.get<ChatDB | null>(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)) {
-4
View File
@@ -141,8 +141,6 @@
<polyline points="10 9 9 9 8 9"/>
</svg>
</button>
<input type="file" id="fileInput" accept="image/*" multiple style="display:none;">
<input type="file" id="textFileInput" accept=".txt,.md,.log,.csv,.py,.pyw,.js,.mjs,.cjs,.ts,.tsx,.jsx,.java,.c,.h,.cpp,.cc,.hpp,.go,.rs,.rb,.php,.sh,.bash,.zsh,.sql,.html,.htm,.css,.json,.jsonl,.xml,.svg,.yaml,.yml,.toml,.ini,.cfg,.conf,.swift,.kt,.kts,.scala,.lua,.r,.m,.cs,.fs,.ex,.exs,.erl,.hs,.clj,.lisp,.diff,.patch,Dockerfile,Makefile,.rst,.tsv,.vb,.pl,.mm,.pyi,.fsharp,.makefile,.dockerfile" multiple style="display:none;">
<textarea class="chat-input" id="chatInput" placeholder="输入消息..." rows="1"></textarea>
<label class="think-toggle" id="thinkToggleWrap" title="Think 模式未可用">
<input type="checkbox" id="toggleThink" disabled>
@@ -264,7 +262,6 @@
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<button class="btn btn-outline" id="btnExportAll">📦 导出全部会话</button>
<button class="btn btn-outline" id="btnImportSessions">📥 导入会话</button>
<input type="file" id="importFileInput" accept=".metona" style="display:none;">
<button class="btn btn-danger" id="btnClearAllHistory">清空所有历史</button>
</div>
<p class="text-muted" style="margin-top:6px;font-size:11px;">导出格式为 JSON,可备份后在其他设备导入恢复。</p>
@@ -344,7 +341,6 @@
<div class="kb-main">
<div class="kb-toolbar">
<button class="btn btn-sm btn-outline" id="btnUploadDoc">📄 上传文档</button>
<input type="file" id="kbFileInput" accept=".txt,.md,.py,.js,.ts,.java,.go,.rs,.cpp,.c,.h,.hpp,.rb,.php,.sh,.html,.css,.json,.yaml,.yml,.toml,.xml,.sql,.csv" multiple style="display:none;">
<span id="kbProgress" style="display:none;" class="kb-progress">
<span class="spinner"></span>
<span id="kbProgressText">处理中...</span>
-3
View File
@@ -158,6 +158,3 @@ export async function decryptData(buffer: ArrayBuffer): Promise<unknown> {
return JSON.parse(new TextDecoder().decode(jsonBytes));
}
export function isMetonaFile(file: File): boolean {
return file.name.endsWith('.metona');
}
+1 -1
View File
@@ -198,7 +198,7 @@ export interface MetonaDesktopAPI {
};
fs: {
readFile: (filePath: string) => Promise<{ success: boolean; content?: string; name?: string; error?: string }>;
writeFile: (filePath: string, content: string) => Promise<{ success: boolean; error?: string }>;
writeFile: (filePath: string, content: string, encoding?: string) => Promise<{ success: boolean; error?: string }>;
};
tool: {
execute: (toolName: string, args: Record<string, unknown>) => Promise<ToolResult>;
-24
View File
@@ -59,30 +59,6 @@ export function downloadFile(filename: string, content: string, type: string): v
URL.revokeObjectURL(url);
}
/** File → Base64(去前缀) */
export function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
const base64 = result.split(',')[1];
resolve(base64);
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
/** File → 文本内容 */
export function fileToText(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsText(file);
});
}
/** 根据文件名检测语言标识(用于 Markdown 代码块) */
export function detectLanguage(filename: string): string {
const ext = filename.split('.').pop()?.toLowerCase() || '';