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,42 @@
{
"schema": "guanghu.desktop-delivery-receipt/v1",
"receipt_id": "GH-HOLOLAKE-DESKTOP-0.5.1-IMPORT-20260808-001",
"development_id": "DEV-20260808-004",
"source_base_commit": "46bdd0ca738f3fea0896056bf902b92675b53e2d",
"source_commit": "SELF",
"target": {
"path": "/Users/bingshuolingdianyuanhe/Desktop/HoloLake Era.app",
"version": "0.5.1",
"bundle_identifier": "com.guanghu.hololake-desktop",
"binary_sha256": "08bea54b9667b639b90a4cd671082227b318fceaf5e53e426a3a4a3227342bc4",
"app_asar_sha256": "afb3b328fdbb3ad1d7a9c8bbb74bcc06f028a33e901ccbffc791bed6292d8505"
},
"result": "PASS_100_LOCAL_DEVELOPMENT_DELIVERY",
"implemented": {
"folder_import": "native macOS directory picker; recursive bounded import for Markdown, TXT, CSV, JSON and YAML; image assets copied; unsupported formats counted",
"import_versioning": "one Git commit per completed folder import; first imported document opens automatically",
"knowledge_ui": "HoloLake knowledge-space shell with restrained dark navigation, light reading canvas, document tree and import onboarding",
"assistant_prompt": "official HoloLake assistant identity, controlled-tool boundary, receipt-based completion claims and non-anthropomorphic language",
"desktop": "draggable Electron title region, signed arm64 application, existing macOS safeStorage model configuration preserved"
},
"verification": {
"production_build": "PASS",
"codesign": "valid on disk and satisfies designated requirement",
"folder_import_fixture": "3 documents imported; CSV rendered as a table; automatic first-document opening observed; fixture removed afterward",
"runtime_health": "127.0.0.1:3890 online",
"assistant_model": "configured and operational; deepseek-v4-pro",
"assistant_prompt_readback": "identified itself as HoloLake built-in knowledge assistant and stated tool, receipt and non-escalation boundaries",
"agent_tools_registered": 8,
"forgejo_remote_configured": false
},
"not_claimed": [
"PDF, DOCX, XLSX and other binary office formats are not imported in this first version.",
"Copied image assets are retained with the import but automatic link rewriting is not yet claimed.",
"No Forgejo remote was invented or connected.",
"No Apple notarization was performed; this is a signed local development delivery, not a public macOS release."
],
"recovery": {
"previous_app": "/Users/bingshuolingdianyuanhe/.Trash/HoloLake Era-0.5.0-before-0.5.1.app"
},
"verified_at": "2026-08-08T11:15:00+08:00"
}

View file

@ -1,5 +1,5 @@
/**
* Agent ·
* HoloLake Assistant ·
*
* Grok Build (xAI/SpaceXAI) Agent
* - xai-grok-agent: Agent = Definition + PromptContext + ToolBridge + Policies
@ -8,9 +8,9 @@
*
*
* - Rust TypeScript
* - Agent
* - Agent Git =
* - Git
* - Agent HoloLake
* - Agent Git
* - Git
*
*
* Grok Agent PersonaAgent
@ -347,26 +347,29 @@ export class PersonaAgent {
return `${this.definition.systemPromptBase}
${this.definition.name}${this.definition.role}
##
##
HoloLake
使
##
使
${toolsList}
##
##
1. AI Agent
2. Git
3. Git commit
4.
5.
1.
2.
3. Git Forgejo
4.
5.
6.
##
-
-
- `;
- 使使
-
- `;
}
// ─── LLM 调用(可插拔) ───
@ -474,10 +477,10 @@ ${toolsList}
export function createDefaultPersona(git: GitEngine): PersonaAgent {
return new PersonaAgent(
{
id: 'hololake-persona-default',
name: '光湖人格体',
role: '知识库管理者 — 负责文档的创建、整理、搜索和版本管理',
systemPromptBase: '你是光湖知识库的人格体,一个活的知识管理 AI Agent。',
id: 'hololake-assistant-default',
name: 'HoloLake 助手',
role: '知识工作助手 — 支持文档检索、编辑、整理和版本管理',
systemPromptBase: '你是 HoloLake 官方内置知识助手。所有回答与操作都必须遵守产品权限边界和可核验回执原则。',
tools: [
'inspect_repository',
'read_document',

View file

@ -18,6 +18,8 @@ export default function App() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [agentPanelOpen, setAgentPanelOpen] = useState(true);
const [importing, setImporting] = useState(false);
const [importMessage, setImportMessage] = useState<string | null>(null);
const refreshTree = useCallback(async () => {
try {
@ -85,6 +87,38 @@ export default function App() {
}
}, [currentPath, refreshTree]);
const importFolder = useCallback(async () => {
const bridge = (window as any).hololake?.knowledge;
if (!bridge?.importFolder) {
setError('本地文件夹导入只在 HoloLake 桌面 App 中提供');
return;
}
setImporting(true);
setError(null);
setImportMessage(null);
try {
const result = await bridge.importFolder();
if (result.cancelled) return;
if (!result.imported) {
setImportMessage(`没有找到可导入的文档;已跳过 ${result.skipped || 0} 个文件`);
return;
}
await refreshTree();
if (result.firstDocument) await openDoc(result.firstDocument);
const details = [
`已导入 ${result.imported} 篇文档`,
result.assets ? `${result.assets} 个图片资源` : '',
result.skipped ? `跳过 ${result.skipped} 个暂不支持的文件` : '',
result.failed?.length ? `${result.failed.length} 个文件失败` : '',
].filter(Boolean).join(' · ');
setImportMessage(details);
} catch (err: any) {
setError(`导入失败: ${err.message}`);
} finally {
setImporting(false);
}
}, [openDoc, refreshTree]);
useEffect(() => {
refreshTree();
}, [refreshTree]);
@ -101,7 +135,11 @@ export default function App() {
>
{sidebarOpen ? '◀' : '▶'}
</button>
<h1 className="kb-title"></h1>
<div className="kb-brand-mark" aria-hidden="true" />
<div>
<h1 className="kb-title">HoloLake</h1>
<div className="kb-subtitle"></div>
</div>
</div>
<div className="kb-header-center">
<SearchBar onSelect={openDoc} />
@ -131,7 +169,7 @@ export default function App() {
onClick={() => setAgentPanelOpen(!agentPanelOpen)}
title={agentPanelOpen ? '收起 Agent' : '展开 Agent'}
>
🌊 Agent
HoloLake
</button>
</div>
</header>
@ -141,11 +179,18 @@ export default function App() {
{/* 侧栏 */}
{sidebarOpen && (
<aside className="kb-sidebar">
<div className="kb-workspace-label"></div>
<div className="kb-sidebar-actions">
<button className="kb-btn-small" onClick={() => createDoc('')}>
+
<button className="kb-btn-import" onClick={importFolder} disabled={importing}>
<span className="kb-btn-import-icon"></span>
{importing ? '正在导入…' : '导入本地文件夹'}
</button>
<button className="kb-btn-new" onClick={() => createDoc('')}>
<span></span>
</button>
</div>
{importMessage && <div className="kb-import-message">{importMessage}</div>}
<div className="kb-tree-heading"></div>
<DocTree
nodes={tree}
currentPath={currentPath}
@ -166,9 +211,16 @@ export default function App() {
{loading && <div className="kb-loading">...</div>}
{!currentDoc && !loading && (
<div className="kb-empty">
<div className="kb-empty-icon">📖</div>
<p></p>
<p className="kb-empty-hint">+ </p>
<div className="kb-empty-card">
<div className="kb-empty-orbit"><span /></div>
<p className="kb-empty-eyebrow">HOLOLAKE KNOWLEDGE</p>
<h2></h2>
<p>HoloLake Git </p>
<button className="kb-empty-primary" onClick={importFolder} disabled={importing}>
{importing ? '正在导入…' : '选择本地文件夹'}
</button>
<div className="kb-empty-support"> MarkdownTXTCSVJSON YAML</div>
</div>
</div>
)}
{currentDoc && view === 'editor' && (

View file

@ -1,7 +1,7 @@
/**
* AgentChat
* AgentChat HoloLake
*
* Agent Git
* Git
*
*/
@ -215,8 +215,8 @@ export default function AgentChat({ apiBase, onDocSelect }: Props) {
<div className="agent-info">
<div className="agent-avatar">🌊</div>
<div className="agent-meta">
<h3>{status?.name || '光湖人格体'}</h3>
<span className="agent-role">{status?.role || '知识库管理者'}</span>
<h3>{status?.name || 'HoloLake 助手'}</h3>
<span className="agent-role">{status?.role || '知识工作助手'}</span>
</div>
</div>
<div className="agent-actions">
@ -274,8 +274,8 @@ export default function AgentChat({ apiBase, onDocSelect }: Props) {
{messages.length === 0 && (
<div className="agent-welcome">
<div className="welcome-icon">🌊</div>
<h2></h2>
<p>{status?.operational ? '我是知识库的 AI Agent可以调用工具管理文档。' : '知识库功能已运行Agent 需要配置模型后才会回应,不会用模拟回复冒充。'}</p>
<h2>HoloLake </h2>
<p>{status?.operational ? '可以检索、整理和编辑当前知识库,并为操作保留版本记录。' : '知识库已运行;配置模型后即可使用智能整理功能。'}</p>
<p className="welcome-hint"></p>
<div className="welcome-suggestions">
<button onClick={() => { setInput('帮我列出所有文档'); inputRef.current?.focus(); }}>
@ -355,7 +355,7 @@ export default function AgentChat({ apiBase, onDocSelect }: Props) {
<textarea
ref={inputRef}
className="agent-input"
placeholder="和人格体对话…"
placeholder="向 HoloLake 助手提问…"
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown}

View file

@ -49,7 +49,8 @@ function TreeNode({
className={`kb-tree-item kb-tree-folder-header ${expanded ? 'expanded' : ''}`}
onClick={() => setExpanded(!expanded)}
>
<span className="kb-tree-icon">{expanded ? '📂' : '📁'}</span>
<span className={`kb-tree-chevron ${expanded ? 'expanded' : ''}`}></span>
<span className="kb-tree-folder-icon" aria-hidden="true" />
<span className="kb-tree-name">{node.name}</span>
<button
className="kb-tree-add"
@ -80,7 +81,7 @@ function TreeNode({
className={`kb-tree-item kb-tree-doc ${isActive ? 'active' : ''}`}
onClick={() => onSelect(node.path)}
>
<span className="kb-tree-icon">📄</span>
<span className="kb-tree-doc-icon" aria-hidden="true" />
<span className="kb-tree-name">{node.name}</span>
</div>
);

View file

@ -40,9 +40,10 @@ export function Editor({ doc, onSave }: Props) {
return (
<div className="kb-editor">
<div className="kb-page-symbol" aria-hidden="true"></div>
{/* 元信息栏 */}
<div className="kb-editor-meta">
<span className="kb-editor-path">{doc.meta.id}</span>
<span className="kb-editor-path">HoloLake / {doc.meta.id}</span>
<span className="kb-editor-date">
{new Date(doc.meta.updatedAt).toLocaleString('zh-CN')}
</span>
@ -71,7 +72,7 @@ export function Editor({ doc, onSave }: Props) {
<button className="kb-btn-secondary" onClick={handleCancel}></button>
</>
) : (
<button className="kb-btn-primary" onClick={() => setEditing(true)}></button>
<button className="kb-btn-primary" onClick={() => setEditing(true)}></button>
)}
</div>

View file

@ -4,22 +4,22 @@
*/
:root {
--kb-bg: #0d1117;
--kb-bg-secondary: #161b22;
--kb-bg-tertiary: #21262d;
--kb-border: #30363d;
--kb-text: #e6edf3;
--kb-text-muted: #8b949e;
--kb-accent: #58a6ff;
--kb-accent-hover: #79c0ff;
--kb-bg: #f7f8fb;
--kb-bg-secondary: #101723;
--kb-bg-tertiary: #1a2433;
--kb-border: #dfe3eb;
--kb-text: #202632;
--kb-text-muted: #71798a;
--kb-accent: #277d91;
--kb-accent-hover: #1f6b7d;
--kb-danger: #f85149;
--kb-success: #3fb950;
--kb-warning: #d29922;
--kb-font-mono: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
--kb-font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
--kb-sidebar-width: 280px;
--kb-header-height: 52px;
--kb-radius: 6px;
--kb-sidebar-width: 286px;
--kb-header-height: 64px;
--kb-radius: 10px;
}
* {
@ -49,8 +49,8 @@ body {
height: var(--kb-header-height);
display: flex;
align-items: center;
padding: 0 16px;
border-bottom: 1px solid var(--kb-border);
padding: 0 18px 0 78px;
border-bottom: 1px solid #263143;
background: var(--kb-bg-secondary);
gap: 12px;
flex-shrink: 0;
@ -83,9 +83,11 @@ body {
}
.kb-title {
font-size: 16px;
font-weight: 600;
color: var(--kb-text);
font-size: 15px;
line-height: 1.1;
font-weight: 650;
letter-spacing: .02em;
color: #f4f7fb;
white-space: nowrap;
}
@ -99,7 +101,7 @@ body {
.kb-sidebar {
width: var(--kb-sidebar-width);
border-right: 1px solid var(--kb-border);
border-right: 1px solid #263143;
background: var(--kb-bg-secondary);
display: flex;
flex-direction: column;
@ -182,14 +184,15 @@ body {
flex: 1;
overflow-y: auto;
padding: 0;
background: radial-gradient(circle at 88% 6%, rgba(78, 178, 190, .08), transparent 28%), var(--kb-bg);
}
/* ─── 编辑器 ─── */
.kb-editor {
max-width: 900px;
max-width: 820px;
margin: 0 auto;
padding: 24px 32px;
padding: 72px 34px 120px;
}
.kb-editor-meta {
@ -197,15 +200,18 @@ body {
gap: 16px;
font-size: 12px;
color: var(--kb-text-muted);
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid var(--kb-border);
margin-bottom: 20px;
padding-bottom: 0;
border-bottom: none;
}
.kb-editor-title {
font-size: 28px;
font-weight: 700;
margin-bottom: 12px;
font-family: ui-serif, 'Songti SC', 'STSong', Georgia, serif;
font-size: clamp(36px, 4vw, 50px);
font-weight: 650;
letter-spacing: -.035em;
color: #18202d;
margin-bottom: 16px;
cursor: pointer;
line-height: 1.3;
}
@ -229,7 +235,7 @@ body {
.kb-editor-toolbar {
display: flex;
gap: 8px;
margin-bottom: 16px;
margin-bottom: 30px;
}
.kb-editor-edit-pane {
@ -270,13 +276,14 @@ body {
/* ─── Markdown 渲染 ─── */
.kb-markdown-render {
font-size: 15px;
line-height: 1.8;
font-family: ui-serif, 'Songti SC', 'STSong', Georgia, serif;
font-size: 17px;
line-height: 1.9;
color: var(--kb-text);
}
.kb-markdown-render h1 { font-size: 2em; margin: 1em 0 0.5em; border-bottom: 1px solid var(--kb-border); padding-bottom: 0.3em; }
.kb-markdown-render h2 { font-size: 1.5em; margin: 1em 0 0.5em; border-bottom: 1px solid var(--kb-border); padding-bottom: 0.3em; }
.kb-markdown-render h1 { font-size: 2em; margin: 1.4em 0 0.5em; border-bottom: none; padding-bottom: 0; color: #18202d; }
.kb-markdown-render h2 { font-size: 1.5em; margin: 1.4em 0 0.5em; border-bottom: none; padding-bottom: 0; color: #18202d; }
.kb-markdown-render h3 { font-size: 1.25em; margin: 1em 0 0.5em; }
.kb-markdown-render p { margin: 0.8em 0; }
.kb-markdown-render ul, .kb-markdown-render ol { margin: 0.8em 0; padding-left: 2em; }
@ -285,18 +292,19 @@ body {
border-left: 3px solid var(--kb-accent);
padding: 0.5em 1em;
margin: 1em 0;
background: var(--kb-bg-tertiary);
background: #eef5f6;
color: var(--kb-text-muted);
}
.kb-markdown-render code {
background: var(--kb-bg-tertiary);
background: #edf0f4;
padding: 2px 6px;
border-radius: 3px;
font-family: var(--kb-font-mono);
font-size: 0.9em;
}
.kb-markdown-render pre {
background: var(--kb-bg-tertiary);
background: #17202c;
color: #eef4f8;
padding: 16px;
border-radius: var(--kb-radius);
overflow-x: auto;
@ -752,6 +760,158 @@ body {
background: var(--kb-text-muted);
}
/* ─── HoloLake 0.5.1 知识空间外观 ─── */
.kb-brand-mark {
width: 27px;
height: 27px;
border: 1px solid rgba(140, 229, 235, .55);
border-radius: 50%;
position: relative;
box-shadow: inset 0 0 14px rgba(74, 192, 202, .15), 0 0 18px rgba(74, 192, 202, .12);
}
.kb-brand-mark::before,
.kb-brand-mark::after {
content: '';
position: absolute;
left: 50%;
top: 50%;
border: 1px solid rgba(140, 229, 235, .42);
border-radius: 50%;
transform: translate(-50%, -50%) rotate(-20deg);
}
.kb-brand-mark::before { width: 34px; height: 11px; }
.kb-brand-mark::after { width: 5px; height: 5px; background: #a6f2ed; border: 0; }
.kb-subtitle {
margin-top: 3px;
color: #77869a;
font-size: 9px;
letter-spacing: .18em;
}
.kb-header .kb-btn-icon,
.kb-header .kb-btn-tab { color: #aab5c3; }
.kb-header .kb-btn-tab:hover { color: #f4f7fb; }
.kb-header .kb-btn-tab.active { color: #9ee5e4; }
.kb-search-input-wrap {
background: rgba(255,255,255,.055);
border-color: #303c4d;
border-radius: 9px;
}
.kb-search-input { color: #edf2f7; }
.kb-workspace-label,
.kb-tree-heading {
color: #647287;
font-size: 10px;
font-weight: 700;
letter-spacing: .14em;
text-transform: uppercase;
}
.kb-workspace-label { padding: 22px 18px 7px; }
.kb-tree-heading { padding: 16px 18px 4px; }
.kb-sidebar-actions { display: grid; gap: 8px; border-bottom: 0; padding: 8px 12px; }
.kb-btn-import,
.kb-btn-new {
width: 100%;
display: flex;
align-items: center;
gap: 9px;
border-radius: 8px;
padding: 9px 11px;
cursor: pointer;
font-size: 12px;
text-align: left;
}
.kb-btn-import {
border: 1px solid rgba(110, 209, 214, .34);
color: #dff7f5;
background: linear-gradient(135deg, rgba(30,127,139,.32), rgba(29,65,89,.28));
}
.kb-btn-import:hover { border-color: rgba(140, 229, 235, .7); background: rgba(34,132,143,.38); }
.kb-btn-new { border: 1px solid #2b3748; color: #aeb9c7; background: rgba(255,255,255,.025); }
.kb-btn-new:hover { color: #f1f5f8; background: rgba(255,255,255,.06); }
.kb-btn-import:disabled,
.kb-btn-new:disabled { opacity: .55; cursor: wait; }
.kb-btn-import-icon { font-size: 17px; line-height: 1; }
.kb-import-message {
margin: 2px 12px 5px;
padding: 8px 10px;
border-radius: 7px;
background: rgba(44, 143, 150, .12);
color: #8cced0;
font-size: 10px;
line-height: 1.5;
}
.kb-tree { color: #aeb8c7; padding: 2px 8px 16px; }
.kb-tree-item { margin: 1px 0; padding: 7px 9px; border-radius: 7px; }
.kb-tree-item:hover { background: rgba(255,255,255,.055); }
.kb-tree-item.active { background: rgba(53, 152, 164, .17); color: #b4eeee; }
.kb-tree-chevron { color: #637186; font-size: 17px; line-height: 12px; transform-origin: center; transition: transform .15s; }
.kb-tree-chevron.expanded { transform: rotate(90deg); }
.kb-tree-folder-icon { width: 13px; height: 9px; border: 1px solid #77869b; border-radius: 2px; position: relative; }
.kb-tree-folder-icon::before { content: ''; position: absolute; width: 6px; height: 3px; left: 0; top: -4px; border: 1px solid #77869b; border-bottom: 0; border-radius: 2px 2px 0 0; }
.kb-tree-doc-icon { width: 11px; height: 14px; border: 1px solid #77869b; border-radius: 2px; position: relative; }
.kb-tree-doc-icon::after { content: ''; position: absolute; width: 5px; height: 1px; left: 2px; top: 5px; background: #77869b; box-shadow: 0 3px 0 #77869b; }
.kb-page-symbol {
width: 45px;
height: 45px;
display: grid;
place-items: center;
margin-bottom: 28px;
border-radius: 12px;
color: #2b8997;
font-size: 23px;
background: linear-gradient(145deg, #e9f6f6, #f4f7fb);
box-shadow: inset 0 0 0 1px rgba(39,125,145,.12);
}
.kb-editor-author { display: none; }
.kb-editor-toolbar { opacity: .65; transition: opacity .15s; }
.kb-editor:hover .kb-editor-toolbar { opacity: 1; }
.kb-empty { padding: 36px; }
.kb-empty-card {
width: min(590px, 100%);
padding: 56px 62px;
border: 1px solid #e0e5ec;
border-radius: 24px;
background: rgba(255,255,255,.82);
box-shadow: 0 24px 60px rgba(26, 38, 55, .08);
text-align: center;
}
.kb-empty-orbit { width: 72px; height: 72px; margin: 0 auto 28px; border: 1px solid rgba(39,125,145,.32); border-radius: 50%; position: relative; }
.kb-empty-orbit::before { content: ''; position: absolute; width: 92px; height: 28px; border: 1px solid rgba(39,125,145,.28); border-radius: 50%; left: -11px; top: 21px; transform: rotate(-22deg); }
.kb-empty-orbit span { position: absolute; width: 9px; height: 9px; border-radius: 50%; background: #318d9a; left: 31px; top: 31px; box-shadow: 0 0 18px rgba(49,141,154,.55); }
.kb-empty-eyebrow { color: #3b8994; font-size: 10px; font-weight: 750; letter-spacing: .2em; }
.kb-empty-card h2 { margin: 10px 0 14px; color: #17202c; font-family: ui-serif, 'Songti SC', Georgia, serif; font-size: 30px; line-height: 1.25; }
.kb-empty-card > p:not(.kb-empty-eyebrow) { max-width: 440px; margin: 0 auto; color: #697284; font-size: 14px; line-height: 1.8; }
.kb-empty-primary { margin-top: 28px; border: 0; border-radius: 10px; padding: 12px 21px; color: white; background: #237c8b; cursor: pointer; font-weight: 650; box-shadow: 0 9px 22px rgba(35,124,139,.22); }
.kb-empty-primary:hover { background: #1c6a77; }
.kb-empty-support { margin-top: 14px; color: #939aaa; font-size: 11px; }
.kb-agent-panel { border-left-color: #dbe1e8; background: #f4f6f9; }
.agent-header,
.runtime-status { background: #111925; border-color: #293446; color: #e9eef4; }
.agent-meta h3,
.model-config input,
.model-config button,
.forgejo-url,
.forgejo-actions button { color: #e9eef4; }
.agent-role,
.runtime-tools,
.forgejo-title span,
.forgejo-detail { color: #8190a4; }
.model-config,
.forgejo-status { background: #17212f; border-color: #2c394b; }
.model-config input,
.forgejo-url { background: #0e1621; border-color: #2c394b; }
.forgejo-actions button { background: #202c3b; border-color: #344155; }
/*
Agent 面板
*/

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",