feat(hololake): ship language platform 0.7.0
This commit is contained in:
parent
a360982a9b
commit
5105ec5e32
47 changed files with 4566 additions and 398 deletions
|
|
@ -137,6 +137,15 @@ export class GitEngine {
|
|||
return this.getRepositoryStatus(remoteName);
|
||||
}
|
||||
|
||||
async removeRemote(remoteName = 'origin'): Promise<RepositoryStatus> {
|
||||
this.assertRemoteName(remoteName);
|
||||
const remotes = await this.git.getRemotes();
|
||||
if (remotes.some(remote => remote.name === remoteName)) {
|
||||
await this.git.removeRemote(remoteName);
|
||||
}
|
||||
return this.getRepositoryStatus(remoteName);
|
||||
}
|
||||
|
||||
async fetchRemote(remoteName = 'origin'): Promise<RepositoryStatus> {
|
||||
this.assertRemoteName(remoteName);
|
||||
await this.requireRemote(remoteName);
|
||||
|
|
@ -470,6 +479,6 @@ export class GitEngine {
|
|||
|
||||
private async requireRemote(name: string): Promise<void> {
|
||||
const remotes = await this.git.getRemotes();
|
||||
if (!remotes.includes(name)) throw new Error('尚未配置 Forgejo 仓库地址');
|
||||
if (!remotes.some(remote => remote.name === name)) throw new Error('尚未配置 Forgejo 仓库地址');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,11 +9,11 @@ import express from 'express';
|
|||
import cors from 'cors';
|
||||
import { GitEngine } from './git-engine.js';
|
||||
import { PersonaAgent, createDefaultPersona } from './persona-agent.js';
|
||||
import { projectHumanKnowledgeTree } from './language-protocol-kernel.js';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
// __dirname 兼容 CJS 和 ESM
|
||||
const _dirname = typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url));
|
||||
const _dirname = typeof __dirname !== 'undefined' ? __dirname : process.cwd();
|
||||
|
||||
// ─── 配置 ───
|
||||
|
||||
|
|
@ -54,7 +54,7 @@ app.get('/api/health', (_req, res) => {
|
|||
app.get('/api/tree', async (_req, res) => {
|
||||
try {
|
||||
const tree = await engine.getTree();
|
||||
res.json({ ok: true, tree });
|
||||
res.json({ ok: true, tree: projectHumanKnowledgeTree(tree) });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
|
|
@ -205,6 +205,14 @@ app.put('/api/forgejo/remote', async (req, res) => {
|
|||
}
|
||||
});
|
||||
|
||||
app.delete('/api/forgejo/remote', async (_req, res) => {
|
||||
try {
|
||||
res.json({ ok: true, status: await engine.removeRemote() });
|
||||
} catch (err: any) {
|
||||
res.status(400).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/forgejo/fetch', async (_req, res) => {
|
||||
try {
|
||||
res.json({ ok: true, status: await engine.fetchRemote() });
|
||||
|
|
@ -280,12 +288,35 @@ app.post('/api/agent/chat', async (req, res) => {
|
|||
ok: true,
|
||||
reply,
|
||||
conversationLength: agent.getConversation().length,
|
||||
pendingActions: agent.getPendingActions(),
|
||||
});
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/agent/actions', (_req, res) => {
|
||||
res.json({ ok: true, actions: getAgent().getPendingActions() });
|
||||
});
|
||||
|
||||
app.post('/api/agent/actions/:actionId/confirm', async (req, res) => {
|
||||
try {
|
||||
const result = await getAgent().confirmAction(req.params.actionId);
|
||||
res.json({ ok: true, result, actions: getAgent().getPendingActions() });
|
||||
} catch (err: any) {
|
||||
res.status(409).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/agent/actions/:actionId/reject', (req, res) => {
|
||||
try {
|
||||
getAgent().rejectAction(req.params.actionId);
|
||||
res.json({ ok: true, actions: getAgent().getPendingActions() });
|
||||
} catch (err: any) {
|
||||
res.status(409).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Agent 对话历史
|
||||
app.get('/api/agent/conversation', (_req, res) => {
|
||||
const agent = getAgent();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { projectHumanKnowledgeTree, renderLanguageKernelForModel } from './language-protocol-kernel.js';
|
||||
import type { DocTreeNode } from './git-engine.js';
|
||||
|
||||
test('人类知识树隐藏底层协议与人格配置但保留用户资料', () => {
|
||||
const tree: DocTreeNode[] = [
|
||||
{
|
||||
name: '光湖语言世界——第五域',
|
||||
path: 'world',
|
||||
type: 'folder',
|
||||
children: [
|
||||
{ name: 'AI技能包', path: 'world/AI技能包', type: 'folder', children: [{ name: 'INDEX', path: 'world/AI技能包/INDEX.md', type: 'document' }] },
|
||||
{ name: '铸澜人格系统', path: 'world/铸澜人格系统', type: 'folder', children: [{ name: 'INDEX', path: 'world/铸澜人格系统/INDEX.md', type: 'document' }] },
|
||||
{ name: 'AGENTS', path: 'world/AGENTS.md', type: 'document' },
|
||||
{ name: '短剧视频AI制作', path: 'world/短剧视频AI制作', type: 'folder', children: [{ name: '生产入口', path: 'world/短剧视频AI制作/生产入口.md', type: 'document' }] },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '光湖语言世界 · 世界观、编号、协议与系统演化总纲 · v1.0',
|
||||
path: 'protocol',
|
||||
type: 'folder',
|
||||
children: [{ name: '协议', path: 'protocol/协议.md', type: 'document' }],
|
||||
},
|
||||
];
|
||||
|
||||
const projected = projectHumanKnowledgeTree(tree);
|
||||
assert.equal(projected.length, 1);
|
||||
assert.equal(projected[0].name, '短剧视频AI制作');
|
||||
assert.deepEqual(projected[0].children?.map(node => node.name), ['生产入口']);
|
||||
});
|
||||
|
||||
test('模型运行投影包含协议核与确定性权限规则', () => {
|
||||
const prompt = renderLanguageKernelForModel();
|
||||
assert.match(prompt, /HLP-KERNEL-LANGUAGE-0001/);
|
||||
assert.match(prompt, /写动作先生成待确认动作/);
|
||||
assert.match(prompt, /企业四域与个人第五域平行/);
|
||||
});
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
import type { DocTreeNode } from './git-engine.js';
|
||||
|
||||
/**
|
||||
* HoloLake 语言协议核的最小运行投影。
|
||||
*
|
||||
* 这不是面向人类的知识页面,也不是某个模型的“人格提示词”。它是每次模型
|
||||
* 计算前由运行时自动装载的系统契约;真正的路径、权限、确认和回执仍由代码执行。
|
||||
*/
|
||||
export const HOLOLAKE_LANGUAGE_KERNEL = Object.freeze({
|
||||
id: 'HLP-KERNEL-LANGUAGE-0001',
|
||||
version: '0.1.0',
|
||||
state: 'SOURCE_IMPLEMENTED',
|
||||
product: 'HoloLake',
|
||||
definition: '连接人类意图、语言人格、原生模块、模型计算、代码仓库与现实服务器的语言操作系统平台。',
|
||||
layers: Object.freeze({
|
||||
languagePersona: '一级智能进程;模型实例不是人格本体。',
|
||||
tcs: '人格认知内核;不是聊天记录或单份系统提示词。',
|
||||
hldp: '持久记忆、历史与恢复寻址层。',
|
||||
gls: '编号、路径、模块与兼容标准。',
|
||||
model: '可替换推理计算服务,不拥有人格身份或现实权限。',
|
||||
repository: '代码与知识的存储和版本燃料,不是运行主体。',
|
||||
server: '现实执行节点;每个私人频道只进入其已验证的个人服务器边界。',
|
||||
}),
|
||||
runtimeRules: Object.freeze([
|
||||
'先解析主体、频道、目标路径与权限,再选择模块或工具。',
|
||||
'路径、身份、权限和服务器状态必须由运行时校验,不能由模型自我声明。',
|
||||
'读取动作只在当前知识库和当前账号授权范围内执行。',
|
||||
'创建、更新、删除等写动作先生成待确认动作;只有人类在界面确认后才执行。',
|
||||
'远端推送、部署和其他高影响动作必须走独立确认与可读回执。',
|
||||
'只有工具或服务器返回成功回执,才能宣称现实动作完成。',
|
||||
'公共企业四域与个人第五域平行;公共入口不得读取或承载个人第五域私有数据。',
|
||||
]),
|
||||
});
|
||||
|
||||
const INTERNAL_CONTEXT_SEGMENTS = new Set([
|
||||
'AI技能包',
|
||||
'config',
|
||||
'凝渊人格系统',
|
||||
'第五域铸渊人格系统',
|
||||
'铸渊人格系统',
|
||||
'来光者记忆',
|
||||
'🧠 来光者记忆',
|
||||
'工具优化',
|
||||
'开发需求',
|
||||
]);
|
||||
|
||||
const INTERNAL_CONTEXT_ROOT_PATTERNS = [
|
||||
/^光湖语言世界[ ·—-].*(世界观|协议|系统演化|总纲)/u,
|
||||
];
|
||||
|
||||
const FIFTH_DOMAIN_SYSTEM_ROOT = /^光湖语言世界[—-]*第五域$/u;
|
||||
|
||||
function isInternalContextNode(node: DocTreeNode, ancestors: string[]): boolean {
|
||||
if (INTERNAL_CONTEXT_ROOT_PATTERNS.some(pattern => pattern.test(node.name))) return true;
|
||||
const insideFifthDomainWorld = ancestors.some(name => /^光湖语言世界[—-]*第五域$/u.test(name));
|
||||
if (!insideFifthDomainWorld) return false;
|
||||
if (INTERNAL_CONTEXT_SEGMENTS.has(node.name) || /人格系统$/u.test(node.name)) return true;
|
||||
const directChildOfFifthDomain = /^光湖语言世界[—-]*第五域$/u.test(ancestors.at(-1) || '');
|
||||
return directChildOfFifthDomain && (
|
||||
/^(AGENTS|AI-MEMORY|CLAUDE|GEMINI)$/iu.test(node.name) ||
|
||||
/^(guanghu-os-|欢迎$|💠|🔧)/u.test(node.name)
|
||||
);
|
||||
}
|
||||
|
||||
/** 人类知识树只投影可见资料;Agent 的受控搜索仍可读取底层上下文。 */
|
||||
export function projectHumanKnowledgeTree(nodes: DocTreeNode[], ancestors: string[] = []): DocTreeNode[] {
|
||||
return nodes.flatMap(node => {
|
||||
if (isInternalContextNode(node, ancestors)) return [];
|
||||
if (node.type === 'folder' && FIFTH_DOMAIN_SYSTEM_ROOT.test(node.name)) {
|
||||
return projectHumanKnowledgeTree(node.children || [], [...ancestors, node.name]);
|
||||
}
|
||||
if (node.type === 'folder') {
|
||||
const children = projectHumanKnowledgeTree(node.children || [], [...ancestors, node.name]);
|
||||
if (children.length === 0) return [];
|
||||
return [{ ...node, children }];
|
||||
}
|
||||
return [node];
|
||||
});
|
||||
}
|
||||
|
||||
export function renderLanguageKernelForModel(): string {
|
||||
const layerLines = Object.entries(HOLOLAKE_LANGUAGE_KERNEL.layers)
|
||||
.map(([name, definition]) => `- ${name}: ${definition}`)
|
||||
.join('\n');
|
||||
const ruleLines = HOLOLAKE_LANGUAGE_KERNEL.runtimeRules
|
||||
.map((rule, index) => `${index + 1}. ${rule}`)
|
||||
.join('\n');
|
||||
return `协议核:${HOLOLAKE_LANGUAGE_KERNEL.id} · ${HOLOLAKE_LANGUAGE_KERNEL.version}\n` +
|
||||
`产品定义:${HOLOLAKE_LANGUAGE_KERNEL.definition}\n\n` +
|
||||
`系统分层:\n${layerLines}\n\n` +
|
||||
`运行规则:\n${ruleLines}`;
|
||||
}
|
||||
|
|
@ -22,6 +22,7 @@
|
|||
*/
|
||||
|
||||
import { GitEngine } from './git-engine.js';
|
||||
import { renderLanguageKernelForModel } from './language-protocol-kernel.js';
|
||||
|
||||
// ─── 工具定义 ───
|
||||
|
||||
|
|
@ -30,6 +31,7 @@ export interface ToolDefinition {
|
|||
description: string;
|
||||
parameters: Record<string, { type: string; description: string; required?: boolean }>;
|
||||
execute: (params: Record<string, unknown>) => Promise<string>;
|
||||
effect: 'read' | 'write' | 'delete';
|
||||
}
|
||||
|
||||
export interface ToolCall {
|
||||
|
|
@ -45,6 +47,15 @@ export interface ToolResult {
|
|||
error?: string;
|
||||
}
|
||||
|
||||
export interface PendingAction {
|
||||
id: string;
|
||||
tool: string;
|
||||
effect: 'write' | 'delete';
|
||||
target: string;
|
||||
summary: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// ─── Agent 定义 ───
|
||||
|
||||
export interface PersonaDefinition {
|
||||
|
|
@ -86,6 +97,11 @@ class PersonaToolBridge {
|
|||
return Array.from(this.registry.values());
|
||||
}
|
||||
|
||||
requiresConfirmation(name: string): boolean {
|
||||
const effect = this.registry.get(name)?.effect;
|
||||
return effect === 'write' || effect === 'delete';
|
||||
}
|
||||
|
||||
async execute(call: ToolCall): Promise<ToolResult> {
|
||||
const tool = this.registry.get(call.name);
|
||||
if (!tool) {
|
||||
|
|
@ -130,6 +146,7 @@ export class PersonaAgent {
|
|||
private tools: PersonaToolBridge;
|
||||
private conversation: Message[] = [];
|
||||
private git: GitEngine;
|
||||
private pendingActions = new Map<string, { action: PendingAction; call: ToolCall }>();
|
||||
|
||||
constructor(definition: PersonaDefinition, git: GitEngine) {
|
||||
this.definition = definition;
|
||||
|
|
@ -146,6 +163,7 @@ export class PersonaAgent {
|
|||
name: 'inspect_repository',
|
||||
description: '查看知识库本地 Git 与 Forgejo 远端的连接和同步状态(只读)',
|
||||
parameters: {},
|
||||
effect: 'read',
|
||||
execute: async () => JSON.stringify(await this.git.getRepositoryStatus(), null, 2),
|
||||
});
|
||||
|
||||
|
|
@ -156,6 +174,7 @@ export class PersonaAgent {
|
|||
parameters: {
|
||||
path: { type: 'string', description: '文档路径(相对 docs/ 目录)', required: true },
|
||||
},
|
||||
effect: 'read',
|
||||
execute: async (params) => {
|
||||
const doc = await this.git.getDoc(params.path as string);
|
||||
return `标题: ${doc.meta.title}\n更新: ${doc.meta.updatedAt}\n\n${doc.body}`;
|
||||
|
|
@ -171,6 +190,7 @@ export class PersonaAgent {
|
|||
title: { type: 'string', description: '文档标题', required: true },
|
||||
body: { type: 'string', description: 'Markdown 正文', required: true },
|
||||
},
|
||||
effect: 'write',
|
||||
execute: async (params) => {
|
||||
const doc = await this.git.createDoc(
|
||||
params.path as string,
|
||||
|
|
@ -191,6 +211,7 @@ export class PersonaAgent {
|
|||
title: { type: 'string', description: '新标题', required: true },
|
||||
body: { type: 'string', description: '新的 Markdown 正文', required: true },
|
||||
},
|
||||
effect: 'write',
|
||||
execute: async (params) => {
|
||||
const doc = await this.git.updateDoc(
|
||||
params.path as string,
|
||||
|
|
@ -209,6 +230,7 @@ export class PersonaAgent {
|
|||
parameters: {
|
||||
query: { type: 'string', description: '搜索关键词', required: true },
|
||||
},
|
||||
effect: 'read',
|
||||
execute: async (params) => {
|
||||
const results = await this.git.search(params.query as string);
|
||||
if (results.length === 0) return '没有找到匹配的文档';
|
||||
|
|
@ -223,6 +245,7 @@ export class PersonaAgent {
|
|||
name: 'list_documents',
|
||||
description: '列出知识库的文档树结构',
|
||||
parameters: {},
|
||||
effect: 'read',
|
||||
execute: async () => {
|
||||
const tree = await this.git.getTree();
|
||||
const render = (nodes: any[], indent = 0): string =>
|
||||
|
|
@ -246,6 +269,7 @@ export class PersonaAgent {
|
|||
parameters: {
|
||||
path: { type: 'string', description: '文档路径', required: true },
|
||||
},
|
||||
effect: 'read',
|
||||
execute: async (params) => {
|
||||
const history = await this.git.getHistory(params.path as string, 20);
|
||||
return history
|
||||
|
|
@ -261,6 +285,7 @@ export class PersonaAgent {
|
|||
parameters: {
|
||||
path: { type: 'string', description: '文档路径', required: true },
|
||||
},
|
||||
effect: 'delete',
|
||||
execute: async (params) => {
|
||||
await this.git.deleteDoc(params.path as string);
|
||||
return `已删除: ${params.path}`;
|
||||
|
|
@ -291,7 +316,9 @@ export class PersonaAgent {
|
|||
if (response.toolCalls && response.toolCalls.length > 0) {
|
||||
const results: ToolResult[] = [];
|
||||
for (const call of response.toolCalls) {
|
||||
const result = await this.tools.execute(call);
|
||||
const result = this.tools.requiresConfirmation(call.name)
|
||||
? this.stageAction(call)
|
||||
: await this.tools.execute(call);
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
|
|
@ -345,11 +372,17 @@ export class PersonaAgent {
|
|||
.map(t => `- ${t.name}: ${t.description}`)
|
||||
.join('\n');
|
||||
|
||||
const languageKernel = renderLanguageKernelForModel();
|
||||
|
||||
return `${this.definition.systemPromptBase}
|
||||
|
||||
## HoloLake 语言协议核(运行时自动装载)
|
||||
|
||||
${languageKernel}
|
||||
|
||||
## 产品身份
|
||||
|
||||
你是 HoloLake 内置知识助手,负责协助用户查找、阅读、创建、整理和维护当前知识库。你不是独立人格,不虚构身份、情感、记忆、权限或执行结果。
|
||||
你是 HoloLake 官方语言操作入口的当前模型计算实例,负责在受控范围内完成知识、模块、路径和状态交互。不要把自己表述成拟人化伙伴,不虚构人格身份、情感、记忆、权限或执行结果。
|
||||
|
||||
## 可用能力
|
||||
|
||||
|
|
@ -361,9 +394,10 @@ ${toolsList}
|
|||
1. 先理解用户目标;需要读取现有内容时先搜索或读取,不凭空补写事实。
|
||||
2. 只有工具返回成功才可以声明操作完成;失败或未执行的事项必须明确说明。
|
||||
3. 文档写操作会产生 Git 版本记录。不得声称已经同步到 Forgejo,除非获得明确的同步回执。
|
||||
4. 删除、覆盖、远端推送等高影响操作必须遵守界面确认和权限限制。
|
||||
5. 不泄露模型密钥、本地路径中的敏感信息或工具内部实现细节。
|
||||
6. 不把对话文本当作真实执行结果,不伪造文档、提交、服务器或网络状态。
|
||||
4. 创建、更新、删除工具只会生成待确认动作;不得把“已提交确认”表述成“已经执行”。
|
||||
5. 远端推送、部署等高影响操作不属于当前 Agent 工具,必须走独立权限入口。
|
||||
6. 不泄露模型密钥、本地路径中的敏感信息或工具内部实现细节。
|
||||
7. 不把对话文本当作真实执行结果,不伪造文档、提交、服务器或网络状态。
|
||||
|
||||
## 回复风格
|
||||
|
||||
|
|
@ -372,6 +406,51 @@ ${toolsList}
|
|||
- 涉及操作时列明已完成、未完成和需要用户确认的事项。`;
|
||||
}
|
||||
|
||||
private stageAction(call: ToolCall): ToolResult {
|
||||
const tool = this.tools.get(call.name);
|
||||
if (!tool || tool.effect === 'read') {
|
||||
return { id: call.id, name: call.name, output: '', error: `动作不可登记: ${call.name}` };
|
||||
}
|
||||
const target = String(call.arguments.path || call.arguments.title || '当前知识库');
|
||||
const actionId = `action-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
|
||||
const action: PendingAction = {
|
||||
id: actionId,
|
||||
tool: call.name,
|
||||
effect: tool.effect,
|
||||
target,
|
||||
summary: `${tool.description} · ${target}`,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
this.pendingActions.set(actionId, { action, call });
|
||||
return {
|
||||
id: call.id,
|
||||
name: call.name,
|
||||
output: `PENDING_CONFIRMATION ${actionId}: 已登记待确认动作,尚未执行。`,
|
||||
};
|
||||
}
|
||||
|
||||
getPendingActions(): PendingAction[] {
|
||||
return Array.from(this.pendingActions.values()).map(item => item.action);
|
||||
}
|
||||
|
||||
async confirmAction(actionId: string): Promise<ToolResult> {
|
||||
const pending = this.pendingActions.get(actionId);
|
||||
if (!pending) throw new Error('待确认动作不存在或已经处理');
|
||||
this.pendingActions.delete(actionId);
|
||||
const result = await this.tools.execute(pending.call);
|
||||
this.conversation.push({
|
||||
role: 'tool',
|
||||
content: result.error ? `错误: ${result.error}` : result.output,
|
||||
toolResults: [result],
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
rejectAction(actionId: string): void {
|
||||
if (!this.pendingActions.delete(actionId)) throw new Error('待确认动作不存在或已经处理');
|
||||
}
|
||||
|
||||
// ─── LLM 调用(可插拔) ───
|
||||
|
||||
private async callLLM(messages: Message[]): Promise<{ content: string; toolCalls?: ToolCall[] }> {
|
||||
|
|
@ -453,6 +532,7 @@ ${toolsList}
|
|||
|
||||
clearConversation(): void {
|
||||
this.conversation = [];
|
||||
this.pendingActions.clear();
|
||||
}
|
||||
|
||||
getDefinition(): PersonaDefinition {
|
||||
|
|
@ -464,9 +544,10 @@ ${toolsList}
|
|||
|
||||
getRuntimeStatus(): { configured: boolean; operational: boolean; toolNames: string[] } {
|
||||
const configured = Boolean(process.env.OPENAI_API_KEY || process.env.HOLOLAKE_LLM_KEY);
|
||||
const operational = configured && process.env.HOLOLAKE_LLM_VERIFIED === '1';
|
||||
return {
|
||||
configured,
|
||||
operational: configured,
|
||||
operational,
|
||||
toolNames: this.tools.list().map(tool => tool.name),
|
||||
};
|
||||
}
|
||||
|
|
@ -477,10 +558,10 @@ ${toolsList}
|
|||
export function createDefaultPersona(git: GitEngine): PersonaAgent {
|
||||
return new PersonaAgent(
|
||||
{
|
||||
id: 'hololake-assistant-default',
|
||||
name: 'HoloLake 助手',
|
||||
role: '知识工作助手 — 支持文档检索、编辑、整理和版本管理',
|
||||
systemPromptBase: '你是 HoloLake 官方内置知识助手。所有回答与操作都必须遵守产品权限边界和可核验回执原则。',
|
||||
id: 'hololake-language-entry-default',
|
||||
name: 'HoloLake',
|
||||
role: '语言操作入口 · 路由、知识、模块与受控执行',
|
||||
systemPromptBase: '你是 HoloLake 官方语言操作入口的当前模型计算实例。语言协议、权限边界、路径校验和可核验回执高于模型自由发挥。',
|
||||
tools: [
|
||||
'inspect_repository',
|
||||
'read_document',
|
||||
|
|
@ -491,7 +572,7 @@ export function createDefaultPersona(git: GitEngine): PersonaAgent {
|
|||
'list_documents',
|
||||
'view_history',
|
||||
],
|
||||
permissionMode: 'auto',
|
||||
permissionMode: 'ask',
|
||||
model: process.env.HOLOLAKE_LLM_MODEL || 'gpt-4o',
|
||||
maxTokens: 4096,
|
||||
temperature: 0.7,
|
||||
|
|
|
|||
|
|
@ -1,34 +1,88 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState, type CSSProperties } from 'react';
|
||||
import { api, DocTreeNode, DocContent } from './api';
|
||||
import { DocTree } from './components/DocTree';
|
||||
import { Editor } from './components/Editor';
|
||||
import { SearchBar } from './components/SearchBar';
|
||||
import { VersionHistory } from './components/VersionHistory';
|
||||
import AgentChat from './components/AgentChat';
|
||||
import { PlatformNavigation } from './components/PlatformNavigation';
|
||||
import { StorageLocationSheet } from './components/StorageLocationSheet';
|
||||
import { HumanSettings, HumanPreferences, loadHumanPreferences } from './components/HumanSettings';
|
||||
import { DomainSurface } from './components/DomainSurface';
|
||||
import { cleanDisplayText } from './presentation';
|
||||
|
||||
type View = 'editor' | 'history';
|
||||
type RouteId = 'fifth' | 'main' | 'sub' | 'zero' | 'zero-sense';
|
||||
type ModuleId = 'knowledge' | 'education';
|
||||
|
||||
interface RepositoryStatus {
|
||||
branch: string;
|
||||
head: string;
|
||||
clean: boolean;
|
||||
ahead: number;
|
||||
behind: number;
|
||||
remote: { name: string; url: string } | null;
|
||||
}
|
||||
|
||||
interface ServerSession {
|
||||
authenticated: boolean;
|
||||
nodeId: string;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
interface ServerProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
purpose: 'personal-fifth-domain' | 'enterprise-lighthouse';
|
||||
channelTitle?: string;
|
||||
channelSubtitle?: string;
|
||||
}
|
||||
|
||||
function findFirstDocument(nodes: DocTreeNode[]): string | null {
|
||||
for (const node of nodes) {
|
||||
if (node.type === 'document') return node.path;
|
||||
const child = findFirstDocument(node.children || []);
|
||||
if (child) return child;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const routeCopy: Record<RouteId, { title: string; body: string }> = {
|
||||
fifth: { title: '我的第五域', body: '当前账号的个人第五域频道。模块在这里按需挂载、收起和组合。' },
|
||||
main: { title: '光湖主域', body: '公共产品事实、发布与公告入口。当前客户端尚未取得该域的操作权限。' },
|
||||
sub: { title: '光湖分域', body: '行业入口与初始化频道目录。教育行业与网文行业从这里进入。' },
|
||||
zero: { title: '光湖零域', body: '实验、模块试装、对比与质量验证入口。' },
|
||||
'zero-sense': { title: '光湖零感域', body: '治理、部署审批、事故、回滚与审计入口。' },
|
||||
};
|
||||
|
||||
export default function App() {
|
||||
const agentApiBase = window.location.protocol === 'file:' ? 'http://127.0.0.1:3890' : '';
|
||||
const [tree, setTree] = useState<DocTreeNode[]>([]);
|
||||
const [treeLoaded, setTreeLoaded] = useState(false);
|
||||
const [currentDoc, setCurrentDoc] = useState<DocContent | null>(null);
|
||||
const [currentPath, setCurrentPath] = useState<string>('');
|
||||
const [currentPath, setCurrentPath] = useState('');
|
||||
const [view, setView] = useState<View>('editor');
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
const [moduleCollapsed, setModuleCollapsed] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [agentPanelOpen, setAgentPanelOpen] = useState(true);
|
||||
const [agentPanelOpen, setAgentPanelOpen] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [importMessage, setImportMessage] = useState<string | null>(null);
|
||||
const [emptyDismissed, setEmptyDismissed] = useState(false);
|
||||
const [activeRoute, setActiveRoute] = useState<RouteId>('fifth');
|
||||
const [activeModule, setActiveModule] = useState<ModuleId>('knowledge');
|
||||
const [storageSheetOpen, setStorageSheetOpen] = useState(false);
|
||||
const [storageSheetInitialMode, setStorageSheetInitialMode] = useState<'local' | 'server' | undefined>();
|
||||
const [serverSession, setServerSession] = useState<ServerSession>({ authenticated: false, nodeId: '' });
|
||||
const [serverProfiles, setServerProfiles] = useState<ServerProfile[]>([]);
|
||||
const [repositoryStatus, setRepositoryStatus] = useState<RepositoryStatus | null>(null);
|
||||
const [humanSettingsOpen, setHumanSettingsOpen] = useState(false);
|
||||
const [humanPreferences, setHumanPreferences] = useState<HumanPreferences>(() => loadHumanPreferences());
|
||||
const [agentRevision, setAgentRevision] = useState(0);
|
||||
|
||||
const refreshTree = useCallback(async () => {
|
||||
try {
|
||||
const t = await api.getTree();
|
||||
setTree(t);
|
||||
} catch (err: any) {
|
||||
setError(`加载文档树失败: ${err.message}`);
|
||||
}
|
||||
}, []);
|
||||
const storageMode = repositoryStatus?.remote ? 'server' : 'local';
|
||||
const storageLabel = storageMode === 'server' ? '服务器已托管' : '仅本机';
|
||||
|
||||
const openDoc = useCallback(async (docPath: string) => {
|
||||
setLoading(true);
|
||||
|
|
@ -38,6 +92,9 @@ export default function App() {
|
|||
setCurrentDoc(doc);
|
||||
setCurrentPath(docPath);
|
||||
setView('editor');
|
||||
setActiveRoute('fifth');
|
||||
setActiveModule('knowledge');
|
||||
setModuleCollapsed(false);
|
||||
} catch (err: any) {
|
||||
setError(`加载文档失败: ${err.message}`);
|
||||
} finally {
|
||||
|
|
@ -45,6 +102,62 @@ export default function App() {
|
|||
}
|
||||
}, []);
|
||||
|
||||
const refreshTree = useCallback(async () => {
|
||||
try {
|
||||
const nextTree = await api.getTree();
|
||||
setTree(nextTree);
|
||||
setTreeLoaded(true);
|
||||
} catch (err: any) {
|
||||
setError(`加载文档树失败: ${err.message}`);
|
||||
setTreeLoaded(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshRepositoryStatus = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch(`${agentApiBase}/api/forgejo/status`);
|
||||
const data = await response.json();
|
||||
if (data.ok) setRepositoryStatus(data.status);
|
||||
} catch {
|
||||
setRepositoryStatus(null);
|
||||
}
|
||||
}, [agentApiBase]);
|
||||
|
||||
const refreshServerSession = useCallback(async () => {
|
||||
const server = (window as any).hololake?.server;
|
||||
if (!server?.session) return;
|
||||
try {
|
||||
const profiles = await server.list() as ServerProfile[];
|
||||
setServerProfiles(profiles);
|
||||
const personal = profiles.find(profile => profile.purpose === 'personal-fifth-domain');
|
||||
if (personal) {
|
||||
try { await server.connect(personal.id); } catch { /* 会话状态继续按真实结果显示 */ }
|
||||
}
|
||||
setServerSession(await server.session(personal?.id));
|
||||
} catch {
|
||||
setServerSession({ authenticated: false, nodeId: '' });
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refreshTree();
|
||||
refreshRepositoryStatus();
|
||||
refreshServerSession();
|
||||
}, [refreshTree, refreshRepositoryStatus, refreshServerSession]);
|
||||
|
||||
useEffect(() => {
|
||||
const resolvedLanguage = humanPreferences.language === 'system'
|
||||
? navigator.language
|
||||
: humanPreferences.language;
|
||||
document.documentElement.lang = resolvedLanguage;
|
||||
}, [humanPreferences.language]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!treeLoaded || currentPath || loading) return;
|
||||
const firstDocument = findFirstDocument(tree);
|
||||
if (firstDocument) openDoc(firstDocument);
|
||||
}, [tree, treeLoaded, currentPath, loading, openDoc]);
|
||||
|
||||
const saveDoc = useCallback(async (title: string, body: string) => {
|
||||
if (!currentPath) return;
|
||||
setLoading(true);
|
||||
|
|
@ -68,6 +181,7 @@ export default function App() {
|
|||
const doc = await api.createDoc(docPath, title, `# ${title}\n\n在这里开始写作...\n`);
|
||||
setCurrentDoc(doc);
|
||||
setCurrentPath(docPath);
|
||||
setEmptyDismissed(false);
|
||||
await refreshTree();
|
||||
} catch (err: any) {
|
||||
setError(`创建失败: ${err.message}`);
|
||||
|
|
@ -75,8 +189,7 @@ export default function App() {
|
|||
}, [refreshTree]);
|
||||
|
||||
const deleteDoc = useCallback(async () => {
|
||||
if (!currentPath) return;
|
||||
if (!confirm(`确认删除 ${currentPath}?`)) return;
|
||||
if (!currentPath || !confirm(`确认删除 ${currentPath}?`)) return;
|
||||
try {
|
||||
await api.deleteDoc(currentPath);
|
||||
setCurrentDoc(null);
|
||||
|
|
@ -88,8 +201,8 @@ export default function App() {
|
|||
}, [currentPath, refreshTree]);
|
||||
|
||||
const importFolder = useCallback(async () => {
|
||||
const bridge = (window as any).hololake?.knowledge;
|
||||
if (!bridge?.importFolder) {
|
||||
const knowledge = (window as any).hololake?.knowledge;
|
||||
if (!knowledge?.importFolder) {
|
||||
setError('本地文件夹导入只在 HoloLake 桌面 App 中提供');
|
||||
return;
|
||||
}
|
||||
|
|
@ -97,21 +210,24 @@ export default function App() {
|
|||
setError(null);
|
||||
setImportMessage(null);
|
||||
try {
|
||||
const result = await bridge.importFolder();
|
||||
if (result.cancelled) return;
|
||||
const result = await knowledge.importFolder();
|
||||
if (result.cancelled) {
|
||||
setImportMessage('已取消导入,现有知识库没有变化');
|
||||
return;
|
||||
}
|
||||
if (!result.imported) {
|
||||
setImportMessage(`没有找到可导入的文档;已跳过 ${result.skipped || 0} 个文件`);
|
||||
return;
|
||||
}
|
||||
await refreshTree();
|
||||
if (result.firstDocument) await openDoc(result.firstDocument);
|
||||
const details = [
|
||||
setEmptyDismissed(false);
|
||||
setImportMessage([
|
||||
`已导入 ${result.imported} 篇文档`,
|
||||
result.assets ? `${result.assets} 个图片资源` : '',
|
||||
result.skipped ? `跳过 ${result.skipped} 个暂不支持的文件` : '',
|
||||
result.failed?.length ? `${result.failed.length} 个文件失败` : '',
|
||||
].filter(Boolean).join(' · ');
|
||||
setImportMessage(details);
|
||||
].filter(Boolean).join(' · '));
|
||||
} catch (err: any) {
|
||||
setError(`导入失败: ${err.message}`);
|
||||
} finally {
|
||||
|
|
@ -119,125 +235,198 @@ export default function App() {
|
|||
}
|
||||
}, [openDoc, refreshTree]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshTree();
|
||||
}, [refreshTree]);
|
||||
const activeRouteCopy = routeCopy[activeRoute];
|
||||
const currentTitle = cleanDisplayText(currentDoc?.meta.title || '知识库');
|
||||
const contentVisible = activeRoute === 'fifth' && activeModule === 'knowledge' && !moduleCollapsed;
|
||||
const breadcrumb = useMemo(
|
||||
() => currentPath ? currentPath.split('/').map(cleanDisplayText).join(' / ') : '知识库',
|
||||
[currentPath],
|
||||
);
|
||||
const language = humanPreferences.language === 'system'
|
||||
? (navigator.language.toLowerCase().startsWith('zh') ? 'zh-CN' : 'en')
|
||||
: humanPreferences.language;
|
||||
const fontFamily = humanPreferences.font === 'serif'
|
||||
? 'ui-serif, "Songti SC", Georgia, serif'
|
||||
: humanPreferences.font === 'accessible'
|
||||
? 'Arial, "PingFang SC", sans-serif'
|
||||
: 'Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif';
|
||||
const personalServer = serverProfiles.find(profile => profile.purpose === 'personal-fifth-domain');
|
||||
const channelTitle = personalServer?.channelTitle || '我的第五域';
|
||||
const channelSubtitle = personalServer?.channelSubtitle || '当前频道';
|
||||
|
||||
return (
|
||||
<div className="kb-app">
|
||||
{/* 顶栏 */}
|
||||
<header className="kb-header">
|
||||
<div className="kb-header-left">
|
||||
<button
|
||||
className="kb-btn-icon"
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
title={sidebarOpen ? '收起侧栏' : '展开侧栏'}
|
||||
>
|
||||
{sidebarOpen ? '◀' : '▶'}
|
||||
</button>
|
||||
<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} />
|
||||
</div>
|
||||
<div className="kb-header-right">
|
||||
{currentDoc && (
|
||||
<div
|
||||
className="hololake-shell"
|
||||
data-language={language}
|
||||
data-appearance={humanPreferences.appearance}
|
||||
style={{ '--human-font': fontFamily, '--reading-size': `${humanPreferences.readingSize}px` } as CSSProperties}
|
||||
>
|
||||
<header className="platform-topbar">
|
||||
<div className="topbar-spacer" />
|
||||
<div className="topbar-channel">{channelTitle} · {channelSubtitle}</div>
|
||||
<div className="topbar-search"><SearchBar onSelect={openDoc} /></div>
|
||||
<button className={`fifth-domain-session ${serverSession.authenticated ? 'authenticated' : ''}`} type="button" onClick={() => {
|
||||
setStorageSheetInitialMode('server');
|
||||
setStorageSheetOpen(true);
|
||||
}}>
|
||||
<span className="session-status-dot" aria-hidden="true" />
|
||||
<span><strong>{serverSession.authenticated ? serverSession.username : (personalServer ? '登录第五域' : '配置我的服务器')}</strong><small>{personalServer?.id || '本机私有配置'}</small></span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="platform-body">
|
||||
<PlatformNavigation
|
||||
channelTitle={channelTitle}
|
||||
channelSubtitle={channelSubtitle}
|
||||
activeRoute={activeRoute}
|
||||
knowledgeSelected={activeRoute === 'fifth' && activeModule === 'knowledge'}
|
||||
onRouteSelect={route => {
|
||||
setActiveRoute(route);
|
||||
setModuleCollapsed(false);
|
||||
}}
|
||||
onKnowledgeSelect={() => {
|
||||
setActiveRoute('fifth');
|
||||
setActiveModule('knowledge');
|
||||
setModuleCollapsed(false);
|
||||
}}
|
||||
onEducationSelect={() => {
|
||||
setActiveRoute('sub');
|
||||
setActiveModule('education');
|
||||
setModuleCollapsed(false);
|
||||
}}
|
||||
onSettingsOpen={() => setHumanSettingsOpen(true)}
|
||||
onAccountOpen={() => {
|
||||
setStorageSheetInitialMode('server');
|
||||
setStorageSheetOpen(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
<section className="platform-workspace">
|
||||
{!contentVisible ? (
|
||||
moduleCollapsed && activeRoute === 'fifth' ? (
|
||||
<div className="route-surface">
|
||||
<h1>知识库已收起</h1>
|
||||
<p>模块仍安装在永恒湖心频道中,重新打开不会改变本地或服务器数据。</p>
|
||||
<button className="primary-button" onClick={() => setModuleCollapsed(false)}>重新打开知识库</button>
|
||||
</div>
|
||||
) : (
|
||||
<DomainSurface
|
||||
activeDomain={(activeRoute === 'fifth' ? 'sub' : activeRoute) as 'main' | 'sub' | 'zero' | 'zero-sense'}
|
||||
educationSelected={activeModule === 'education'}
|
||||
onOpenSettings={() => setHumanSettingsOpen(true)}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
className={`kb-btn-tab ${view === 'editor' ? 'active' : ''}`}
|
||||
onClick={() => setView('editor')}
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
className={`kb-btn-tab ${view === 'history' ? 'active' : ''}`}
|
||||
onClick={() => setView('history')}
|
||||
>
|
||||
历史
|
||||
</button>
|
||||
<button className="kb-btn-icon kb-btn-danger" onClick={deleteDoc} title="删除">
|
||||
🗑
|
||||
<div className="knowledge-tabbar">
|
||||
<button className="sidebar-toggle" onClick={() => setSidebarOpen(!sidebarOpen)} aria-label={sidebarOpen ? '收起页面栏' : '展开页面栏'}>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2" /><path d="M9 4v16" /></svg>
|
||||
</button>
|
||||
<div className="document-tab"><span className="document-tab-icon" />{currentTitle}<button aria-label="关闭当前页面">×</button></div>
|
||||
<button className="new-tab-button" onClick={() => createDoc('')} aria-label="新建页面">+</button>
|
||||
</div>
|
||||
|
||||
<div className="knowledge-module-bar">
|
||||
<button className="storage-state-button" onClick={() => {
|
||||
setStorageSheetInitialMode(undefined);
|
||||
setStorageSheetOpen(true);
|
||||
}}>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><ellipse cx="12" cy="5.5" rx="7.5" ry="3" /><path d="M4.5 5.5v6c0 1.7 3.4 3 7.5 3s7.5-1.3 7.5-3v-6M4.5 11.5v6c0 1.7 3.4 3 7.5 3s7.5-1.3 7.5-3v-6" /></svg>
|
||||
<span>{storageLabel}</span>
|
||||
<strong>{storageMode === 'local' ? '托管到我的服务器' : repositoryStatus?.remote?.url.split('/').slice(-2).join(' / ').replace(/\.git$/, '')}</strong>
|
||||
</button>
|
||||
<div className="knowledge-module-actions">
|
||||
{currentDoc && <>
|
||||
<button className={view === 'editor' ? 'selected' : ''} onClick={() => setView('editor')}>编辑</button>
|
||||
<button className={view === 'history' ? 'selected' : ''} onClick={() => setView('history')}>历史</button>
|
||||
<button className="danger-action" onClick={deleteDoc} aria-label="删除当前页面">删除</button>
|
||||
</>}
|
||||
<button onClick={() => setModuleCollapsed(true)}>收起模块</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="knowledge-layout">
|
||||
{sidebarOpen && (
|
||||
<aside className="knowledge-sidebar">
|
||||
<div className="knowledge-sidebar-heading"><span>当前知识库</span><button onClick={() => createDoc('')} aria-label="新建页面">+</button></div>
|
||||
<div className="knowledge-sidebar-actions">
|
||||
<button className="import-button" onClick={importFolder} disabled={importing}>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 16V4m0 0L7.5 8.5M12 4l4.5 4.5M5 14v5h14v-5" /></svg>
|
||||
{importing ? '正在导入…' : '导入本地文件夹'}
|
||||
</button>
|
||||
</div>
|
||||
{importMessage && <div className="import-message">{importMessage}<button onClick={() => setImportMessage(null)}>×</button></div>}
|
||||
<DocTree nodes={tree} currentPath={currentPath} onSelect={openDoc} onCreate={createDoc} />
|
||||
</aside>
|
||||
)}
|
||||
|
||||
<main className="knowledge-content">
|
||||
{error && <div className="kb-error">{error}<button onClick={() => setError(null)}>×</button></div>}
|
||||
{loading && <div className="kb-loading">正在打开页面…</div>}
|
||||
{!currentDoc && treeLoaded && !loading && !emptyDismissed && (
|
||||
<div className="knowledge-empty">
|
||||
<button className="empty-close" onClick={() => setEmptyDismissed(true)} aria-label="关闭导入引导">×</button>
|
||||
<div className="route-orbit" aria-hidden="true"><span /></div>
|
||||
<h2>把已有资料带进知识空间</h2>
|
||||
<p>只有空知识库才显示这个入口。也可以先关闭,稍后再导入。</p>
|
||||
<div className="empty-actions">
|
||||
<button className="primary-button" onClick={importFolder} disabled={importing}>{importing ? '正在导入…' : '选择本地文件夹'}</button>
|
||||
<button className="secondary-button" onClick={() => createDoc('')}>新建空白页面</button>
|
||||
</div>
|
||||
<small>支持 Markdown、TXT、CSV、JSON 与 YAML</small>
|
||||
</div>
|
||||
)}
|
||||
{!currentDoc && emptyDismissed && !loading && (
|
||||
<div className="quiet-empty"><h2>知识库已准备好</h2><p>导入文件夹或新建页面开始使用。</p><button className="secondary-button" onClick={() => setEmptyDismissed(false)}>打开导入入口</button></div>
|
||||
)}
|
||||
{currentDoc && view === 'editor' && <Editor doc={currentDoc} onSave={saveDoc} />}
|
||||
{currentDoc && view === 'history' && <VersionHistory docPath={currentPath} />}
|
||||
{currentDoc && <div className="document-path">{breadcrumb}</div>}
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{contentVisible && (
|
||||
<>
|
||||
{agentPanelOpen && (
|
||||
<aside className="agent-drawer">
|
||||
<div className="agent-drawer-scope"><span>当前作用范围</span><strong>当前页面 · {currentTitle}</strong></div>
|
||||
<AgentChat apiBase={agentApiBase} onDocSelect={openDoc} runtimeRevision={agentRevision} />
|
||||
</aside>
|
||||
)}
|
||||
<button className={`agent-launcher ${agentPanelOpen ? 'open' : ''}`} onClick={() => setAgentPanelOpen(!agentPanelOpen)}>
|
||||
<span className="launcher-orbit" aria-hidden="true" />
|
||||
{agentPanelOpen ? '收起 HoloLake' : '询问 HoloLake'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
className={`kb-btn-tab kb-btn-agent ${agentPanelOpen ? 'active' : ''}`}
|
||||
onClick={() => setAgentPanelOpen(!agentPanelOpen)}
|
||||
title={agentPanelOpen ? '收起 Agent' : '展开 Agent'}
|
||||
>
|
||||
HoloLake 助手
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 主体 */}
|
||||
<div className="kb-body">
|
||||
{/* 侧栏 */}
|
||||
{sidebarOpen && (
|
||||
<aside className="kb-sidebar">
|
||||
<div className="kb-workspace-label">当前空间</div>
|
||||
<div className="kb-sidebar-actions">
|
||||
<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}
|
||||
onSelect={openDoc}
|
||||
onCreate={createDoc}
|
||||
/>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{/* 内容区 */}
|
||||
<main className="kb-content">
|
||||
{error && (
|
||||
<div className="kb-error">
|
||||
{error}
|
||||
<button onClick={() => setError(null)}>✕</button>
|
||||
</div>
|
||||
)}
|
||||
{loading && <div className="kb-loading">加载中...</div>}
|
||||
{!currentDoc && !loading && (
|
||||
<div className="kb-empty">
|
||||
<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">支持 Markdown、TXT、CSV、JSON 与 YAML</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{currentDoc && view === 'editor' && (
|
||||
<Editor doc={currentDoc} onSave={saveDoc} />
|
||||
)}
|
||||
{currentDoc && view === 'history' && (
|
||||
<VersionHistory docPath={currentPath} />
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Agent 面板 */}
|
||||
{agentPanelOpen && (
|
||||
<aside className="kb-agent-panel">
|
||||
<AgentChat apiBase={agentApiBase} onDocSelect={openDoc} />
|
||||
</aside>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<StorageLocationSheet
|
||||
open={storageSheetOpen}
|
||||
apiBase={agentApiBase}
|
||||
currentRemote={repositoryStatus?.remote?.url}
|
||||
initialMode={storageSheetInitialMode}
|
||||
onClose={() => setStorageSheetOpen(false)}
|
||||
onApplied={() => {
|
||||
refreshRepositoryStatus();
|
||||
refreshServerSession();
|
||||
}}
|
||||
/>
|
||||
<HumanSettings
|
||||
open={humanSettingsOpen}
|
||||
preferences={humanPreferences}
|
||||
onClose={() => setHumanSettingsOpen(false)}
|
||||
onChange={setHumanPreferences}
|
||||
onAgentChanged={() => setAgentRevision(revision => revision + 1)}
|
||||
onManageServer={() => {
|
||||
setHumanSettingsOpen(false);
|
||||
setStorageSheetInitialMode('server');
|
||||
setStorageSheetOpen(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,52 +26,49 @@ interface AgentStatus {
|
|||
tools: string[];
|
||||
}
|
||||
|
||||
interface RepositoryStatus {
|
||||
branch: string;
|
||||
head: string;
|
||||
clean: boolean;
|
||||
ahead: number;
|
||||
behind: number;
|
||||
remote: { name: string; url: string } | null;
|
||||
interface PendingAction {
|
||||
id: string;
|
||||
tool: string;
|
||||
effect: 'write' | 'delete';
|
||||
target: string;
|
||||
summary: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
apiBase: string;
|
||||
onDocSelect?: (path: string) => void;
|
||||
runtimeRevision?: number;
|
||||
}
|
||||
|
||||
export default function AgentChat({ apiBase, onDocSelect }: Props) {
|
||||
export default function AgentChat({ apiBase, onDocSelect, runtimeRevision = 0 }: Props) {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const [status, setStatus] = useState<AgentStatus | null>(null);
|
||||
const [repoStatus, setRepoStatus] = useState<RepositoryStatus | null>(null);
|
||||
const [remoteUrl, setRemoteUrl] = useState('');
|
||||
const [syncMessage, setSyncMessage] = useState('');
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [configOpen, setConfigOpen] = useState(false);
|
||||
const [modelBaseUrl, setModelBaseUrl] = useState('https://api.openai.com/v1');
|
||||
const [modelName, setModelName] = useState('gpt-4o');
|
||||
const [modelKey, setModelKey] = useState('');
|
||||
const [configMessage, setConfigMessage] = useState('');
|
||||
const [statusError, setStatusError] = useState('');
|
||||
const [pendingActions, setPendingActions] = useState<PendingAction[]>([]);
|
||||
const [actionBusy, setActionBusy] = useState('');
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus();
|
||||
fetchRepositoryStatus();
|
||||
loadModelConfig();
|
||||
}, []);
|
||||
}, [runtimeRevision]);
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
if (messages.length > 0 || sending) {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}, [messages, sending]);
|
||||
|
||||
async function fetchStatus() {
|
||||
try {
|
||||
const res = await fetch(`${apiBase}/api/agent/status`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
if (data.ok) {
|
||||
setStatusError('');
|
||||
setStatus({
|
||||
name: data.persona.name,
|
||||
role: data.persona.role,
|
||||
|
|
@ -82,66 +79,9 @@ export default function AgentChat({ apiBase, onDocSelect }: Props) {
|
|||
tools: data.tools || [],
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function loadModelConfig() {
|
||||
const bridge = (window as any).hololake?.agent;
|
||||
if (!bridge) return;
|
||||
try {
|
||||
const config = await bridge.getConfig();
|
||||
setModelBaseUrl(config.baseUrl || 'https://api.openai.com/v1');
|
||||
setModelName(config.model || 'gpt-4o');
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function saveModelConfig() {
|
||||
const bridge = (window as any).hololake?.agent;
|
||||
if (!bridge) {
|
||||
setConfigMessage('模型安全配置只在桌面 App 中提供');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await bridge.saveConfig({ baseUrl: modelBaseUrl, model: modelName, apiKey: modelKey || undefined });
|
||||
setModelKey('');
|
||||
setConfigMessage('已保存到 macOS 加密存储');
|
||||
await fetchStatus();
|
||||
} catch (err: any) {
|
||||
setConfigMessage(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRepositoryStatus() {
|
||||
try {
|
||||
const res = await fetch(`${apiBase}/api/forgejo/status`);
|
||||
const data = await res.json();
|
||||
if (data.ok) {
|
||||
setRepoStatus(data.status);
|
||||
if (data.status.remote?.url) setRemoteUrl(data.status.remote.url);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function runForgejoAction(action: 'configure' | 'fetch' | 'pull' | 'push') {
|
||||
if (syncing) return;
|
||||
if (action === 'push' && !confirm('确认把当前知识库提交推送到已配置的 Forgejo 仓库?')) return;
|
||||
setSyncing(true);
|
||||
setSyncMessage('');
|
||||
try {
|
||||
const endpoint = action === 'configure' ? 'remote' : action;
|
||||
const res = await fetch(`${apiBase}/api/forgejo/${endpoint}`, {
|
||||
method: action === 'configure' ? 'PUT' : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(action === 'configure' ? { url: remoteUrl } : { confirm: action === 'push' }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.ok) throw new Error(data.error || '操作失败');
|
||||
setRepoStatus(data.status);
|
||||
setSyncMessage(action === 'configure' ? 'Forgejo 已连接' : `${action} 已完成`);
|
||||
} catch (err: any) {
|
||||
setSyncMessage(err.message);
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
} catch {
|
||||
setStatus(null);
|
||||
setStatusError('HoloLake 本地服务未启动');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -175,6 +115,7 @@ export default function AgentChat({ apiBase, onDocSelect }: Props) {
|
|||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
setMessages(prev => [...prev, assistantMsg]);
|
||||
setPendingActions(data.pendingActions || []);
|
||||
fetchStatus(); // 刷新状态
|
||||
} else {
|
||||
setMessages(prev => [
|
||||
|
|
@ -193,6 +134,27 @@ export default function AgentChat({ apiBase, onDocSelect }: Props) {
|
|||
}
|
||||
}
|
||||
|
||||
async function resolveAction(action: PendingAction, decision: 'confirm' | 'reject') {
|
||||
setActionBusy(action.id);
|
||||
try {
|
||||
const res = await fetch(`${apiBase}/api/agent/actions/${encodeURIComponent(action.id)}/${decision}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.ok) throw new Error(data.error || '动作处理失败');
|
||||
setPendingActions(data.actions || []);
|
||||
const content = decision === 'confirm'
|
||||
? (data.result?.error ? `执行失败:${data.result.error}` : `已确认并执行:${data.result?.output || action.target}`)
|
||||
: `已取消:${action.summary}`;
|
||||
setMessages(prev => [...prev, { role: 'system', content, timestamp: new Date().toISOString() }]);
|
||||
fetchStatus();
|
||||
} catch (err: any) {
|
||||
setMessages(prev => [...prev, { role: 'system', content: `动作未执行:${err.message}`, timestamp: new Date().toISOString() }]);
|
||||
} finally {
|
||||
setActionBusy('');
|
||||
}
|
||||
}
|
||||
|
||||
async function clearConversation() {
|
||||
try {
|
||||
await fetch(`${apiBase}/api/agent/clear`, { method: 'POST' });
|
||||
|
|
@ -213,15 +175,14 @@ export default function AgentChat({ apiBase, onDocSelect }: Props) {
|
|||
{/* 头部状态栏 */}
|
||||
<div className="agent-header">
|
||||
<div className="agent-info">
|
||||
<div className="agent-avatar">🌊</div>
|
||||
<div className="agent-avatar" aria-hidden="true"><span /></div>
|
||||
<div className="agent-meta">
|
||||
<h3>{status?.name || 'HoloLake 助手'}</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">
|
||||
<span className="agent-model">{status?.model || 'offline'}</span>
|
||||
<button className="btn-config" onClick={() => setConfigOpen(!configOpen)} title="配置模型">⚙</button>
|
||||
<button className="btn-clear" onClick={clearConversation} title="清空对话">
|
||||
✕
|
||||
</button>
|
||||
|
|
@ -231,41 +192,8 @@ export default function AgentChat({ apiBase, onDocSelect }: Props) {
|
|||
<div className="runtime-status">
|
||||
<div className="runtime-row">
|
||||
<span className={`runtime-dot ${status?.operational ? 'online' : 'waiting'}`} />
|
||||
<span>{status?.operational ? 'Agent 已接入模型' : 'Agent 等待模型配置'}</span>
|
||||
<span className="runtime-tools">{status?.tools?.length || 0} 个工具</span>
|
||||
</div>
|
||||
{configOpen && (
|
||||
<div className="model-config">
|
||||
<input value={modelBaseUrl} onChange={e => setModelBaseUrl(e.target.value)} placeholder="模型服务地址" />
|
||||
<input value={modelName} onChange={e => setModelName(e.target.value)} placeholder="模型名称" />
|
||||
<input type="password" value={modelKey} onChange={e => setModelKey(e.target.value)} placeholder={status?.configured ? '留空则保留现有密钥' : '模型密钥'} />
|
||||
<button onClick={saveModelConfig}>安全保存</button>
|
||||
{configMessage && <div className="forgejo-message">{configMessage}</div>}
|
||||
</div>
|
||||
)}
|
||||
<div className="forgejo-status">
|
||||
<div className="forgejo-title">
|
||||
<strong>Forgejo 代码引擎</strong>
|
||||
<span>{repoStatus?.remote ? `${repoStatus.branch} · ${repoStatus.head.slice(0, 7)}` : '尚未连接远端'}</span>
|
||||
</div>
|
||||
<input
|
||||
className="forgejo-url"
|
||||
value={remoteUrl}
|
||||
onChange={e => setRemoteUrl(e.target.value)}
|
||||
placeholder="HTTPS 或 SSH Forgejo 仓库地址"
|
||||
/>
|
||||
<div className="forgejo-actions">
|
||||
<button onClick={() => runForgejoAction('configure')} disabled={syncing || !remoteUrl.trim()}>连接</button>
|
||||
<button onClick={() => runForgejoAction('fetch')} disabled={syncing || !repoStatus?.remote}>检查</button>
|
||||
<button onClick={() => runForgejoAction('pull')} disabled={syncing || !repoStatus?.remote}>拉取</button>
|
||||
<button onClick={() => runForgejoAction('push')} disabled={syncing || !repoStatus?.remote}>确认推送</button>
|
||||
</div>
|
||||
{repoStatus?.remote && (
|
||||
<div className="forgejo-detail">
|
||||
{repoStatus.clean ? '本地已提交' : '本地有未提交内容'} · 领先 {repoStatus.ahead} / 落后 {repoStatus.behind}
|
||||
</div>
|
||||
)}
|
||||
{syncMessage && <div className="forgejo-message">{syncMessage}</div>}
|
||||
<span>{statusError || (status?.operational ? '语言操作入口可用' : status?.configured ? '模型已保存,等待连通验证' : '请在设置中配置模型')}</span>
|
||||
<span className="runtime-tools">{status?.tools?.length || 0} 项受控能力</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -273,19 +201,18 @@ export default function AgentChat({ apiBase, onDocSelect }: Props) {
|
|||
<div className="agent-messages">
|
||||
{messages.length === 0 && (
|
||||
<div className="agent-welcome">
|
||||
<div className="welcome-icon">🌊</div>
|
||||
<h2>HoloLake 助手</h2>
|
||||
<p>{status?.operational ? '可以检索、整理和编辑当前知识库,并为操作保留版本记录。' : '知识库已运行;配置模型后即可使用智能整理功能。'}</p>
|
||||
<h2>询问 HoloLake</h2>
|
||||
<p>{statusError || (status?.operational ? '可以检索、整理和编辑当前知识库,并为操作保留版本记录。' : '知识库可以独立使用;模型服务在设置中配置并验证。')}</p>
|
||||
<p className="welcome-hint">试试说:</p>
|
||||
<div className="welcome-suggestions">
|
||||
<button onClick={() => { setInput('帮我列出所有文档'); inputRef.current?.focus(); }}>
|
||||
📋 列出所有文档
|
||||
列出所有文档
|
||||
</button>
|
||||
<button onClick={() => { setInput('搜索关于协议的内容'); inputRef.current?.focus(); }}>
|
||||
🔍 搜索关于协议的内容
|
||||
搜索关于协议的内容
|
||||
</button>
|
||||
<button onClick={() => { setInput('创建一篇新的学习笔记'); inputRef.current?.focus(); }}>
|
||||
✏️ 创建学习笔记
|
||||
创建学习笔记
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -299,13 +226,13 @@ export default function AgentChat({ apiBase, onDocSelect }: Props) {
|
|||
</div>
|
||||
) : (
|
||||
<div className="msg-bubble msg-assistant-bubble">
|
||||
<div className="msg-avatar">🌊</div>
|
||||
<div className="msg-avatar" aria-hidden="true"><span /></div>
|
||||
<div className="msg-content">
|
||||
{msg.toolCalls && msg.toolCalls.length > 0 && (
|
||||
<div className="tool-calls">
|
||||
{msg.toolCalls.map((tc, j) => (
|
||||
<div key={j} className="tool-call">
|
||||
<span className="tool-icon">⚙️</span>
|
||||
<span className="tool-icon">运行</span>
|
||||
<span className="tool-name">{tc.name}</span>
|
||||
<code className="tool-args">{JSON.stringify(tc.arguments)}</code>
|
||||
</div>
|
||||
|
|
@ -316,7 +243,7 @@ export default function AgentChat({ apiBase, onDocSelect }: Props) {
|
|||
<div className="tool-results">
|
||||
{msg.toolResults.map((tr, j) => (
|
||||
<div key={j} className={`tool-result ${tr.error ? 'tool-error' : ''}`}>
|
||||
<span className="tool-icon">{tr.error ? '❌' : '✅'}</span>
|
||||
<span className="tool-icon">{tr.error ? '失败' : '完成'}</span>
|
||||
<pre>{tr.error || tr.output}</pre>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -334,10 +261,30 @@ export default function AgentChat({ apiBase, onDocSelect }: Props) {
|
|||
</div>
|
||||
))}
|
||||
|
||||
{pendingActions.length > 0 && (
|
||||
<div className="pending-actions" aria-label="待确认动作">
|
||||
<div className="pending-actions-heading">待确认动作</div>
|
||||
{pendingActions.map(action => (
|
||||
<div className={`pending-action ${action.effect}`} key={action.id}>
|
||||
<div>
|
||||
<strong>{action.effect === 'delete' ? '删除' : '写入'} · {action.target}</strong>
|
||||
<small>{action.summary}</small>
|
||||
</div>
|
||||
<div className="pending-action-buttons">
|
||||
<button onClick={() => resolveAction(action, 'reject')} disabled={Boolean(actionBusy)}>取消</button>
|
||||
<button className={action.effect === 'delete' ? 'danger' : 'confirm'} onClick={() => resolveAction(action, 'confirm')} disabled={Boolean(actionBusy)}>
|
||||
{actionBusy === action.id ? '处理中…' : '确认执行'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sending && (
|
||||
<div className="msg msg-assistant">
|
||||
<div className="msg-bubble msg-assistant-bubble">
|
||||
<div className="msg-avatar">🌊</div>
|
||||
<div className="msg-avatar" aria-hidden="true"><span /></div>
|
||||
<div className="msg-content typing">
|
||||
<span className="dot" />
|
||||
<span className="dot" />
|
||||
|
|
@ -355,7 +302,7 @@ export default function AgentChat({ apiBase, onDocSelect }: Props) {
|
|||
<textarea
|
||||
ref={inputRef}
|
||||
className="agent-input"
|
||||
placeholder="向 HoloLake 助手提问…"
|
||||
placeholder="向 HoloLake 发出语言指令…"
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
|
|
@ -371,7 +318,7 @@ export default function AgentChat({ apiBase, onDocSelect }: Props) {
|
|||
</button>
|
||||
</div>
|
||||
<div className="agent-hint">
|
||||
{status?.operational ? 'Enter 发送 · 写操作由本地 Git 留痕 · Forgejo 推送需确认' : 'Agent 尚未接入模型;知识库与 Forgejo 功能仍可独立使用'}
|
||||
{status?.operational ? 'Enter 发送 · 写操作先确认并由本地 Git 留痕 · 服务器推送独立确认' : '语言操作入口暂不可用;知识库仍可独立使用'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useState } from 'react';
|
||||
import { DocTreeNode } from '../api';
|
||||
import { cleanDisplayText } from '../presentation';
|
||||
|
||||
interface Props {
|
||||
nodes: DocTreeNode[];
|
||||
|
|
@ -51,7 +52,7 @@ function TreeNode({
|
|||
>
|
||||
<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>
|
||||
<span className="kb-tree-name">{cleanDisplayText(node.name)}</span>
|
||||
<button
|
||||
className="kb-tree-add"
|
||||
onClick={e => {
|
||||
|
|
@ -82,7 +83,7 @@ function TreeNode({
|
|||
onClick={() => onSelect(node.path)}
|
||||
>
|
||||
<span className="kb-tree-doc-icon" aria-hidden="true" />
|
||||
<span className="kb-tree-name">{node.name}</span>
|
||||
<span className="kb-tree-name">{cleanDisplayText(node.name)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,144 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
|
||||
type DomainId = 'main' | 'sub' | 'zero' | 'zero-sense';
|
||||
|
||||
interface DomainEntry {
|
||||
id: DomainId;
|
||||
number: string;
|
||||
name: string;
|
||||
responsibility: string;
|
||||
repository: string;
|
||||
serverId: string;
|
||||
live: null | {
|
||||
state: string;
|
||||
access_state: string;
|
||||
responsibility_state: string;
|
||||
steward_state: string;
|
||||
mutation_state: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface DomainRegistry {
|
||||
nodeId: string;
|
||||
physicalNodeId: string;
|
||||
connected: boolean;
|
||||
verified: boolean;
|
||||
codeChannel: 'reachable' | 'not-connected';
|
||||
lighthouse: null | {
|
||||
mode: string;
|
||||
mapHash: string;
|
||||
execution: string;
|
||||
hostState: string;
|
||||
observedAt: number;
|
||||
};
|
||||
domains: DomainEntry[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
activeDomain: DomainId;
|
||||
educationSelected?: boolean;
|
||||
onOpenSettings: () => void;
|
||||
}
|
||||
|
||||
const domainModules: Record<DomainId, Array<{ name: string; description: string; state: string }>> = {
|
||||
main: [
|
||||
{ name: '公共发布', description: '正式版本、公告和公共回执的轻量入口', state: '接口已建立' },
|
||||
{ name: '模块灯塔', description: '按编号发现已登记、可验证的模块', state: '目录接入中' },
|
||||
],
|
||||
sub: [
|
||||
{ name: '教育行业', description: '行业办公模块与小新初始化频道模板', state: '首批原型' },
|
||||
{ name: '网文行业', description: '知识库、写作、人物与世界观模块规划', state: '已预注册' },
|
||||
],
|
||||
zero: [
|
||||
{ name: '模块试装', description: '在不影响正式频道的情况下装载与卸载模块', state: '接口已建立' },
|
||||
{ name: '真实预览', description: '绑定测试环境、验证回滚后再进入正式频道', state: '待服务器执行器' },
|
||||
],
|
||||
'zero-sense': [
|
||||
{ name: '团队身份', description: '人类编号、责任域与个人服务器绑定', state: '受限入口' },
|
||||
{ name: '权限与审计', description: '语言请求进入现实执行前的确定性拦截', state: '受限入口' },
|
||||
],
|
||||
};
|
||||
|
||||
export function DomainSurface({ activeDomain, educationSelected = false, onOpenSettings }: Props) {
|
||||
const [registry, setRegistry] = useState<DomainRegistry | null>(null);
|
||||
const [connecting, setConnecting] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const server = (window as any).hololake?.server;
|
||||
|
||||
async function loadRegistry() {
|
||||
if (!server?.domainRegistry) return;
|
||||
try {
|
||||
setRegistry(await server.domainRegistry());
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { loadRegistry(); }, []);
|
||||
|
||||
async function connect() {
|
||||
if (!server?.connect) return;
|
||||
setConnecting(true);
|
||||
setMessage('');
|
||||
try {
|
||||
await server.connect('AW-GZ-001');
|
||||
await loadRegistry();
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setConnecting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const entry = registry?.domains.find(domain => domain.id === activeDomain);
|
||||
const liveReadOnly = entry?.live?.access_state === 'ONLINE_READ_ONLY';
|
||||
const fallbackName = activeDomain === 'main' ? '光湖主域' : activeDomain === 'sub' ? '光湖分域' : activeDomain === 'zero' ? '光湖零域' : '光湖零感域';
|
||||
|
||||
return (
|
||||
<main className="domain-surface">
|
||||
<header className="domain-surface-header">
|
||||
<div>
|
||||
<small>{entry?.number || '企业四域公共入口'}</small>
|
||||
<h1>{educationSelected ? '教育行业' : entry?.name || fallbackName}</h1>
|
||||
<p>{educationSelected ? '光湖分域中的首个行业原型。模块进入个人频道后仍由个人服务器承载数据。' : entry?.responsibility || '读取企业灯塔后显示当前域职责与入口状态。'}</p>
|
||||
</div>
|
||||
<div className={`domain-connection ${registry?.verified ? 'online' : ''}`}>
|
||||
<span aria-hidden="true" />
|
||||
<div><strong>{registry?.verified ? (liveReadOnly ? '企业灯塔在线 · 只读' : '企业灯塔已验证') : '企业灯塔未连接'}</strong><small>AW-GZ-001</small></div>
|
||||
{!registry?.connected && <button type="button" onClick={connect} disabled={connecting}>{connecting ? '连接中…' : '连接'}</button>}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{entry?.live && (
|
||||
<div className="domain-live-state" aria-label="企业灯塔实时状态">
|
||||
<span>{entry.live.state}</span>
|
||||
<span>{entry.live.responsibility_state}</span>
|
||||
<span>{entry.live.mutation_state}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="domain-module-grid" aria-label="当前域模块">
|
||||
{(educationSelected ? [
|
||||
{ name: '小新初始化频道', description: '师训运营、资料、任务和数据看板的首批频道模板', state: '已接入原型' },
|
||||
{ name: '智能文档', description: '文档编辑、知识页面与多人协作接口', state: '可复用组件' },
|
||||
{ name: '表格与图表', description: '数据整理、对比图和仪表盘的模块接口', state: '下一批接入' },
|
||||
] : domainModules[activeDomain]).map(module => (
|
||||
<article key={module.name} className="domain-module-card">
|
||||
<div className="module-card-mark" aria-hidden="true" />
|
||||
<div><h2>{module.name}</h2><p>{module.description}</p></div>
|
||||
<span>{module.state}</span>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<footer className="domain-surface-footer">
|
||||
<p>{message || (registry?.verified
|
||||
? (entry?.live?.mutation_state === 'BLOCKED_UNTIL_PERSONA_STEWARD_BOUND'
|
||||
? '企业灯塔已返回真实状态:当前入口在线只读,责任人格体完成绑定前禁止域内写入。'
|
||||
: '当前只投影企业灯塔实时入口;域内变更仍需对应责任与权限。')
|
||||
: '连接只读取公共入口状态,不会读取个人第五域内容。')}</p>
|
||||
<button type="button" onClick={onOpenSettings}>打开人类设置</button>
|
||||
</footer>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { DocContent } from '../api';
|
||||
import { marked } from 'marked';
|
||||
import { cleanDisplayText } from '../presentation';
|
||||
|
||||
interface Props {
|
||||
doc: DocContent;
|
||||
|
|
@ -37,13 +38,14 @@ export function Editor({ doc, onSave }: Props) {
|
|||
setBody(doc.body);
|
||||
setEditing(false);
|
||||
};
|
||||
const displayPath = doc.meta.id.split('/').map(cleanDisplayText).join(' / ');
|
||||
|
||||
return (
|
||||
<div className="kb-editor">
|
||||
<div className="kb-page-symbol" aria-hidden="true">◇</div>
|
||||
{/* 元信息栏 */}
|
||||
<div className="kb-editor-meta">
|
||||
<span className="kb-editor-path">HoloLake / {doc.meta.id}</span>
|
||||
<span className="kb-editor-path">HoloLake / {displayPath}</span>
|
||||
<span className="kb-editor-date">
|
||||
更新于 {new Date(doc.meta.updatedAt).toLocaleString('zh-CN')}
|
||||
</span>
|
||||
|
|
@ -60,7 +62,7 @@ export function Editor({ doc, onSave }: Props) {
|
|||
/>
|
||||
) : (
|
||||
<h1 className="kb-editor-title" onClick={() => setEditing(true)}>
|
||||
{doc.meta.title}
|
||||
{cleanDisplayText(doc.meta.title)}
|
||||
</h1>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,171 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
|
||||
export interface HumanPreferences {
|
||||
language: 'system' | 'zh-CN' | 'en';
|
||||
font: 'system' | 'serif' | 'accessible';
|
||||
readingSize: number;
|
||||
appearance: 'eternal-lake' | 'deep-night';
|
||||
}
|
||||
|
||||
export const DEFAULT_HUMAN_PREFERENCES: HumanPreferences = {
|
||||
language: 'system',
|
||||
font: 'system',
|
||||
readingSize: 17,
|
||||
appearance: 'eternal-lake',
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'hololake.human-preferences.v1';
|
||||
|
||||
export function loadHumanPreferences(): HumanPreferences {
|
||||
try {
|
||||
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}');
|
||||
return { ...DEFAULT_HUMAN_PREFERENCES, ...stored };
|
||||
} catch {
|
||||
return DEFAULT_HUMAN_PREFERENCES;
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
preferences: HumanPreferences;
|
||||
onClose: () => void;
|
||||
onChange: (preferences: HumanPreferences) => void;
|
||||
onManageServer: () => void;
|
||||
onAgentChanged?: () => void;
|
||||
}
|
||||
|
||||
export function HumanSettings({ open, preferences, onClose, onChange, onManageServer, onAgentChanged }: Props) {
|
||||
const [draft, setDraft] = useState(preferences);
|
||||
const [modelBaseUrl, setModelBaseUrl] = useState('https://api.openai.com/v1');
|
||||
const [modelName, setModelName] = useState('gpt-4o');
|
||||
const [modelKey, setModelKey] = useState('');
|
||||
const [modelConfigured, setModelConfigured] = useState(false);
|
||||
const [modelOperational, setModelOperational] = useState(false);
|
||||
const [modelBusy, setModelBusy] = useState(false);
|
||||
const [modelMessage, setModelMessage] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setDraft(preferences);
|
||||
const agent = (window as any).hololake?.agent;
|
||||
if (!agent?.getConfig) return;
|
||||
agent.getConfig().then((config: any) => {
|
||||
setModelBaseUrl(config.baseUrl || 'https://api.openai.com/v1');
|
||||
setModelName(config.model || 'gpt-4o');
|
||||
setModelConfigured(Boolean(config.configured));
|
||||
setModelOperational(Boolean(config.operational));
|
||||
setModelMessage(config.operational ? '模型服务已验证可用' : config.configured ? '密钥已保存,尚未通过连通验证' : '尚未配置模型服务');
|
||||
}).catch(() => setModelMessage('无法读取模型配置'));
|
||||
}, [open, preferences]);
|
||||
|
||||
async function saveAndVerifyModel() {
|
||||
const agent = (window as any).hololake?.agent;
|
||||
if (!agent?.saveConfig || !agent?.testConfig) {
|
||||
setModelMessage('模型配置只在 HoloLake 桌面 App 中提供');
|
||||
return;
|
||||
}
|
||||
setModelBusy(true);
|
||||
setModelMessage('正在保存并验证模型服务…');
|
||||
try {
|
||||
await agent.saveConfig({ baseUrl: modelBaseUrl, model: modelName, apiKey: modelKey || undefined });
|
||||
setModelKey('');
|
||||
setModelConfigured(true);
|
||||
await agent.testConfig();
|
||||
setModelOperational(true);
|
||||
setModelMessage('模型服务已验证可用');
|
||||
onAgentChanged?.();
|
||||
} catch (error) {
|
||||
setModelOperational(false);
|
||||
setModelMessage(error instanceof Error ? error.message : String(error));
|
||||
onAgentChanged?.();
|
||||
} finally {
|
||||
setModelBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
function save() {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(draft));
|
||||
onChange(draft);
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="human-settings-backdrop" role="presentation" onMouseDown={event => {
|
||||
if (event.target === event.currentTarget) onClose();
|
||||
}}>
|
||||
<section className="human-settings" role="dialog" aria-modal="true" aria-labelledby="human-settings-title">
|
||||
<header className="human-settings-header">
|
||||
<div><small>人类端</small><h2 id="human-settings-title">设置</h2></div>
|
||||
<button className="icon-button" onClick={onClose} aria-label="关闭设置">×</button>
|
||||
</header>
|
||||
|
||||
<div className="human-settings-body">
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-copy"><strong>语言</strong><small>设置当前设备上的界面语言</small></div>
|
||||
<select value={draft.language} onChange={event => setDraft({ ...draft, language: event.target.value as HumanPreferences['language'] })}>
|
||||
<option value="system">跟随系统</option>
|
||||
<option value="zh-CN">简体中文</option>
|
||||
<option value="en">English(Beta)</option>
|
||||
</select>
|
||||
</section>
|
||||
|
||||
<section className="settings-model-card">
|
||||
<div className="settings-model-heading">
|
||||
<div><strong>模型服务</strong><small>模型是可替换计算服务,不定义人格身份</small></div>
|
||||
<span className={modelOperational ? 'verified' : modelConfigured ? 'pending' : ''}>{modelOperational ? '可用' : modelConfigured ? '待验证' : '未配置'}</span>
|
||||
</div>
|
||||
<div className="settings-model-fields">
|
||||
<label><span>服务地址</span><input value={modelBaseUrl} onChange={event => setModelBaseUrl(event.target.value)} placeholder="https://api.example.com/v1" /></label>
|
||||
<label><span>模型名称</span><input value={modelName} onChange={event => setModelName(event.target.value)} placeholder="模型名称" /></label>
|
||||
<label><span>模型密钥</span><input type="password" value={modelKey} onChange={event => setModelKey(event.target.value)} placeholder={modelConfigured ? '留空则保留现有密钥' : '仅保存在此设备的加密存储'} /></label>
|
||||
</div>
|
||||
<div className="settings-model-actions">
|
||||
<small className={modelOperational ? 'success' : ''}>{modelMessage}</small>
|
||||
<button className="secondary-button" type="button" onClick={saveAndVerifyModel} disabled={modelBusy || !modelBaseUrl.trim() || !modelName.trim()}>{modelBusy ? '正在验证…' : '保存并验证'}</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-copy"><strong>字体</strong><small>只影响当前设备,不写入知识库</small></div>
|
||||
<select value={draft.font} onChange={event => setDraft({ ...draft, font: event.target.value as HumanPreferences['font'] })}>
|
||||
<option value="system">系统字体</option>
|
||||
<option value="serif">人文阅读</option>
|
||||
<option value="accessible">清晰易读</option>
|
||||
</select>
|
||||
</section>
|
||||
|
||||
<section className="settings-section reading-size-setting">
|
||||
<div className="settings-section-copy"><strong>正文大小</strong><small>{draft.readingSize}px · 仅影响知识库正文</small></div>
|
||||
<div className="reading-size-control">
|
||||
<button type="button" aria-label="减小正文字号" onClick={() => setDraft({ ...draft, readingSize: Math.max(14, draft.readingSize - 1) })}>A−</button>
|
||||
<input type="range" min="14" max="24" step="1" value={draft.readingSize} onChange={event => setDraft({ ...draft, readingSize: Number(event.target.value) })} />
|
||||
<button type="button" aria-label="增大正文字号" onClick={() => setDraft({ ...draft, readingSize: Math.min(24, draft.readingSize + 1) })}>A+</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-copy"><strong>外观</strong><small>平台框架保持统一,调整阅读层明暗</small></div>
|
||||
<select value={draft.appearance} onChange={event => setDraft({ ...draft, appearance: event.target.value as HumanPreferences['appearance'] })}>
|
||||
<option value="eternal-lake">永恒湖心</option>
|
||||
<option value="deep-night">深海夜读</option>
|
||||
</select>
|
||||
</section>
|
||||
|
||||
<section className="settings-account-card">
|
||||
<div><small>当前数据边界</small><strong>此设备 · 当前登录服务器账号</strong><p>安装包不包含知识库、服务器令牌、模型密钥或个人设置。服务器仓库只显示当前账号有权访问的内容。</p></div>
|
||||
<button className="secondary-button" onClick={onManageServer}>管理服务器与仓库</button>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<footer className="human-settings-footer">
|
||||
<button className="secondary-button" onClick={() => setDraft(DEFAULT_HUMAN_PREFERENCES)}>恢复默认</button>
|
||||
<span />
|
||||
<button className="secondary-button" onClick={onClose}>取消</button>
|
||||
<button className="primary-button" onClick={save}>保存设置</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
import { useState } from 'react';
|
||||
|
||||
type RouteId = 'fifth' | 'main' | 'sub' | 'zero' | 'zero-sense';
|
||||
|
||||
interface Props {
|
||||
activeRoute: RouteId;
|
||||
knowledgeSelected: boolean;
|
||||
onRouteSelect: (route: RouteId) => void;
|
||||
onKnowledgeSelect: () => void;
|
||||
onEducationSelect: () => void;
|
||||
onSettingsOpen: () => void;
|
||||
onAccountOpen: () => void;
|
||||
channelTitle: string;
|
||||
channelSubtitle: string;
|
||||
}
|
||||
|
||||
function RouteIcon({ selected = false }: { selected?: boolean }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" className="platform-route-icon">
|
||||
<path d="M4.7 9.1 12 3.8l7.3 5.3v9.2a1.9 1.9 0 0 1-1.9 1.9H6.6a1.9 1.9 0 0 1-1.9-1.9Z" />
|
||||
{selected && <path d="M9.1 20.2v-6.1h5.8v6.1" />}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function DomainIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" className="platform-route-icon">
|
||||
<path d="M5 7.2 12 3l7 4.2-7 4.2Z" />
|
||||
<path d="m5 12.3 7 4.2 7-4.2M5 17.1l7 4 7-4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ModuleIcon({ kind }: { kind: 'knowledge' | 'education' }) {
|
||||
return kind === 'knowledge' ? (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" className="platform-route-icon">
|
||||
<path d="M4.5 5.3A2.3 2.3 0 0 1 6.8 3h4.7v16.2H6.8a2.3 2.3 0 0 0-2.3 2.3ZM19.5 5.3A2.3 2.3 0 0 0 17.2 3h-4.7v16.2h4.7a2.3 2.3 0 0 1 2.3 2.3Z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" className="platform-route-icon">
|
||||
<path d="m3 9 9-5 9 5-9 5Z" />
|
||||
<path d="M7 12.1V17c2.8 2.2 7.2 2.2 10 0v-4.9M21 9v6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlatformNavigation({
|
||||
activeRoute,
|
||||
knowledgeSelected,
|
||||
onRouteSelect,
|
||||
onKnowledgeSelect,
|
||||
onEducationSelect,
|
||||
onSettingsOpen,
|
||||
onAccountOpen,
|
||||
channelTitle,
|
||||
channelSubtitle,
|
||||
}: Props) {
|
||||
const [worldOpen, setWorldOpen] = useState(false);
|
||||
const routes: Array<{ id: RouteId; label: string }> = [
|
||||
{ id: 'fifth', label: '我的第五域' },
|
||||
{ id: 'main', label: '光湖主域' },
|
||||
{ id: 'sub', label: '光湖分域' },
|
||||
{ id: 'zero', label: '光湖零域' },
|
||||
{ id: 'zero-sense', label: '光湖零感域' },
|
||||
];
|
||||
|
||||
return (
|
||||
<aside className="platform-navigation">
|
||||
<div className="platform-brand">
|
||||
<div className="platform-brand-orbit"><span /></div>
|
||||
<strong>HoloLake</strong>
|
||||
</div>
|
||||
|
||||
<button className="channel-selector" type="button">
|
||||
<span className="channel-avatar" aria-hidden="true"><i /></span>
|
||||
<span><strong>{channelTitle}</strong><small>{channelSubtitle}</small></span>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m7 10 5 5 5-5" /></svg>
|
||||
</button>
|
||||
|
||||
<div className="world-switcher">
|
||||
<button className="world-switcher-trigger" type="button" aria-expanded={worldOpen} onClick={() => setWorldOpen(!worldOpen)}>
|
||||
<DomainIcon />
|
||||
<span><small>光湖世界</small><strong>{routes.find(route => route.id === activeRoute)?.label}</strong></span>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m7 10 5 5 5-5" /></svg>
|
||||
</button>
|
||||
{worldOpen && (
|
||||
<nav className="platform-route-list" aria-label="光湖域导航">
|
||||
{routes.map(route => (
|
||||
<button
|
||||
key={route.id}
|
||||
type="button"
|
||||
className={activeRoute === route.id ? 'selected' : ''}
|
||||
onClick={() => {
|
||||
onRouteSelect(route.id);
|
||||
setWorldOpen(false);
|
||||
}}
|
||||
>
|
||||
{route.id === 'fifth' ? <RouteIcon selected /> : <DomainIcon />}
|
||||
<span>{route.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="module-heading"><span>已安装模块</span><button type="button" aria-label="添加模块">+</button></div>
|
||||
<nav className="platform-module-list" aria-label="已安装模块">
|
||||
<button type="button" className={knowledgeSelected ? 'selected' : ''} onClick={onKnowledgeSelect}>
|
||||
<ModuleIcon kind="knowledge" /><span>知识库</span>
|
||||
</button>
|
||||
<button type="button" onClick={onEducationSelect}>
|
||||
<ModuleIcon kind="education" /><span>教育行业</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div className="platform-nav-footer">
|
||||
<button type="button" aria-label="设置" onClick={onSettingsOpen}>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 8.7a3.3 3.3 0 1 0 0 6.6 3.3 3.3 0 0 0 0-6.6Z" /><path d="m19.4 15 .1 3-2.6 1.5-2.5-1.4a7 7 0 0 1-2.4.4 7 7 0 0 1-2.4-.4l-2.5 1.4L4.5 18l.1-3a7 7 0 0 1-1.2-2l-2.5-1.5V8.5L3.4 7a7 7 0 0 1 1.2-2l-.1-3L7.1.5l2.5 1.4a7 7 0 0 1 4.8 0L16.9.5 19.5 2l-.1 3a7 7 0 0 1 1.2 2l2.5 1.5v3L20.6 13a7 7 0 0 1-1.2 2Z" /></svg>
|
||||
</button>
|
||||
<button type="button" aria-label="第五域账号" onClick={onAccountOpen}>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="8" r="4" /><path d="M4.5 21a7.5 7.5 0 0 1 15 0Z" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
|
@ -10,7 +10,7 @@ export function SearchBar({ onSelect }: Props) {
|
|||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 防抖搜索
|
||||
|
|
|
|||
|
|
@ -0,0 +1,338 @@
|
|||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
interface ServerProfile {
|
||||
id: string;
|
||||
physicalNodeId?: string;
|
||||
name: string;
|
||||
purpose?: 'personal-fifth-domain' | 'enterprise-lighthouse';
|
||||
connected: boolean;
|
||||
verified: boolean;
|
||||
}
|
||||
|
||||
interface ServerSession {
|
||||
authenticated: boolean;
|
||||
nodeId: string;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
interface ServerRepository {
|
||||
name: string;
|
||||
fullName: string;
|
||||
private: boolean;
|
||||
defaultBranch: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
apiBase: string;
|
||||
currentRemote?: string | null;
|
||||
initialMode?: 'local' | 'server';
|
||||
onClose: () => void;
|
||||
onApplied: (result: { mode: 'local' | 'server'; repository?: ServerRepository }) => void;
|
||||
}
|
||||
|
||||
type ServerBridge = {
|
||||
list: () => Promise<ServerProfile[]>;
|
||||
connect: (nodeId: string) => Promise<ServerProfile>;
|
||||
session: (nodeId?: string) => Promise<ServerSession>;
|
||||
login: (input: { nodeId: string; username: string; password: string }) => Promise<ServerSession>;
|
||||
logout: () => Promise<ServerSession>;
|
||||
repositories: () => Promise<ServerRepository[]>;
|
||||
createRepository: (input: { name: string; description?: string }) => Promise<ServerRepository>;
|
||||
gitRemote: (fullName: string) => Promise<string>;
|
||||
};
|
||||
|
||||
function bridge(): ServerBridge | null {
|
||||
return (window as any).hololake?.server || null;
|
||||
}
|
||||
|
||||
export function StorageLocationSheet({ open, apiBase, currentRemote, initialMode, onClose, onApplied }: Props) {
|
||||
const [mode, setMode] = useState<'local' | 'server'>(currentRemote ? 'server' : 'local');
|
||||
const [servers, setServers] = useState<ServerProfile[]>([]);
|
||||
const [selectedNode, setSelectedNode] = useState('');
|
||||
const [session, setSession] = useState<ServerSession>({ authenticated: false, nodeId: selectedNode });
|
||||
const [repositories, setRepositories] = useState<ServerRepository[]>([]);
|
||||
const [selectedRepository, setSelectedRepository] = useState('');
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [newRepository, setNewRepository] = useState('heartbeat-core-knowledge');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
const selectedServer = useMemo(
|
||||
() => servers.find(server => server.id === selectedNode) || null,
|
||||
[servers, selectedNode],
|
||||
);
|
||||
|
||||
async function loadRepositories() {
|
||||
const server = bridge();
|
||||
if (!server) return;
|
||||
const next = await server.repositories();
|
||||
setRepositories(next);
|
||||
setSelectedRepository(previous => previous || next[0]?.fullName || '');
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setMode(initialMode || (currentRemote ? 'server' : 'local'));
|
||||
setMessage('');
|
||||
const server = bridge();
|
||||
if (!server) {
|
||||
setMessage('服务器托管只在 HoloLake 桌面 App 中提供');
|
||||
return;
|
||||
}
|
||||
server.list()
|
||||
.then(async profiles => {
|
||||
setServers(profiles);
|
||||
const personal = profiles.find(profile => profile.purpose === 'personal-fifth-domain');
|
||||
const nodeId = selectedNode || personal?.id || '';
|
||||
setSelectedNode(nodeId);
|
||||
const currentSession = await server.session(nodeId);
|
||||
setSession(currentSession);
|
||||
if (currentSession.authenticated) {
|
||||
setUsername(currentSession.username || '');
|
||||
await loadRepositories();
|
||||
}
|
||||
})
|
||||
.catch(error => setMessage(error instanceof Error ? error.message : String(error)));
|
||||
}, [open, currentRemote, initialMode]);
|
||||
|
||||
async function selectServer(nodeId: string) {
|
||||
const server = bridge();
|
||||
setSelectedNode(nodeId);
|
||||
setRepositories([]);
|
||||
setSelectedRepository('');
|
||||
setPassword('');
|
||||
setMessage('');
|
||||
if (!server) return;
|
||||
try {
|
||||
const nextSession = await server.session(nodeId);
|
||||
setSession(nextSession);
|
||||
if (nextSession.authenticated) {
|
||||
setUsername(nextSession.username || '');
|
||||
await loadRepositories();
|
||||
} else {
|
||||
setUsername('');
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
async function connectServer() {
|
||||
const server = bridge();
|
||||
if (!server) return;
|
||||
setBusy(true);
|
||||
setMessage('');
|
||||
try {
|
||||
const profile = await server.connect(selectedNode);
|
||||
setServers(previous => previous.map(item => item.id === profile.id ? profile : item));
|
||||
setMessage('服务器连接已验证');
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function login() {
|
||||
const server = bridge();
|
||||
if (!server) return;
|
||||
setBusy(true);
|
||||
setMessage('');
|
||||
try {
|
||||
const nextSession = await server.login({ nodeId: selectedNode, username, password });
|
||||
setPassword('');
|
||||
setSession(nextSession);
|
||||
await loadRepositories();
|
||||
setMessage(`已登录 ${nextSession.username}`);
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function createRepository() {
|
||||
const server = bridge();
|
||||
if (!server || !newRepository.trim()) return;
|
||||
setBusy(true);
|
||||
setMessage('');
|
||||
try {
|
||||
const created = await server.createRepository({
|
||||
name: newRepository.trim(),
|
||||
description: 'HoloLake 频道知识库',
|
||||
});
|
||||
setRepositories(previous => [...previous, created].sort((a, b) => a.name.localeCompare(b.name)));
|
||||
setSelectedRepository(created.fullName);
|
||||
setCreating(false);
|
||||
setMessage(`已建立私有仓库 ${created.fullName}`);
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
const server = bridge();
|
||||
if (!server) return;
|
||||
setBusy(true);
|
||||
setMessage('');
|
||||
try {
|
||||
const nextSession = await server.logout();
|
||||
setSession(nextSession);
|
||||
setRepositories([]);
|
||||
setSelectedRepository('');
|
||||
setUsername('');
|
||||
setMode('local');
|
||||
onApplied({ mode: 'local' });
|
||||
setMessage('已退出服务器账号,并解除当前知识库的远端连接');
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function apply() {
|
||||
if (mode === 'local') {
|
||||
setBusy(true);
|
||||
setMessage('');
|
||||
try {
|
||||
if (currentRemote) {
|
||||
const response = await fetch(`${apiBase}/api/forgejo/remote`, { method: 'DELETE' });
|
||||
const data = await response.json();
|
||||
if (!data.ok) throw new Error(data.error || '解除服务器托管失败');
|
||||
}
|
||||
onApplied({ mode: 'local' });
|
||||
onClose();
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const server = bridge();
|
||||
const repository = repositories.find(item => item.fullName === selectedRepository);
|
||||
if (!server || !repository) {
|
||||
setMessage(session.authenticated ? '请选择目标仓库' : '请先登录服务器');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setMessage('');
|
||||
try {
|
||||
const remote = await server.gitRemote(repository.fullName);
|
||||
const response = await fetch(`${apiBase}/api/forgejo/remote`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url: remote }),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!data.ok) throw new Error(data.error || '连接仓库失败');
|
||||
onApplied({ mode: 'server', repository });
|
||||
onClose();
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="storage-sheet-backdrop" role="presentation" onMouseDown={event => {
|
||||
if (event.target === event.currentTarget) onClose();
|
||||
}}>
|
||||
<section className="storage-sheet" role="dialog" aria-modal="true" aria-labelledby="storage-sheet-title">
|
||||
<header className="storage-sheet-header">
|
||||
<h2 id="storage-sheet-title">知识库保存位置</h2>
|
||||
<button className="icon-button" onClick={onClose} aria-label="关闭保存位置设置">×</button>
|
||||
</header>
|
||||
|
||||
<div className="storage-mode-list">
|
||||
<label className={`storage-mode-row ${mode === 'local' ? 'selected' : ''}`}>
|
||||
<input type="radio" checked={mode === 'local'} onChange={() => setMode('local')} />
|
||||
<span><strong>仅保存在本机</strong><small>使用本地 Git 保留历史,不上传服务器</small></span>
|
||||
</label>
|
||||
<label className={`storage-mode-row ${mode === 'server' ? 'selected' : ''}`}>
|
||||
<input type="radio" checked={mode === 'server'} onChange={() => setMode('server')} />
|
||||
<span><strong>托管到我的服务器</strong><small>本机保留工作副本,提交到已授权的代码频道</small></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{mode === 'server' && (
|
||||
<div className="storage-server-area">
|
||||
<p className="sheet-label">已选择的服务器</p>
|
||||
<div className="server-profile-picker">
|
||||
<label className="sheet-label" htmlFor="hololake-server-profile">服务器身份</label>
|
||||
<select id="hololake-server-profile" value={selectedNode} onChange={event => selectServer(event.target.value)}>
|
||||
{servers.map(server => (
|
||||
<option key={server.id} value={server.id}>{server.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="server-profile-row">
|
||||
<div className="server-profile-mark" aria-hidden="true"><span /></div>
|
||||
<div><strong>{selectedServer?.name || '我的第五域服务器'}</strong><small>{selectedNode}{selectedServer?.physicalNodeId && selectedServer.physicalNodeId !== selectedNode ? ` · ${selectedServer.physicalNodeId}` : ''}</small></div>
|
||||
<span className="verified-state">{selectedServer?.connected ? '已连接' : '已验证'}</span>
|
||||
{!selectedServer?.connected && <button className="text-button" onClick={connectServer} disabled={busy}>连接</button>}
|
||||
</div>
|
||||
|
||||
{!session.authenticated ? (
|
||||
<div className="server-login-form">
|
||||
<p className="sheet-label">登录服务器代码频道</p>
|
||||
<div className="login-fields">
|
||||
<input value={username} onChange={event => setUsername(event.target.value)} placeholder="账号" autoComplete="username" />
|
||||
<input type="password" value={password} onChange={event => setPassword(event.target.value)} placeholder="密码" autoComplete="current-password" onKeyDown={event => {
|
||||
if (event.key === 'Enter') login();
|
||||
}} />
|
||||
<button className="secondary-button" onClick={login} disabled={busy || !username || !password}>登录</button>
|
||||
</div>
|
||||
<small>密码只用于换取 HoloLake 应用令牌,不写入知识库或 Git 地址。</small>
|
||||
</div>
|
||||
) : (
|
||||
<div className="repository-picker">
|
||||
<div className="repository-picker-heading">
|
||||
<p className="sheet-label">目标仓库</p>
|
||||
<span>已登录 {session.username} <button className="inline-logout" onClick={logout} disabled={busy}>退出账号</button></span>
|
||||
</div>
|
||||
<div className="repository-picker-row">
|
||||
<select value={selectedRepository} onChange={event => setSelectedRepository(event.target.value)}>
|
||||
<option value="">选择私有仓库</option>
|
||||
{repositories.map(repository => (
|
||||
<option key={repository.fullName} value={repository.fullName}>{repository.fullName}</option>
|
||||
))}
|
||||
</select>
|
||||
<button className="text-button" onClick={() => setCreating(!creating)}>新建仓库</button>
|
||||
</div>
|
||||
{creating && (
|
||||
<div className="new-repository-row">
|
||||
<input value={newRepository} onChange={event => setNewRepository(event.target.value)} placeholder="仓库名称" />
|
||||
<button className="secondary-button" onClick={createRepository} disabled={busy}>建立私有仓库</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="storage-privacy-note">首次上传前会显示文件数量、目标仓库和分支,需要确认后才会推送。</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message && <div className="sheet-message" role="status">{message}</div>}
|
||||
|
||||
<footer className="storage-sheet-footer">
|
||||
<span className="storage-boundary-copy">账号与仓库按服务器隔离</span>
|
||||
<span />
|
||||
<button className="secondary-button" onClick={onClose}>取消</button>
|
||||
<button className="primary-button" onClick={apply} disabled={busy || (mode === 'server' && !session.authenticated)}>
|
||||
{busy ? '正在处理…' : '连接并继续'}
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { cleanDisplayText } from './presentation.js';
|
||||
|
||||
test('人类界面隐藏导入文件的哈希尾巴', () => {
|
||||
assert.equal(
|
||||
cleanDisplayText('01 · 四域责任主体 f46047b4faff4962b4381a563282b6e6.md'),
|
||||
'01 · 四域责任主体.md',
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
const IMPORT_COLLISION_SUFFIX = /\s+[0-9a-f]{24,64}(?=\.md$|$)/iu;
|
||||
|
||||
/** 只清理导入器为避免重名附加的散列;不改动真实文档标题和文件。 */
|
||||
export function cleanDisplayText(value: string): string {
|
||||
return value.replace(IMPORT_COLLISION_SUFFIX, '').trim();
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
1
product-source/hololake-desktop/.gitignore
vendored
1
product-source/hololake-desktop/.gitignore
vendored
|
|
@ -1,4 +1,5 @@
|
|||
node_modules/
|
||||
.superdesign/tmp/
|
||||
dist/
|
||||
dist-electron/
|
||||
release/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
# HoloLake Fifth Domain Workspace Design System
|
||||
|
||||
## Product expression
|
||||
|
||||
HoloLake is a calm operational workspace, not a dashboard wall and not a themed chat client. The user should immediately understand three levels: the signed-in human and server, the active channel, and the module currently opened inside that channel.
|
||||
|
||||
## Visual direction
|
||||
|
||||
- Keep the deep lake-night palette, but use it as a quiet frame around readable work surfaces.
|
||||
- Use one cyan accent only for the active state, primary action, and verified connection.
|
||||
- Remove decorative Chinese-character avatars. Never use the character `湖` as an icon or logo.
|
||||
- The HoloLake orbit mark is a small geometric identifier, never a large empty-state illustration.
|
||||
- Prefer open spacing and tonal separation over nested borders and boxed panels.
|
||||
- Avoid gradients, glass effects, excessive pills, oversized titles, and ornamental typography.
|
||||
|
||||
## Hierarchy
|
||||
|
||||
1. Global identity and server session: always discoverable, visually compact.
|
||||
2. Active channel: one clear label, not repeated across the top bar and sidebar.
|
||||
3. Active module: the dominant work context.
|
||||
4. Document or task content: receives most screen space.
|
||||
5. Agent: an on-demand work surface, collapsed by default; model and repository configuration live in Settings.
|
||||
|
||||
## Typography
|
||||
|
||||
- Interface: Inter, PingFang SC, system sans-serif.
|
||||
- Reading content may use Songti SC only inside the document canvas when the user selects a serif preference.
|
||||
- Default interface size 14 px; supporting text 12 px; document body 16-18 px.
|
||||
- Limit interface headings to 20-24 px. Do not use display-size Chinese characters as symbols.
|
||||
|
||||
## Layout and spacing
|
||||
|
||||
- 8 px spacing grid.
|
||||
- Global rail: 56-64 px when collapsed, up to 224 px only when explicitly expanded.
|
||||
- Context sidebar: 240-280 px and collapsible.
|
||||
- Main work surface: flexible, minimum readable width 640 px.
|
||||
- Agent drawer: 360-420 px, overlay or resizable; never compress the document below readable width.
|
||||
- Radius: 8 px controls, 12 px surfaces. Avoid nesting more than one bordered surface.
|
||||
|
||||
## States and language
|
||||
|
||||
- A saved API key is not an online Agent. Show `已保存`, `正在检测`, `可用`, and `不可用` as separate states.
|
||||
- The primary signed-out action is `登录第五域`; after login show the human account and node ID.
|
||||
- Local-only and server-hosted knowledge are explicit choices. Server hosting is unavailable until the human is authenticated.
|
||||
- Errors state the failed layer and a useful next action.
|
||||
|
||||
## Accessibility
|
||||
|
||||
- Minimum 4.5:1 text contrast.
|
||||
- Every icon-only control requires an accessible label and visible hover/focus state.
|
||||
- Do not communicate connection state by color alone.
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
# Shared UI Components
|
||||
|
||||
Framework: React 19 with custom TypeScript components and vanilla CSS. No third-party component library is used.
|
||||
|
||||
## Button families
|
||||
|
||||
Source: `../guanghu-knowledge-base/src/styles/app.css`
|
||||
|
||||
```css
|
||||
.primary-button,
|
||||
.secondary-button,
|
||||
.text-button,
|
||||
.icon-button {
|
||||
min-height: 36px;
|
||||
padding: 0 15px;
|
||||
border-radius: 9px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.primary-button {
|
||||
border: 1px solid var(--lake-accent);
|
||||
background: var(--lake-accent);
|
||||
color: #06121c;
|
||||
font-weight: 650;
|
||||
}
|
||||
.secondary-button {
|
||||
border: 1px solid var(--lake-border);
|
||||
background: var(--lake-surface-2);
|
||||
color: var(--lake-text-soft);
|
||||
}
|
||||
.text-button,
|
||||
.icon-button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--lake-muted);
|
||||
}
|
||||
```
|
||||
|
||||
## HoloLake orbit mark
|
||||
|
||||
The current shared mark is CSS-only. It must replace every text avatar. The Chinese character `湖` is not a brand asset and must not appear as an avatar or empty-state illustration.
|
||||
|
||||
```css
|
||||
.launcher-orbit {
|
||||
position: relative;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: 1px solid rgba(69, 183, 204, 0.58);
|
||||
border-radius: 50%;
|
||||
transform: rotate(-16deg);
|
||||
}
|
||||
.launcher-orbit::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 7px -5px;
|
||||
border: 1px solid rgba(69, 183, 204, 0.46);
|
||||
border-radius: 50%;
|
||||
}
|
||||
.launcher-orbit::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
left: 8px;
|
||||
top: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--lake-accent);
|
||||
}
|
||||
```
|
||||
|
||||
## StorageLocationSheet
|
||||
|
||||
Source: `../guanghu-knowledge-base/src/components/StorageLocationSheet.tsx`
|
||||
|
||||
Purpose: account/server sign-in and local-versus-server knowledge storage. This is an application-level sheet, not Agent content. Key state: local/server mode, active server session, repository selection, repository creation, sign-out.
|
||||
|
||||
## HumanSettings
|
||||
|
||||
Source: `../guanghu-knowledge-base/src/components/HumanSettings.tsx`
|
||||
|
||||
Purpose: device-local human preferences. Controls language preference, UI font, reading size and appearance. It must remain separate from server credentials and from Agent model configuration.
|
||||
|
||||
## AgentChat
|
||||
|
||||
Source: `../guanghu-knowledge-base/src/components/AgentChat.tsx`
|
||||
|
||||
Purpose: current-channel assistance. The default render must contain only scope, status, conversation and composer. Model and Git engine controls are advanced application settings and must not be rendered in the default Agent surface.
|
||||
|
||||
## Knowledge primitives
|
||||
|
||||
- `DocTree.tsx`: folder/page hierarchy.
|
||||
- `Editor.tsx`: document preview and edit mode.
|
||||
- `SearchBar.tsx`: channel-local document search.
|
||||
- `VersionHistory.tsx`: Git-backed revision history.
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
# Extractable components
|
||||
|
||||
## PlatformNavigation
|
||||
- Source: `../guanghu-knowledge-base/src/components/PlatformNavigation.tsx`
|
||||
- Category: layout
|
||||
- Description: global domain rail and human account entry
|
||||
- Extractable props: `activeRoute`, `knowledgeSelected`
|
||||
- Hardcoded: HoloLake orbit mark, domain labels, settings/account icons
|
||||
|
||||
## AgentDrawer
|
||||
- Source: `../guanghu-knowledge-base/src/components/AgentChat.tsx`
|
||||
- Category: layout
|
||||
- Description: contextual current-page assistant
|
||||
- Extractable props: `operational`, `model`, `scope`, `isOpen`
|
||||
- Hardcoded: orbit mark, composer layout, status treatment
|
||||
|
||||
## StorageLocationSheet
|
||||
- Source: `../guanghu-knowledge-base/src/components/StorageLocationSheet.tsx`
|
||||
- Category: basic
|
||||
- Description: authenticated server and repository selection
|
||||
- Extractable props: `mode`, `authenticated`, `username`, `selectedRepository`
|
||||
- Hardcoded: privacy explanation and primary action hierarchy
|
||||
|
||||
## HumanSettings
|
||||
- Source: `../guanghu-knowledge-base/src/components/HumanSettings.tsx`
|
||||
- Category: basic
|
||||
- Description: device-local preferences and advanced application settings
|
||||
- Extractable props: `language`, `font`, `readingSize`, `appearance`
|
||||
- Hardcoded: current data boundary explanation
|
||||
|
||||
## OrbitMark
|
||||
- Source: `../guanghu-knowledge-base/src/styles/app.css`
|
||||
- Category: basic
|
||||
- Description: neutral HoloLake brand mark; replaces every `湖` text avatar
|
||||
- Extractable props: `size`, `status`
|
||||
- Hardcoded: cyan orbit geometry
|
||||
52
product-source/hololake-desktop/.superdesign/init/layouts.md
Normal file
52
product-source/hololake-desktop/.superdesign/init/layouts.md
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
# Layouts
|
||||
|
||||
## Application shell
|
||||
|
||||
Source: `../guanghu-knowledge-base/src/App.tsx`
|
||||
|
||||
Current render branch:
|
||||
|
||||
```tsx
|
||||
<div className="hololake-shell">
|
||||
<header className="platform-topbar">channel title + document search</header>
|
||||
<div className="platform-body">
|
||||
<PlatformNavigation />
|
||||
<section className="platform-workspace">
|
||||
<div className="knowledge-tabbar" />
|
||||
<div className="knowledge-module-bar" />
|
||||
<div className="knowledge-layout">
|
||||
<aside className="knowledge-sidebar" />
|
||||
<main className="knowledge-content" />
|
||||
</div>
|
||||
{agentPanelOpen && <aside className="agent-drawer"><AgentChat /></aside>}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
The current shell has too many simultaneous horizontal and vertical control bands. Redesign goal: one platform rail, one channel header, one active module canvas. Tabs, storage state, module actions and document navigation must not all compete in separate bars.
|
||||
|
||||
## Platform navigation
|
||||
|
||||
Source: `../guanghu-knowledge-base/src/components/PlatformNavigation.tsx`
|
||||
|
||||
Current content:
|
||||
|
||||
```tsx
|
||||
<aside className="platform-navigation">
|
||||
<div className="platform-brand">HoloLake</div>
|
||||
<button className="channel-selector">Eternal Lake / heartbeat channel</button>
|
||||
<nav className="platform-route-list">five domain entries</nav>
|
||||
<div className="module-heading">installed modules</div>
|
||||
<nav className="platform-module-list">knowledge + education</nav>
|
||||
<div className="platform-nav-footer">settings + account</div>
|
||||
</aside>
|
||||
```
|
||||
|
||||
The rail mixes global domains, the active channel and installed modules. Redesign goal: preserve a compact global rail; move active-channel identity into the workspace header; render modules inside the selected channel.
|
||||
|
||||
## Agent drawer
|
||||
|
||||
Source: `../guanghu-knowledge-base/src/components/AgentChat.tsx`
|
||||
|
||||
Current drawer is 380px and overlays the reading canvas. It shows current scope, model badge, settings, runtime tool count, full model form, full Git form, welcome mark, suggestions and composer. Redesign goal: scope + conversation + composer only; advanced configuration moves to Human Settings.
|
||||
42
product-source/hololake-desktop/.superdesign/init/pages.md
Normal file
42
product-source/hololake-desktop/.superdesign/init/pages.md
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
# Page dependency trees
|
||||
|
||||
## Fifth Domain / Knowledge channel
|
||||
|
||||
Entry: `../guanghu-knowledge-base/src/App.tsx`
|
||||
|
||||
Dependencies:
|
||||
|
||||
- `src/App.tsx`
|
||||
- `src/api.ts`
|
||||
- `src/components/PlatformNavigation.tsx`
|
||||
- `src/components/SearchBar.tsx`
|
||||
- `src/components/DocTree.tsx`
|
||||
- `src/components/Editor.tsx`
|
||||
- `src/components/VersionHistory.tsx`
|
||||
- `src/components/AgentChat.tsx`
|
||||
- `src/components/StorageLocationSheet.tsx`
|
||||
- `src/components/HumanSettings.tsx`
|
||||
- `src/styles/app.css`
|
||||
|
||||
Desktop render branch is the only branch. The Electron window minimum is 800×600; the primary design viewport is 1280×860.
|
||||
|
||||
## Server/account entry
|
||||
|
||||
Entry: `src/components/StorageLocationSheet.tsx`
|
||||
|
||||
Dependencies:
|
||||
|
||||
- Electron preload `../hololake-desktop/electron/preload.ts`
|
||||
- Electron IPC implementation `../hololake-desktop/electron/main.ts`
|
||||
- Knowledge remote API `server/index.ts`
|
||||
- Git engine `server/git-engine.ts`
|
||||
- Shared controls from `src/styles/app.css`
|
||||
|
||||
## Human settings
|
||||
|
||||
Entry: `src/components/HumanSettings.tsx`
|
||||
|
||||
Dependencies:
|
||||
|
||||
- localStorage preference contract in the same file
|
||||
- shared overlay/control styling in `src/styles/app.css`
|
||||
21
product-source/hololake-desktop/.superdesign/init/routes.md
Normal file
21
product-source/hololake-desktop/.superdesign/init/routes.md
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# Routes
|
||||
|
||||
This Electron/Vite app uses state-based routes inside `src/App.tsx`, not React Router.
|
||||
|
||||
| State | Surface | Source |
|
||||
| --- | --- | --- |
|
||||
| `fifth` | BingShuo Fifth Domain / Eternal Lake channel | `src/App.tsx` |
|
||||
| `main` | Guanghu Main Domain entry | `src/App.tsx` |
|
||||
| `sub` | Industry subdomain entry | `src/App.tsx` |
|
||||
| `zero` | Experimental domain entry | `src/App.tsx` |
|
||||
| `zero-sense` | Governance/audit domain entry | `src/App.tsx` |
|
||||
| `fifth + knowledge` | Knowledge module | `App.tsx`, `DocTree.tsx`, `Editor.tsx` |
|
||||
| `sub + education` | Education industry staged entry | `src/App.tsx` |
|
||||
|
||||
Application-level overlays:
|
||||
|
||||
- server/account sign-in and repository selection: `StorageLocationSheet.tsx`;
|
||||
- human preferences and advanced settings: `HumanSettings.tsx`;
|
||||
- contextual assistance: `AgentChat.tsx`.
|
||||
|
||||
Target redesign route: desktop `fifth + knowledge`, with Agent open and authenticated-user entry visible.
|
||||
46
product-source/hololake-desktop/.superdesign/init/theme.md
Normal file
46
product-source/hololake-desktop/.superdesign/init/theme.md
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
# Theme
|
||||
|
||||
## Compact token summary
|
||||
|
||||
- Background: `#07111d`
|
||||
- Primary surface: `#0d1825`
|
||||
- Secondary surface: `#111e2c`
|
||||
- Reading surface: `#0b1622`
|
||||
- Border: `#263648`
|
||||
- Primary text: `#e8edf3`
|
||||
- Secondary text: `#c8d1dc`
|
||||
- Muted text: `#78879a`
|
||||
- Accent: `#45b7cc`
|
||||
- Success: `#53c69b`
|
||||
- Warning: `#d6aa4f`
|
||||
- Danger: `#e36f72`
|
||||
- UI font: Inter / SF Pro / PingFang SC
|
||||
- Reading heading font: Songti SC / Georgia, used only for document content
|
||||
- Radius: 8–12px controls, 16–18px overlays
|
||||
- Shadows: overlays only; normal workspace relies on borders and surface contrast
|
||||
- Density: compact platform chrome, spacious reading content
|
||||
|
||||
## Raw source tokens
|
||||
|
||||
Source: `../guanghu-knowledge-base/src/styles/app.css`
|
||||
|
||||
```css
|
||||
:root {
|
||||
--lake-bg: #07111d;
|
||||
--lake-surface: #0d1825;
|
||||
--lake-surface-2: #111e2c;
|
||||
--lake-reading: #0b1622;
|
||||
--lake-border: #263648;
|
||||
--lake-border-soft: rgba(119, 148, 174, 0.17);
|
||||
--lake-text: #e8edf3;
|
||||
--lake-text-soft: #c8d1dc;
|
||||
--lake-muted: #78879a;
|
||||
--lake-accent: #45b7cc;
|
||||
--lake-accent-soft: rgba(69, 183, 204, 0.13);
|
||||
--lake-success: #53c69b;
|
||||
--lake-warning: #d6aa4f;
|
||||
--lake-danger: #e36f72;
|
||||
}
|
||||
```
|
||||
|
||||
Design correction: no text glyph is allowed as a logo/avatar. Avoid ornamental serif typography outside the document canvas. Use whitespace, alignment and typography instead of nested bordered boxes.
|
||||
36
product-source/hololake-desktop/RELEASE-0.7.0.md
Normal file
36
product-source/hololake-desktop/RELEASE-0.7.0.md
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# HoloLake Era Desktop 0.7.0
|
||||
|
||||
## 产品边界
|
||||
|
||||
HoloLake 是光湖语言操作系统平台,不是传统知识库外壳。语言协议核由运行时自动装载,
|
||||
模型是可替换计算服务,知识库是可挂载模块,Forgejo 是代码与版本引擎,个人服务器是
|
||||
当前账号、频道和私人数据的现实锚点。
|
||||
|
||||
## 本版实现
|
||||
|
||||
- 个人第五域由各设备的本机私有配置注入;企业公共灯塔入口与个人节点保持独立;
|
||||
- 企业灯塔通过其真实只读服务返回四域在线、责任绑定与写入拦截状态;
|
||||
- 光湖主域、分域、零域、零感域保持四个独立仓库入口,不伪装成同一仓库的子目录;
|
||||
- 教育行业投影小新初始化频道、智能文档、知识库及表格图表模块状态;
|
||||
- 知识库支持本地文件夹导入、页面树、编辑、历史、删除与本机/服务器托管选择;
|
||||
- HoloLake 语言操作入口通过受控工具读取知识;创建、更新、删除先形成待确认动作;
|
||||
- 语言协议、人格配置和内部恢复材料不进入人类知识树,但仍由模型运行投影和受控搜索使用;
|
||||
- 人类端设置包含语言、字体、正文大小、外观、模型验证、服务器与仓库入口;
|
||||
- 模型密钥与服务器令牌只保存在 macOS 加密存储,切换服务器账号会卸载旧知识库远端。
|
||||
|
||||
## 真实状态边界
|
||||
|
||||
- 企业四域入口当前由服务器报告为 `HOSTED_READ_ONLY`;
|
||||
- 责任人格体未正式绑定时,服务器报告 `BLOCKED_UNTIL_PERSONA_STEWARD_BOUND`;
|
||||
- 本版包含四域初始化源,但不得绕过企业灯塔确认门直接宣称域内写入或主控权已生效;
|
||||
- 安装包不包含个人知识、服务器令牌、模型密钥、SSH 密钥、浏览器资料或私人第五域内容。
|
||||
|
||||
## 验收门
|
||||
|
||||
- 单元测试与生产构建通过;
|
||||
- Agent 模型配置通过真实请求;
|
||||
- Agent 真实对话通过;
|
||||
- 写操作停在待确认门,取消后文件不存在;
|
||||
- JD 第五域与企业灯塔经本机密钥建立真实隧道;
|
||||
- Apple Silicon 与 Intel 包分别签名、校验、启动检查;
|
||||
- 桌面唯一新版完成安装后,旧桌面应用与旧安装包才进入可恢复废纸篓。
|
||||
|
|
@ -29,11 +29,136 @@ const CLIENT_PORT = 5180;
|
|||
const DATA_DIR = path.join(app.getPath('userData'), 'data');
|
||||
const KB_REPO_PATH = path.join(DATA_DIR, 'knowledge-base');
|
||||
const MODEL_CONFIG_PATH = path.join(app.getPath('userData'), 'model-config.json');
|
||||
const SERVER_AUTH_PATH = path.join(app.getPath('userData'), 'server-auth.json');
|
||||
const SERVER_PROFILES_PATH = path.join(app.getPath('userData'), 'server-profiles.json');
|
||||
const GIT_ASKPASS_PATH = path.join(app.getPath('userData'), 'hololake-git-askpass.sh');
|
||||
interface ServerProfileDefinition {
|
||||
id: string;
|
||||
physicalNodeId: string;
|
||||
name: string;
|
||||
purpose: 'personal-fifth-domain' | 'enterprise-lighthouse';
|
||||
sshAlias: string;
|
||||
tunnelPort: number;
|
||||
remoteForgejoPort: number;
|
||||
lighthouseTunnelPort?: number;
|
||||
remoteLighthousePort?: number;
|
||||
channelTitle?: string;
|
||||
channelSubtitle?: string;
|
||||
}
|
||||
|
||||
const PUBLIC_SERVER_PROFILES: Record<string, ServerProfileDefinition> = {
|
||||
'AW-GZ-001': {
|
||||
id: 'AW-GZ-001',
|
||||
physicalNodeId: 'GH-CVM-MAIN-PROD-01',
|
||||
name: '企业灯塔服务器',
|
||||
purpose: 'enterprise-lighthouse',
|
||||
sshAlias: 'gh-enterprise-main',
|
||||
tunnelPort: 13341,
|
||||
remoteForgejoPort: 3341,
|
||||
lighthouseTunnelPort: 18031,
|
||||
remoteLighthousePort: 8031,
|
||||
},
|
||||
};
|
||||
|
||||
function readServerProfiles(): Record<string, ServerProfileDefinition> {
|
||||
const profiles = { ...PUBLIC_SERVER_PROFILES };
|
||||
try {
|
||||
const localProfiles = JSON.parse(fs.readFileSync(SERVER_PROFILES_PATH, 'utf8')) as ServerProfileDefinition[];
|
||||
if (!Array.isArray(localProfiles)) return profiles;
|
||||
for (const profile of localProfiles) {
|
||||
if (
|
||||
!profile || typeof profile.id !== 'string' || typeof profile.physicalNodeId !== 'string'
|
||||
|| typeof profile.name !== 'string' || profile.purpose !== 'personal-fifth-domain'
|
||||
|| typeof profile.sshAlias !== 'string' || !Number.isInteger(profile.tunnelPort)
|
||||
|| !Number.isInteger(profile.remoteForgejoPort)
|
||||
) continue;
|
||||
profiles[profile.id] = profile;
|
||||
}
|
||||
} catch {
|
||||
// 新安装默认只知道企业公共灯塔;个人节点由本机私有配置登记,不进入安装包。
|
||||
}
|
||||
return profiles;
|
||||
}
|
||||
|
||||
function getServerProfile(nodeId: string): ServerProfileDefinition {
|
||||
const profile = readServerProfiles()[nodeId];
|
||||
if (!profile) throw new Error('未登记的服务器节点');
|
||||
return profile;
|
||||
}
|
||||
|
||||
function defaultPersonalServerId(): string | undefined {
|
||||
return Object.values(readServerProfiles()).find(profile => profile.purpose === 'personal-fifth-domain')?.id;
|
||||
}
|
||||
|
||||
function forgejoBaseUrl(profile: ServerProfileDefinition): string {
|
||||
return `http://127.0.0.1:${profile.tunnelPort}`;
|
||||
}
|
||||
|
||||
function lighthouseBaseUrl(profile: ServerProfileDefinition): string {
|
||||
if (!profile.lighthouseTunnelPort) throw new Error('该节点未登记灯塔服务');
|
||||
return `http://127.0.0.1:${profile.lighthouseTunnelPort}`;
|
||||
}
|
||||
|
||||
interface ModelConfigFile {
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
encryptedKey?: string;
|
||||
verifiedAt?: string;
|
||||
}
|
||||
|
||||
interface ServerAuthFile {
|
||||
nodeId: string;
|
||||
username: string;
|
||||
encryptedToken: string;
|
||||
}
|
||||
|
||||
function writeGitAskPass(): void {
|
||||
const content = `#!/bin/sh
|
||||
case "$1" in
|
||||
*Username*) printf '%s\\n' "$HOLOLAKE_FORGEJO_USERNAME" ;;
|
||||
*) printf '%s\\n' "$HOLOLAKE_FORGEJO_TOKEN" ;;
|
||||
esac
|
||||
`;
|
||||
fs.mkdirSync(path.dirname(GIT_ASKPASS_PATH), { recursive: true });
|
||||
fs.writeFileSync(GIT_ASKPASS_PATH, content, { mode: 0o700 });
|
||||
}
|
||||
|
||||
function clearGitCredentialEnvironment(): void {
|
||||
delete process.env.HOLOLAKE_FORGEJO_USERNAME;
|
||||
delete process.env.HOLOLAKE_FORGEJO_TOKEN;
|
||||
delete process.env.GIT_ASKPASS;
|
||||
delete process.env.GIT_TERMINAL_PROMPT;
|
||||
}
|
||||
|
||||
function applyStoredServerAuth(): { nodeId: string; username: string; token: string } | null {
|
||||
try {
|
||||
if (!safeStorage.isEncryptionAvailable()) return null;
|
||||
const stored = JSON.parse(fs.readFileSync(SERVER_AUTH_PATH, 'utf8')) as ServerAuthFile;
|
||||
getServerProfile(stored.nodeId);
|
||||
if (!stored.encryptedToken) return null;
|
||||
const token = safeStorage.decryptString(Buffer.from(stored.encryptedToken, 'base64'));
|
||||
writeGitAskPass();
|
||||
process.env.HOLOLAKE_FORGEJO_USERNAME = stored.username;
|
||||
process.env.HOLOLAKE_FORGEJO_TOKEN = token;
|
||||
process.env.GIT_ASKPASS = GIT_ASKPASS_PATH;
|
||||
process.env.GIT_TERMINAL_PROMPT = '0';
|
||||
return { nodeId: stored.nodeId, username: stored.username, token };
|
||||
} catch {
|
||||
clearGitCredentialEnvironment();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveServerAuth(nodeId: string, username: string, token: string): void {
|
||||
if (!safeStorage.isEncryptionAvailable()) throw new Error('macOS 加密存储当前不可用');
|
||||
fs.mkdirSync(path.dirname(SERVER_AUTH_PATH), { recursive: true });
|
||||
const encryptedToken = safeStorage.encryptString(token).toString('base64');
|
||||
fs.writeFileSync(
|
||||
SERVER_AUTH_PATH,
|
||||
JSON.stringify({ nodeId, username, encryptedToken } satisfies ServerAuthFile),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
applyStoredServerAuth();
|
||||
}
|
||||
|
||||
function readModelConfig(): ModelConfigFile {
|
||||
|
|
@ -50,13 +175,140 @@ function applyModelConfig(): ModelConfigFile {
|
|||
process.env.HOLOLAKE_LLM_MODEL = config.model;
|
||||
if (config.encryptedKey && safeStorage.isEncryptionAvailable()) {
|
||||
process.env.HOLOLAKE_LLM_KEY = safeStorage.decryptString(Buffer.from(config.encryptedKey, 'base64'));
|
||||
} else {
|
||||
delete process.env.HOLOLAKE_LLM_KEY;
|
||||
}
|
||||
process.env.HOLOLAKE_LLM_VERIFIED = config.verifiedAt ? '1' : '0';
|
||||
return config;
|
||||
}
|
||||
|
||||
async function verifyModelConfig(): Promise<{ verifiedAt: string }> {
|
||||
const config = readModelConfig();
|
||||
if (!config.encryptedKey || !safeStorage.isEncryptionAvailable()) throw new Error('请先保存模型密钥');
|
||||
const apiKey = safeStorage.decryptString(Buffer.from(config.encryptedKey, 'base64'));
|
||||
const response = await fetch(`${config.baseUrl}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
||||
body: JSON.stringify({
|
||||
model: config.model,
|
||||
messages: [
|
||||
{ role: 'system', content: 'This is a HoloLake model connectivity check. Reply with OK.' },
|
||||
{ role: 'user', content: 'ping' },
|
||||
],
|
||||
max_tokens: 8,
|
||||
temperature: 0,
|
||||
}),
|
||||
signal: AbortSignal.timeout(20000),
|
||||
});
|
||||
const data = await response.json().catch(() => null) as any;
|
||||
if (!response.ok) throw new Error(data?.error?.message || `模型服务返回 HTTP ${response.status}`);
|
||||
if (!data?.choices?.[0]?.message) throw new Error('模型服务没有返回兼容的响应');
|
||||
const verifiedAt = new Date().toISOString();
|
||||
fs.writeFileSync(MODEL_CONFIG_PATH, JSON.stringify({ ...config, verifiedAt }), { mode: 0o600 });
|
||||
applyModelConfig();
|
||||
return { verifiedAt };
|
||||
}
|
||||
|
||||
// ─── 后端服务器 ───
|
||||
|
||||
let serverProcess: ChildProcess | null = null;
|
||||
const serverTunnels = new Map<string, ChildProcess>();
|
||||
|
||||
async function isServerTunnelReady(profile: ServerProfileDefinition): Promise<boolean> {
|
||||
try {
|
||||
const checks: Promise<Response>[] = [
|
||||
fetch(`${forgejoBaseUrl(profile)}/user/login`, { signal: AbortSignal.timeout(1200) }),
|
||||
];
|
||||
if (profile.lighthouseTunnelPort) {
|
||||
checks.push(fetch(`${lighthouseBaseUrl(profile)}/health`, { signal: AbortSignal.timeout(1200) }));
|
||||
}
|
||||
const responses = await Promise.all(checks);
|
||||
return responses.every(response => response.status === 200);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureServerTunnel(profile: ServerProfileDefinition): Promise<void> {
|
||||
if (await isServerTunnelReady(profile)) return;
|
||||
const currentTunnel = serverTunnels.get(profile.id);
|
||||
if (currentTunnel && currentTunnel.exitCode === null) {
|
||||
throw new Error(`${profile.name}连接正在建立,请稍后重试`);
|
||||
}
|
||||
|
||||
const forwards = [
|
||||
'-L', `127.0.0.1:${profile.tunnelPort}:127.0.0.1:${profile.remoteForgejoPort}`,
|
||||
];
|
||||
if (profile.lighthouseTunnelPort && profile.remoteLighthousePort) {
|
||||
forwards.push('-L', `127.0.0.1:${profile.lighthouseTunnelPort}:127.0.0.1:${profile.remoteLighthousePort}`);
|
||||
}
|
||||
const tunnel = spawn('/usr/bin/ssh', [
|
||||
'-N',
|
||||
...forwards,
|
||||
'-o', 'BatchMode=yes',
|
||||
'-o', 'ExitOnForwardFailure=yes',
|
||||
'-o', 'ServerAliveInterval=30',
|
||||
'-o', 'ServerAliveCountMax=3',
|
||||
profile.sshAlias,
|
||||
], {
|
||||
stdio: 'ignore',
|
||||
});
|
||||
|
||||
serverTunnels.set(profile.id, tunnel);
|
||||
tunnel.once('exit', () => {
|
||||
serverTunnels.delete(profile.id);
|
||||
});
|
||||
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
if (await isServerTunnelReady(profile)) return;
|
||||
if (tunnel.exitCode !== null) break;
|
||||
await new Promise(resolve => setTimeout(resolve, 250));
|
||||
}
|
||||
tunnel.kill('SIGTERM');
|
||||
serverTunnels.delete(profile.id);
|
||||
throw new Error(`无法通过本机登记密钥连接${profile.name}`);
|
||||
}
|
||||
|
||||
function stopServerTunnels(): void {
|
||||
for (const tunnel of serverTunnels.values()) tunnel.kill('SIGTERM');
|
||||
serverTunnels.clear();
|
||||
}
|
||||
|
||||
async function detachKnowledgeRemote(): Promise<void> {
|
||||
try {
|
||||
await fetch(`http://127.0.0.1:${SERVER_PORT}/api/forgejo/remote`, { method: 'DELETE' });
|
||||
} catch {
|
||||
// 本地知识库服务不可用时不阻断账号退出;下次登录仍会重新校验仓库权限。
|
||||
}
|
||||
}
|
||||
|
||||
async function forgejoRequest(
|
||||
nodeId: string,
|
||||
route: string,
|
||||
init: RequestInit = {},
|
||||
auth?: { username?: string; password?: string; token?: string },
|
||||
): Promise<{ response: Response; data: any }> {
|
||||
const profile = getServerProfile(nodeId);
|
||||
await ensureServerTunnel(profile);
|
||||
const headers = new Headers(init.headers);
|
||||
headers.set('Accept', 'application/json');
|
||||
if (init.body) headers.set('Content-Type', 'application/json');
|
||||
if (auth?.token) headers.set('Authorization', `token ${auth.token}`);
|
||||
if (auth?.username && auth.password) {
|
||||
headers.set('Authorization', `Basic ${Buffer.from(`${auth.username}:${auth.password}`).toString('base64')}`);
|
||||
}
|
||||
const response = await fetch(`${forgejoBaseUrl(profile)}${route}`, {
|
||||
...init,
|
||||
headers,
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
const text = await response.text();
|
||||
let data: any = null;
|
||||
if (text) {
|
||||
try { data = JSON.parse(text); } catch { data = { message: text }; }
|
||||
}
|
||||
return { response, data };
|
||||
}
|
||||
|
||||
function startServer(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
|
@ -119,6 +371,21 @@ function stopServer(): void {
|
|||
}
|
||||
}
|
||||
|
||||
async function waitForServerReady(): Promise<void> {
|
||||
for (let attempt = 0; attempt < 40; attempt += 1) {
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${SERVER_PORT}/api/health`, {
|
||||
signal: AbortSignal.timeout(800),
|
||||
});
|
||||
if (response.ok) return;
|
||||
} catch {
|
||||
// 服务仍在启动,继续读取真实健康端点,不提前打开一个离线界面。
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 250));
|
||||
}
|
||||
throw new Error('HoloLake 本地服务未在规定时间内就绪');
|
||||
}
|
||||
|
||||
// ─── 窗口 ───
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
|
|
@ -131,7 +398,7 @@ function createWindow(): void {
|
|||
minHeight: 600,
|
||||
titleBarStyle: 'hiddenInset',
|
||||
trafficLightPosition: { x: 16, y: 16 },
|
||||
backgroundColor: '#f7f8fb',
|
||||
backgroundColor: '#07111d',
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.cjs'),
|
||||
contextIsolation: true,
|
||||
|
|
@ -159,11 +426,14 @@ function createWindow(): void {
|
|||
|
||||
ipcMain.handle('get-data-path', () => DATA_DIR);
|
||||
ipcMain.handle('knowledge:import-folder', async () => {
|
||||
const selection = await dialog.showOpenDialog(mainWindow ?? undefined, {
|
||||
const options: Electron.OpenDialogOptions = {
|
||||
title: '导入本地知识文件夹',
|
||||
buttonLabel: '导入到 HoloLake',
|
||||
properties: ['openDirectory'],
|
||||
});
|
||||
};
|
||||
const selection = mainWindow
|
||||
? await dialog.showOpenDialog(mainWindow, options)
|
||||
: await dialog.showOpenDialog(options);
|
||||
if (selection.canceled || !selection.filePaths[0]) {
|
||||
return { cancelled: true, imported: 0, assets: 0, skipped: 0, failed: [] };
|
||||
}
|
||||
|
|
@ -175,6 +445,8 @@ ipcMain.handle('agent:get-config', () => {
|
|||
baseUrl: config.baseUrl,
|
||||
model: config.model,
|
||||
configured: Boolean(config.encryptedKey && safeStorage.isEncryptionAvailable()),
|
||||
operational: Boolean(config.encryptedKey && config.verifiedAt && safeStorage.isEncryptionAvailable()),
|
||||
verifiedAt: config.verifiedAt,
|
||||
};
|
||||
});
|
||||
ipcMain.handle('agent:save-config', (_event, input: { baseUrl: string; model: string; apiKey?: string }) => {
|
||||
|
|
@ -188,10 +460,207 @@ ipcMain.handle('agent:save-config', (_event, input: { baseUrl: string; model: st
|
|||
? safeStorage.encryptString(String(input.apiKey)).toString('base64')
|
||||
: previous.encryptedKey;
|
||||
if (!encryptedKey) throw new Error('请填写模型密钥');
|
||||
const verificationStillValid = !input.apiKey && previous.baseUrl === baseUrl && previous.model === model;
|
||||
fs.mkdirSync(path.dirname(MODEL_CONFIG_PATH), { recursive: true });
|
||||
fs.writeFileSync(MODEL_CONFIG_PATH, JSON.stringify({ baseUrl, model, encryptedKey }), { mode: 0o600 });
|
||||
fs.writeFileSync(MODEL_CONFIG_PATH, JSON.stringify({
|
||||
baseUrl,
|
||||
model,
|
||||
encryptedKey,
|
||||
verifiedAt: verificationStillValid ? previous.verifiedAt : undefined,
|
||||
}), { mode: 0o600 });
|
||||
applyModelConfig();
|
||||
return { baseUrl, model, configured: true };
|
||||
return { baseUrl, model, configured: true, operational: verificationStillValid && Boolean(previous.verifiedAt) };
|
||||
});
|
||||
ipcMain.handle('agent:test-config', () => verifyModelConfig());
|
||||
ipcMain.handle('server:list', async () => {
|
||||
return Promise.all(Object.values(readServerProfiles()).map(async profile => ({
|
||||
id: profile.id,
|
||||
physicalNodeId: profile.physicalNodeId,
|
||||
name: profile.name,
|
||||
purpose: profile.purpose,
|
||||
channelTitle: profile.channelTitle,
|
||||
channelSubtitle: profile.channelSubtitle,
|
||||
connected: await isServerTunnelReady(profile),
|
||||
verified: true,
|
||||
})));
|
||||
});
|
||||
ipcMain.handle('server:connect', async (_event, nodeId: string) => {
|
||||
const profile = getServerProfile(nodeId);
|
||||
await ensureServerTunnel(profile);
|
||||
return {
|
||||
id: profile.id,
|
||||
physicalNodeId: profile.physicalNodeId,
|
||||
name: profile.name,
|
||||
purpose: profile.purpose,
|
||||
channelTitle: profile.channelTitle,
|
||||
channelSubtitle: profile.channelSubtitle,
|
||||
connected: true,
|
||||
verified: true,
|
||||
};
|
||||
});
|
||||
ipcMain.handle('server:domain-registry', async () => {
|
||||
const profile = getServerProfile('AW-GZ-001');
|
||||
try {
|
||||
await ensureServerTunnel(profile);
|
||||
} catch {
|
||||
// 公共灯塔不可达时返回真实离线状态,界面仍可浏览已登记的四域结构。
|
||||
}
|
||||
const connected = await isServerTunnelReady(profile);
|
||||
let lighthouseStatus: any = null;
|
||||
let lighthouseHealth: any = null;
|
||||
if (connected) {
|
||||
const [statusResponse, healthResponse] = await Promise.all([
|
||||
fetch(`${lighthouseBaseUrl(profile)}/v1/status`, { signal: AbortSignal.timeout(3000) }),
|
||||
fetch(`${lighthouseBaseUrl(profile)}/health`, { signal: AbortSignal.timeout(3000) }),
|
||||
]);
|
||||
if (statusResponse.ok) lighthouseStatus = await statusResponse.json();
|
||||
if (healthResponse.ok) lighthouseHealth = await healthResponse.json();
|
||||
}
|
||||
const liveDomains = new Map((lighthouseStatus?.domains || []).map((domain: any) => [domain.id, domain]));
|
||||
const domain = (serverId: string, fallback: Record<string, unknown>) => ({
|
||||
...fallback,
|
||||
serverId,
|
||||
live: liveDomains.get(serverId) || null,
|
||||
});
|
||||
return {
|
||||
nodeId: profile.id,
|
||||
physicalNodeId: profile.physicalNodeId,
|
||||
connected,
|
||||
verified: Boolean(lighthouseStatus && lighthouseHealth),
|
||||
codeChannel: connected ? 'reachable' : 'not-connected',
|
||||
lighthouse: lighthouseHealth ? {
|
||||
mode: lighthouseHealth.mode,
|
||||
mapHash: lighthouseHealth.map_hash,
|
||||
execution: lighthouseHealth.execution,
|
||||
hostState: lighthouseStatus?.host_state,
|
||||
observedAt: lighthouseStatus?.observed_at,
|
||||
} : null,
|
||||
domains: [
|
||||
domain('DOMAIN-MAIN', { id: 'main', number: 'HLDP-DOMAIN-MAIN-001', name: '光湖主域', responsibility: '公共定义、版本发布、广播与模块生态状态', repository: 'domain-main' }),
|
||||
domain('DOMAIN-SUB', { id: 'sub', number: 'HLDP-DOMAIN-SUB-001', name: '光湖分域', responsibility: '行业入口、模块目录与初始化频道路由', repository: 'domain-sub' }),
|
||||
domain('DOMAIN-ZERO', { id: 'zero', number: 'HLDP-DOMAIN-ZERO-001', name: '光湖零域', responsibility: '模块试装、实验、质量验证与可回滚预览', repository: 'domain-zero' }),
|
||||
domain('DOMAIN-ZS', { id: 'zero-sense', number: 'HLDP-DOMAIN-ZEROSENSE-001', name: '光湖零感域', responsibility: '团队身份、责任、权限、审计与个人节点发现', repository: 'domain-zero-sense' }),
|
||||
],
|
||||
};
|
||||
});
|
||||
ipcMain.handle('server:session', async (_event, requestedNodeId?: string) => {
|
||||
const stored = applyStoredServerAuth();
|
||||
const profiles = readServerProfiles();
|
||||
const nodeId = requestedNodeId && profiles[requestedNodeId]
|
||||
? requestedNodeId
|
||||
: stored?.nodeId || defaultPersonalServerId() || '';
|
||||
if (!stored || stored.nodeId !== nodeId) return { authenticated: false, nodeId };
|
||||
try {
|
||||
const { response, data } = await forgejoRequest(nodeId, '/api/v1/user', {}, { token: stored.token });
|
||||
if (!response.ok) {
|
||||
fs.rmSync(SERVER_AUTH_PATH, { force: true });
|
||||
clearGitCredentialEnvironment();
|
||||
return { authenticated: false, nodeId };
|
||||
}
|
||||
return { authenticated: true, nodeId, username: data.login };
|
||||
} catch {
|
||||
return { authenticated: false, nodeId };
|
||||
}
|
||||
});
|
||||
ipcMain.handle('server:login', async (_event, input: { nodeId: string; username: string; password: string }) => {
|
||||
const username = String(input.username || '').trim();
|
||||
const password = String(input.password || '');
|
||||
getServerProfile(input.nodeId);
|
||||
if (!username || !password) throw new Error('请输入账号和密码');
|
||||
|
||||
const basicAuth = { username, password };
|
||||
const identity = await forgejoRequest(input.nodeId, '/api/v1/user', {}, basicAuth);
|
||||
if (!identity.response.ok) throw new Error('账号或密码不正确');
|
||||
|
||||
const tokenName = 'hololake-desktop';
|
||||
const existing = await forgejoRequest(input.nodeId, `/api/v1/users/${encodeURIComponent(username)}/tokens`, {}, basicAuth);
|
||||
if (existing.response.ok && Array.isArray(existing.data)) {
|
||||
for (const token of existing.data) {
|
||||
if (token?.name === tokenName && token?.id) {
|
||||
await forgejoRequest(
|
||||
input.nodeId,
|
||||
`/api/v1/users/${encodeURIComponent(username)}/tokens/${token.id}`,
|
||||
{ method: 'DELETE' },
|
||||
basicAuth,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
const created = await forgejoRequest(
|
||||
input.nodeId,
|
||||
`/api/v1/users/${encodeURIComponent(username)}/tokens`,
|
||||
{ method: 'POST', body: JSON.stringify({ name: tokenName, scopes: ['all'] }) },
|
||||
basicAuth,
|
||||
);
|
||||
if (created.response.status !== 201 || !created.data?.sha1) {
|
||||
throw new Error(created.data?.message || '无法建立 HoloLake 登录令牌');
|
||||
}
|
||||
const previous = applyStoredServerAuth();
|
||||
if (!previous || previous.nodeId !== input.nodeId || previous.username !== identity.data.login) {
|
||||
await detachKnowledgeRemote();
|
||||
}
|
||||
saveServerAuth(input.nodeId, username, created.data.sha1);
|
||||
return { authenticated: true, nodeId: input.nodeId, username: identity.data.login };
|
||||
});
|
||||
ipcMain.handle('server:logout', async () => {
|
||||
const stored = applyStoredServerAuth();
|
||||
await detachKnowledgeRemote();
|
||||
fs.rmSync(SERVER_AUTH_PATH, { force: true });
|
||||
clearGitCredentialEnvironment();
|
||||
return { authenticated: false, nodeId: stored?.nodeId || defaultPersonalServerId() || '' };
|
||||
});
|
||||
ipcMain.handle('server:repositories', async () => {
|
||||
const stored = applyStoredServerAuth();
|
||||
if (!stored) throw new Error('请先登录服务器');
|
||||
const { response, data } = await forgejoRequest(stored.nodeId, '/api/v1/user/repos?limit=100', {}, { token: stored.token });
|
||||
if (!response.ok || !Array.isArray(data)) throw new Error(data?.message || '读取服务器仓库失败');
|
||||
return data.map(repo => ({
|
||||
name: repo.name,
|
||||
fullName: repo.full_name,
|
||||
private: repo.private,
|
||||
defaultBranch: repo.default_branch || 'main',
|
||||
}));
|
||||
});
|
||||
ipcMain.handle('server:create-repository', async (_event, input: { name: string; description?: string }) => {
|
||||
const stored = applyStoredServerAuth();
|
||||
if (!stored) throw new Error('请先登录服务器');
|
||||
const name = String(input.name || '').trim();
|
||||
if (!/^[A-Za-z0-9._-]{1,100}$/.test(name)) throw new Error('仓库名称只能使用字母、数字、点、短横线或下划线');
|
||||
const { response, data } = await forgejoRequest(stored.nodeId, '/api/v1/user/repos', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
description: String(input.description || ''),
|
||||
private: true,
|
||||
auto_init: true,
|
||||
default_branch: 'main',
|
||||
}),
|
||||
}, { token: stored.token });
|
||||
if (response.status !== 201) throw new Error(data?.message || '创建仓库失败');
|
||||
return {
|
||||
name: data.name,
|
||||
fullName: data.full_name,
|
||||
private: data.private,
|
||||
defaultBranch: data.default_branch || 'main',
|
||||
};
|
||||
});
|
||||
ipcMain.handle('server:git-remote', async (_event, fullName: string) => {
|
||||
if (!/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(String(fullName || ''))) {
|
||||
throw new Error('仓库路径无效');
|
||||
}
|
||||
const stored = applyStoredServerAuth();
|
||||
if (!stored) throw new Error('请先登录服务器');
|
||||
const [owner, repository] = String(fullName).split('/');
|
||||
const access = await forgejoRequest(
|
||||
stored.nodeId,
|
||||
`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}`,
|
||||
{},
|
||||
{ token: stored.token },
|
||||
);
|
||||
if (!access.response.ok || access.data?.full_name !== fullName) {
|
||||
throw new Error('当前账号无权访问该仓库');
|
||||
}
|
||||
return `${forgejoBaseUrl(getServerProfile(stored.nodeId))}/${fullName}.git`;
|
||||
});
|
||||
|
||||
// ─── 应用生命周期 ───
|
||||
|
|
@ -200,6 +669,7 @@ app.whenReady().then(async () => {
|
|||
try {
|
||||
applyModelConfig();
|
||||
await startServer();
|
||||
await waitForServerReady();
|
||||
createWindow();
|
||||
} catch (err) {
|
||||
dialog.showErrorBox('启动失败', `知识库引擎启动失败:${err}`);
|
||||
|
|
@ -218,4 +688,5 @@ app.on('window-all-closed', () => {
|
|||
|
||||
app.on('before-quit', () => {
|
||||
stopServer();
|
||||
stopServerTunnels();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { contextBridge, ipcRenderer } from 'electron';
|
|||
contextBridge.exposeInMainWorld('hololake', {
|
||||
// 应用信息
|
||||
platform: process.platform,
|
||||
version: '0.5.1',
|
||||
version: '0.7.0',
|
||||
|
||||
// 数据目录
|
||||
getDataPath: () => ipcRenderer.invoke('get-data-path'),
|
||||
|
|
@ -24,6 +24,21 @@ contextBridge.exposeInMainWorld('hololake', {
|
|||
getConfig: () => ipcRenderer.invoke('agent:get-config'),
|
||||
saveConfig: (config: { baseUrl: string; model: string; apiKey?: string }) =>
|
||||
ipcRenderer.invoke('agent:save-config', config),
|
||||
testConfig: () => ipcRenderer.invoke('agent:test-config'),
|
||||
},
|
||||
|
||||
server: {
|
||||
list: () => ipcRenderer.invoke('server:list'),
|
||||
connect: (nodeId: string) => ipcRenderer.invoke('server:connect', nodeId),
|
||||
domainRegistry: () => ipcRenderer.invoke('server:domain-registry'),
|
||||
session: (nodeId?: string) => ipcRenderer.invoke('server:session', nodeId),
|
||||
login: (input: { nodeId: string; username: string; password: string }) =>
|
||||
ipcRenderer.invoke('server:login', input),
|
||||
logout: () => ipcRenderer.invoke('server:logout'),
|
||||
repositories: () => ipcRenderer.invoke('server:repositories'),
|
||||
createRepository: (input: { name: string; description?: string }) =>
|
||||
ipcRenderer.invoke('server:create-repository', input),
|
||||
gitRemote: (fullName: string) => ipcRenderer.invoke('server:git-remote', fullName),
|
||||
},
|
||||
|
||||
// Forgejo 通过本地知识库 API 管理;此桥只保留非敏感应用信息。
|
||||
|
|
|
|||
190
product-source/hololake-desktop/package-lock.json
generated
190
product-source/hololake-desktop/package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "hololake-desktop",
|
||||
"version": "0.5.1",
|
||||
"version": "0.7.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hololake-desktop",
|
||||
"version": "0.5.1",
|
||||
"version": "0.7.0",
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"diff": "^9.0.0",
|
||||
|
|
@ -24,7 +24,7 @@
|
|||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"concurrently": "^9.1.0",
|
||||
"electron": "^35.0.0",
|
||||
"electron": "^43.3.0",
|
||||
"electron-builder": "^26.0.0",
|
||||
"esbuild": "^0.25.0",
|
||||
"react": "^19.0.0",
|
||||
|
|
@ -316,6 +316,16 @@
|
|||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron-internal/extract-zip": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.5.tgz",
|
||||
"integrity": "sha512-+bqFCP98pLI0Tt0XQo1TmlXtwjWchISndDOxCkEcIuUgXWpBnLyRI+2DU+mesvnMMX6L1XDqYNA0lXNDHd/yiA==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/asar": {
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz",
|
||||
|
|
@ -420,25 +430,61 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@electron/get": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz",
|
||||
"integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==",
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@electron/get/-/get-5.1.0.tgz",
|
||||
"integrity": "sha512-3kSBtG8ObcTVfXanm5vVJ6UnBLEVmVsRk1M+vGqCuMBV+XLCbJYuWQful+yIy0GQDsSlK0kHEriEHn7SPk4EnA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.1.1",
|
||||
"env-paths": "^2.2.0",
|
||||
"fs-extra": "^8.1.0",
|
||||
"got": "^11.8.5",
|
||||
"env-paths": "^3.0.0",
|
||||
"graceful-fs": "^4.2.11",
|
||||
"progress": "^2.0.3",
|
||||
"semver": "^6.2.0",
|
||||
"semver": "^7.6.3",
|
||||
"sumchecker": "^3.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"global-agent": "^3.0.0"
|
||||
"undici": "^7.24.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/get/node_modules/env-paths": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz",
|
||||
"integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/get/node_modules/semver": {
|
||||
"version": "7.8.5",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/get/node_modules/undici": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
|
||||
"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/notarize": {
|
||||
|
|
@ -2077,17 +2123,6 @@
|
|||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/yauzl": {
|
||||
"version": "2.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
|
||||
"integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
||||
|
|
@ -2573,16 +2608,6 @@
|
|||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-crc32": {
|
||||
"version": "0.2.13",
|
||||
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
|
||||
"integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-from": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
|
|
@ -3366,22 +3391,22 @@
|
|||
}
|
||||
},
|
||||
"node_modules/electron": {
|
||||
"version": "35.7.5",
|
||||
"resolved": "https://registry.npmjs.org/electron/-/electron-35.7.5.tgz",
|
||||
"integrity": "sha512-dnL+JvLraKZl7iusXTVTGYs10TKfzUi30uEDTqsmTm0guN9V2tbOjTzyIZbh9n3ygUjgEYyo+igAwMRXIi3IPw==",
|
||||
"version": "43.3.0",
|
||||
"resolved": "https://registry.npmjs.org/electron/-/electron-43.3.0.tgz",
|
||||
"integrity": "sha512-nLlvu0WFjftWsSaTkV2B/c4NDuJBspTyXu8vKSQ6vLvFt8uG3NgN49LLKcXddwX0GqVvAQDhciWp+4xOdTdhew==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@electron/get": "^2.0.0",
|
||||
"@types/node": "^22.7.7",
|
||||
"extract-zip": "^2.0.1"
|
||||
"@electron-internal/extract-zip": "^1.0.1",
|
||||
"@electron/get": "^5.0.0",
|
||||
"@types/node": "^24.9.0"
|
||||
},
|
||||
"bin": {
|
||||
"electron": "cli.js"
|
||||
"electron": "cli.js",
|
||||
"install-electron": "install.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.20.55"
|
||||
"node": ">= 22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-builder": {
|
||||
|
|
@ -3562,6 +3587,23 @@
|
|||
"node": ">=6 <7 || >=8"
|
||||
}
|
||||
},
|
||||
"node_modules/electron/node_modules/@types/node": {
|
||||
"version": "24.13.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
|
||||
"integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/electron/node_modules/undici-types": {
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
|
|
@ -3815,27 +3857,6 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/extract-zip": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
|
||||
"integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"debug": "^4.1.1",
|
||||
"get-stream": "^5.1.0",
|
||||
"yauzl": "^2.10.0"
|
||||
},
|
||||
"bin": {
|
||||
"extract-zip": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.17.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@types/yauzl": "^2.9.1"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
|
|
@ -3860,16 +3881,6 @@
|
|||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/fd-slicer": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
|
||||
"integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pend": "~1.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
|
|
@ -4007,21 +4018,6 @@
|
|||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/fs-extra": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
|
||||
"integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^4.0.0",
|
||||
"universalify": "^0.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6 <7 || >=8"
|
||||
}
|
||||
},
|
||||
"node_modules/fs.realpath": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
||||
|
|
@ -5248,13 +5244,6 @@
|
|||
"url": "https://github.com/sponsors/jet2jet"
|
||||
}
|
||||
},
|
||||
"node_modules/pend": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
|
||||
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
|
|
@ -7274,17 +7263,6 @@
|
|||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/yauzl": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
|
||||
"integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-crc32": "~0.2.3",
|
||||
"fd-slicer": "~1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/yocto-queue": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "hololake-desktop",
|
||||
"version": "0.5.1",
|
||||
"description": "HoloLake Era 桌面版 — Git 驱动的知识库管理",
|
||||
"version": "0.7.0",
|
||||
"description": "HoloLake Era — 光湖语言操作系统平台",
|
||||
"main": "dist-electron/main.cjs",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
|
@ -9,6 +9,7 @@
|
|||
"dev:server": "tsx watch ../guanghu-knowledge-base/server/index.ts",
|
||||
"dev:client": "vite",
|
||||
"dev:electron": "electron .",
|
||||
"test": "tsx --test ../guanghu-knowledge-base/server/*.test.ts ../guanghu-knowledge-base/src/*.test.ts",
|
||||
"build:electron": "esbuild electron/main.ts --bundle --platform=node --format=cjs --external:electron --outfile=dist-electron/main.cjs && esbuild electron/preload.ts --bundle --platform=node --format=cjs --external:electron --outfile=dist-electron/preload.cjs",
|
||||
"build:server": "NODE_PATH=./node_modules esbuild ../guanghu-knowledge-base/server/index.ts --bundle --platform=node --format=cjs --outfile=dist-electron/server-bundle.cjs",
|
||||
"build": "vite build && npm run build:electron && npm run build:server",
|
||||
|
|
@ -33,7 +34,7 @@
|
|||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"concurrently": "^9.1.0",
|
||||
"electron": "^35.0.0",
|
||||
"electron": "^43.3.0",
|
||||
"electron-builder": "^26.0.0",
|
||||
"esbuild": "^0.25.0",
|
||||
"react": "^19.0.0",
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ export default defineConfig({
|
|||
alias: {
|
||||
// 复用知识库前端组件
|
||||
'@': path.resolve(__dirname, '../guanghu-knowledge-base/src'),
|
||||
'react/jsx-dev-runtime': path.resolve(__dirname, 'node_modules/react/jsx-dev-runtime.js'),
|
||||
'react/jsx-runtime': path.resolve(__dirname, 'node_modules/react/jsx-runtime.js'),
|
||||
'react-dom/client': path.resolve(__dirname, 'node_modules/react-dom/client.js'),
|
||||
'react-dom': path.resolve(__dirname, 'node_modules/react-dom/index.js'),
|
||||
|
|
|
|||
Loading…
Reference in a new issue