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
Loading…
Reference in a new issue