feat: add HoloLake folder import and official assistant

This commit is contained in:
冰朔 2026-08-08 11:15:34 +08:00
commit a360982a9b
12 changed files with 507 additions and 76 deletions

View file

@ -0,0 +1,156 @@
import fs from 'fs';
import path from 'path';
import simpleGit from 'simple-git';
const DOCUMENT_EXTENSIONS = new Set(['.md', '.markdown', '.txt', '.csv', '.json', '.yaml', '.yml']);
const ASSET_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg']);
const IGNORED_DIRECTORIES = new Set(['.git', 'node_modules', '.idea', '.vscode', '__macosx']);
const MAX_FILES = 1000;
const MAX_FILE_BYTES = 10 * 1024 * 1024;
export interface FolderImportResult {
cancelled: boolean;
sourceName?: string;
destination?: string;
imported: number;
assets: number;
skipped: number;
failed: string[];
firstDocument?: string;
}
function safeSegment(value: string): string {
const cleaned = value.normalize('NFC').replace(/[\\/:*?"<>|\u0000-\u001f]/g, '-').trim();
return cleaned || '未命名文件夹';
}
function markdownFrontmatter(title: string, source: string): string {
const now = new Date().toISOString();
return `---\ntitle: ${JSON.stringify(title)}\nauthor: HoloLake Importer\ncreatedAt: ${now}\nupdatedAt: ${now}\nsource: ${JSON.stringify(source)}\ntags:\n - imported\n---\n\n`;
}
function csvToMarkdown(input: string): string {
const rows = input
.split(/\r?\n/)
.filter(Boolean)
.map(line => line.split(',').map(cell => cell.trim().replace(/\|/g, '\\|')));
if (!rows.length) return '_空表格_\n';
const width = Math.max(...rows.map(row => row.length));
const padded = rows.map(row => [...row, ...Array(Math.max(0, width - row.length)).fill('')]);
return [
`| ${padded[0].join(' | ')} |`,
`| ${Array(width).fill('---').join(' | ')} |`,
...padded.slice(1).map(row => `| ${row.join(' | ')} |`),
].join('\n') + '\n';
}
function convertDocument(sourcePath: string, relativePath: string): { targetRelative: string; content: string } {
const extension = path.extname(sourcePath).toLowerCase();
const raw = fs.readFileSync(sourcePath, 'utf8');
const title = path.basename(sourcePath, extension);
const targetRelative = extension === '.md'
? relativePath
: relativePath.slice(0, -extension.length) + '.md';
if (extension === '.md' || extension === '.markdown') {
return {
targetRelative: targetRelative.replace(/\.markdown$/i, '.md'),
content: raw,
};
}
if (extension === '.txt') {
return { targetRelative, content: `${markdownFrontmatter(title, relativePath)}# ${title}\n\n${raw}\n` };
}
if (extension === '.csv') {
return { targetRelative, content: `${markdownFrontmatter(title, relativePath)}# ${title}\n\n${csvToMarkdown(raw)}` };
}
const language = extension === '.json' ? 'json' : 'yaml';
return {
targetRelative,
content: `${markdownFrontmatter(title, relativePath)}# ${title}\n\n\`\`\`${language}\n${raw}\n\`\`\`\n`,
};
}
function walk(root: string, current: string, files: string[]): void {
if (files.length >= MAX_FILES) return;
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
if (entry.name.startsWith('._') || entry.name === '.DS_Store') continue;
const fullPath = path.join(current, entry.name);
if (entry.isSymbolicLink()) continue;
if (entry.isDirectory()) {
if (!entry.name.startsWith('.') && !IGNORED_DIRECTORIES.has(entry.name.toLowerCase())) {
walk(root, fullPath, files);
}
continue;
}
files.push(fullPath);
if (files.length >= MAX_FILES) return;
}
}
export async function importKnowledgeFolder(sourcePath: string, repoPath: string): Promise<FolderImportResult> {
const sourceRoot = fs.realpathSync(sourcePath);
const sourceName = safeSegment(path.basename(sourceRoot));
const docsRoot = path.join(repoPath, 'docs');
const importRoot = path.join(docsRoot, '导入');
let destinationName = sourceName;
let destinationRoot = path.join(importRoot, destinationName);
if (fs.existsSync(destinationRoot)) {
const stamp = new Date().toISOString().replace(/[-:]/g, '').slice(0, 13);
destinationName = `${sourceName}-${stamp}`;
destinationRoot = path.join(importRoot, destinationName);
}
fs.mkdirSync(destinationRoot, { recursive: true });
const candidates: string[] = [];
walk(sourceRoot, sourceRoot, candidates);
const result: FolderImportResult = {
cancelled: false,
sourceName,
destination: `导入/${destinationName}`,
imported: 0,
assets: 0,
skipped: 0,
failed: [],
};
for (const sourceFile of candidates) {
const relative = path.relative(sourceRoot, sourceFile);
const safeRelative = relative.split(path.sep).map(safeSegment).join(path.sep);
const extension = path.extname(sourceFile).toLowerCase();
try {
if (fs.statSync(sourceFile).size > MAX_FILE_BYTES) {
result.skipped += 1;
continue;
}
if (DOCUMENT_EXTENSIONS.has(extension)) {
const converted = convertDocument(sourceFile, safeRelative);
const target = path.join(destinationRoot, converted.targetRelative);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, converted.content, 'utf8');
const docPath = path.posix.join('导入', destinationName, converted.targetRelative.split(path.sep).join('/'));
result.firstDocument ||= docPath;
result.imported += 1;
} else if (ASSET_EXTENSIONS.has(extension)) {
const target = path.join(destinationRoot, safeRelative);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.copyFileSync(sourceFile, target);
result.assets += 1;
} else {
result.skipped += 1;
}
} catch (error) {
result.failed.push(`${relative}: ${error instanceof Error ? error.message : String(error)}`);
}
}
if (result.imported === 0 && result.assets === 0) {
fs.rmSync(destinationRoot, { recursive: true, force: true });
return result;
}
const git = simpleGit(repoPath);
await git.add(path.relative(repoPath, destinationRoot));
await git.commit(`import: 导入本地文件夹 ${sourceName}`);
return result;
}

View file

@ -17,6 +17,7 @@ import { app, BrowserWindow, shell, dialog, ipcMain, safeStorage } from 'electro
import path from 'path';
import { spawn, ChildProcess } from 'child_process';
import fs from 'fs';
import { importKnowledgeFolder } from './folder-import.js';
// ─── 配置 ───
@ -130,7 +131,7 @@ function createWindow(): void {
minHeight: 600,
titleBarStyle: 'hiddenInset',
trafficLightPosition: { x: 16, y: 16 },
backgroundColor: '#0d1117',
backgroundColor: '#f7f8fb',
webPreferences: {
preload: path.join(__dirname, 'preload.cjs'),
contextIsolation: true,
@ -157,6 +158,17 @@ function createWindow(): void {
}
ipcMain.handle('get-data-path', () => DATA_DIR);
ipcMain.handle('knowledge:import-folder', async () => {
const selection = await dialog.showOpenDialog(mainWindow ?? undefined, {
title: '导入本地知识文件夹',
buttonLabel: '导入到 HoloLake',
properties: ['openDirectory'],
});
if (selection.canceled || !selection.filePaths[0]) {
return { cancelled: true, imported: 0, assets: 0, skipped: 0, failed: [] };
}
return importKnowledgeFolder(selection.filePaths[0], KB_REPO_PATH);
});
ipcMain.handle('agent:get-config', () => {
const config = readModelConfig();
return {

View file

@ -11,11 +11,15 @@ import { contextBridge, ipcRenderer } from 'electron';
contextBridge.exposeInMainWorld('hololake', {
// 应用信息
platform: process.platform,
version: '0.5.0',
version: '0.5.1',
// 数据目录
getDataPath: () => ipcRenderer.invoke('get-data-path'),
knowledge: {
importFolder: () => ipcRenderer.invoke('knowledge:import-folder'),
},
agent: {
getConfig: () => ipcRenderer.invoke('agent:get-config'),
saveConfig: (config: { baseUrl: string; model: string; apiKey?: string }) =>

View file

@ -1,12 +1,12 @@
{
"name": "hololake-desktop",
"version": "0.5.0",
"version": "0.5.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hololake-desktop",
"version": "0.5.0",
"version": "0.5.1",
"dependencies": {
"cors": "^2.8.5",
"diff": "^9.0.0",

View file

@ -1,6 +1,6 @@
{
"name": "hololake-desktop",
"version": "0.5.0",
"version": "0.5.1",
"description": "HoloLake Era 桌面版 — Git 驱动的知识库管理",
"main": "dist-electron/main.cjs",
"type": "module",