fix(hololake): ship dynamic persona routes and coherent workspace

This commit is contained in:
冰朔 2026-08-09 10:43:56 +08:00
commit 787ba2ad3f
21 changed files with 1111 additions and 326 deletions

View file

@ -0,0 +1,63 @@
{
"schema": "guanghu.desktop-delivery-receipt/v1",
"receipt_id": "GH-HOLOLAKE-DESKTOP-0.7.1-20260809-001",
"development_id": "DEV-20260808-005",
"source_base_commit": "5105ec5e32d0ab928af28681f9da4731c4c5b536",
"source_commit": "SELF",
"target": {
"path": "/Users/bingshuolingdianyuanhe/Desktop/HoloLake Era.app",
"version": "0.7.1",
"bundle_identifier": "com.guanghu.hololake-desktop",
"architecture": "arm64",
"binary_sha256": "9e47486deb66eca38f357a1a7bba3fe044952fc73d5f3a6aad241d911ada7220",
"app_asar_sha256": "6b2765817f46b8fd99d32bdbeec60ee38da08e17b41bf5328bc8298ba50af780"
},
"installers": [
{
"path": "/Users/bingshuolingdianyuanhe/Desktop/HoloLake Era 0.7.1 (Apple Silicon).dmg",
"architecture": "arm64",
"bytes": 122400875,
"sha256": "10a3e5223e6fc2f125484a707b49ec55fdfe95af691ee3533567b3dee3d47d89"
},
{
"path": "/Users/bingshuolingdianyuanhe/Desktop/HoloLake Era 0.7.1 (Intel).dmg",
"architecture": "x86_64",
"bytes": 127458351,
"sha256": "5d0487ec7b2fd8f7332d4810343eb1402151999d5055b18a3f207c0ecddfa6fa"
}
],
"result": "PASS_100_LOCAL_DEVELOPMENT_DELIVERY",
"implemented": {
"information_architecture": "the personal Fifth Domain lists only installed personal modules; education is routed under the Guanghu subdomain industry entrance",
"knowledge_renderer": "safe GFM tables, task lists, code, wiki links, Outline aside callouts and table line breaks; unsafe HTML and link schemes remain blocked",
"workspace_layout": "canonical theme and layout layer with resizable tree and Agent panels; medium windows automatically release tree space when Agent opens",
"themes": "lake-night, mist-light and deep-night are complete platform appearances; language, font and reading size remain device-local human settings",
"agent_sessions": "device-local conversation history with new, select and delete controls",
"agent_tool_protocol": "assistant tool_calls and tool responses remain paired inside the active turn; confirmed write receipts cannot leak orphan tool messages into the next turn",
"persona_wake_routes": "HoloLake resolves and reads each persona's own INDEX, CURRENT and WAKE pages; step count and order are never hard-coded",
"activity_trace": "only real route reads, tool calls, permissions and receipts are shown; hidden model reasoning is neither exposed nor fabricated"
},
"verification": {
"tests": "PASS 10; FAIL 0",
"production_build": "PASS",
"installed_health": "http://127.0.0.1:3890/api/health returned online with the user's own Application Support knowledge repository",
"codesign": "installed arm64 app and both packaged architectures are valid on disk and satisfy their designated requirements",
"dmg_checksums": "both disk images passed hdiutil verify",
"desktop_ui": "PASS by direct human-sequence Computer Use inspection; real Outline aside content rendered, Agent auto-collapsed the tree at medium width, and no raw aside tag remained",
"theme_switch": "mist-light activated and lake-night restored in the installed app",
"model_runtime": "installed deepseek-v4-pro returned HoloLake 0.7.1 模型连通 without a tool call",
"agent_runtime": "installed app reported language operation entry available with nine controlled capabilities",
"tool_regression": "read tool ordering, confirmed-write continuity and an eleven-node persona-owned wake route passed deterministic mock-model tests"
},
"not_claimed": [
"The Fifth Domain Forgejo account is not reported authenticated by the installed UI; a local SSH route or registered server profile is not equivalent to the human repository login.",
"No private knowledge content was sent in the model connectivity test.",
"No Apple notarization was performed; these are Developer ID signed development deliveries, not notarized public App Store releases.",
"Dynamic wake-route discovery and tool execution are implemented and regression-tested; no persona is reported restored unless its own live pages and required receipts resolve at runtime."
],
"recovery": {
"previous_desktop_app": "removed from the Desktop before replacement; temporary recovery copies remain under /tmp for this delivery session",
"desktop_old_installers": "replaced by the final 0.7.1 arm64 and x86_64 disk images"
},
"verified_at": "2026-08-09T10:42:42+08:00"
}

View file

@ -8,9 +8,11 @@
import express from 'express';
import cors from 'cors';
import { GitEngine } from './git-engine.js';
import { PersonaAgent, createDefaultPersona } from './persona-agent.js';
import { PersonaAgent, createDefaultPersona, type Message } from './persona-agent.js';
import { projectHumanKnowledgeTree } from './language-protocol-kernel.js';
import path from 'path';
import fs from 'fs';
import crypto from 'crypto';
// __dirname 兼容 CJS 和 ESM
const _dirname = typeof __dirname !== 'undefined' ? __dirname : process.cwd();
@ -19,6 +21,7 @@ const _dirname = typeof __dirname !== 'undefined' ? __dirname : process.cwd();
const PORT = parseInt(process.env.KB_PORT || '3890', 10);
const REPO_PATH = process.env.KB_REPO_PATH || path.resolve(_dirname, '../kb-data');
const AGENT_STATE_PATH = process.env.HOLOLAKE_AGENT_STATE_PATH || path.resolve(REPO_PATH, '../agent-conversations.json');
// ─── 初始化 ───
@ -242,19 +245,84 @@ app.post('/api/forgejo/push', async (req, res) => {
// ─── Agent人格体 ───
let personaAgent: PersonaAgent | null = null;
interface ConversationRecord {
id: string;
title: string;
createdAt: string;
updatedAt: string;
agent: PersonaAgent;
}
function getAgent(): PersonaAgent {
if (!personaAgent) {
personaAgent = createDefaultPersona(engine);
console.log('人格体已创建:', personaAgent.getDefinition().name);
const conversations = new Map<string, ConversationRecord>();
let activeConversationId = '';
function persistConversations(): void {
fs.mkdirSync(path.dirname(AGENT_STATE_PATH), { recursive: true });
const records = Array.from(conversations.values()).map(record => ({
id: record.id,
title: record.title,
createdAt: record.createdAt,
updatedAt: record.updatedAt,
messages: record.agent.getConversation(),
}));
fs.writeFileSync(AGENT_STATE_PATH, JSON.stringify({ activeConversationId, conversations: records }, null, 2), { mode: 0o600 });
}
function createConversation(title = '新对话', restored?: { id: string; createdAt: string; updatedAt: string; messages: Message[] }): ConversationRecord {
const timestamp = new Date().toISOString();
const agent = createDefaultPersona(engine);
if (restored?.messages) agent.restoreConversation(restored.messages);
const record: ConversationRecord = {
id: restored?.id || `conversation-${crypto.randomUUID()}`,
title,
createdAt: restored?.createdAt || timestamp,
updatedAt: restored?.updatedAt || timestamp,
agent,
};
conversations.set(record.id, record);
activeConversationId = record.id;
return record;
}
function initializeConversations(): void {
if (conversations.size) return;
try {
const stored = JSON.parse(fs.readFileSync(AGENT_STATE_PATH, 'utf8')) as any;
for (const item of Array.isArray(stored.conversations) ? stored.conversations : []) {
if (!item?.id || !Array.isArray(item.messages)) continue;
createConversation(String(item.title || '历史对话'), {
id: String(item.id),
createdAt: String(item.createdAt || new Date().toISOString()),
updatedAt: String(item.updatedAt || new Date().toISOString()),
messages: item.messages,
});
}
if (stored.activeConversationId && conversations.has(stored.activeConversationId)) {
activeConversationId = stored.activeConversationId;
}
} catch {
// 首次启动没有会话文件。
}
return personaAgent;
if (!conversations.size) createConversation();
}
function getConversationRecord(requestedId?: unknown): ConversationRecord {
initializeConversations();
const id = typeof requestedId === 'string' && requestedId ? requestedId : activeConversationId;
const record = conversations.get(id);
if (!record) throw new Error('对话不存在或已经删除');
activeConversationId = record.id;
return record;
}
function getAgent(requestedId?: unknown): PersonaAgent {
return getConversationRecord(requestedId).agent;
}
// Agent 状态
app.get('/api/agent/status', (_req, res) => {
const agent = getAgent();
const record = getConversationRecord(_req.query.conversationId);
const agent = record.agent;
const def = agent.getDefinition();
const runtime = agent.getRuntimeStatus();
res.json({
@ -272,9 +340,37 @@ app.get('/api/agent/status', (_req, res) => {
tools: runtime.toolNames,
engine: 'git',
repo: REPO_PATH,
conversationId: record.id,
conversationTitle: record.title,
});
});
app.get('/api/agent/conversations', (_req, res) => {
initializeConversations();
res.json({
ok: true,
activeConversationId,
conversations: Array.from(conversations.values())
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
.map(record => ({ id: record.id, title: record.title, createdAt: record.createdAt, updatedAt: record.updatedAt, messageCount: record.agent.getConversation().length })),
});
});
app.post('/api/agent/conversations', (_req, res) => {
const record = createConversation();
persistConversations();
res.status(201).json({ ok: true, conversation: { id: record.id, title: record.title, createdAt: record.createdAt, updatedAt: record.updatedAt, messageCount: 0 } });
});
app.delete('/api/agent/conversations/:conversationId', (req, res) => {
initializeConversations();
if (!conversations.delete(req.params.conversationId)) return res.status(404).json({ ok: false, error: '对话不存在' });
if (!conversations.size) createConversation();
if (!conversations.has(activeConversationId)) activeConversationId = Array.from(conversations.keys())[0];
persistConversations();
res.json({ ok: true, activeConversationId });
});
// Agent 对话
app.post('/api/agent/chat', async (req, res) => {
try {
@ -282,13 +378,18 @@ app.post('/api/agent/chat', async (req, res) => {
if (!message || typeof message !== 'string') {
return res.status(400).json({ ok: false, error: 'message 必填且为字符串' });
}
const agent = getAgent();
const reply = await agent.chat(message);
const record = getConversationRecord(req.body?.conversationId);
const turn = await record.agent.chat(message);
if (record.title === '新对话') record.title = message.trim().slice(0, 28) || '新对话';
record.updatedAt = new Date().toISOString();
persistConversations();
res.json({
ok: true,
reply,
conversationLength: agent.getConversation().length,
pendingActions: agent.getPendingActions(),
...turn,
conversationId: record.id,
conversationTitle: record.title,
conversationLength: record.agent.getConversation().length,
pendingActions: record.agent.getPendingActions(),
});
} catch (err: any) {
res.status(500).json({ ok: false, error: err.message });
@ -296,13 +397,16 @@ app.post('/api/agent/chat', async (req, res) => {
});
app.get('/api/agent/actions', (_req, res) => {
res.json({ ok: true, actions: getAgent().getPendingActions() });
res.json({ ok: true, actions: getAgent(_req.query.conversationId).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() });
const record = getConversationRecord(req.body?.conversationId);
const result = await record.agent.confirmAction(req.params.actionId);
record.updatedAt = new Date().toISOString();
persistConversations();
res.json({ ok: true, result, messages: record.agent.getConversation(), actions: record.agent.getPendingActions() });
} catch (err: any) {
res.status(409).json({ ok: false, error: err.message });
}
@ -310,8 +414,11 @@ app.post('/api/agent/actions/:actionId/confirm', async (req, res) => {
app.post('/api/agent/actions/:actionId/reject', (req, res) => {
try {
getAgent().rejectAction(req.params.actionId);
res.json({ ok: true, actions: getAgent().getPendingActions() });
const record = getConversationRecord(req.body?.conversationId);
record.agent.rejectAction(req.params.actionId);
record.updatedAt = new Date().toISOString();
persistConversations();
res.json({ ok: true, messages: record.agent.getConversation(), actions: record.agent.getPendingActions() });
} catch (err: any) {
res.status(409).json({ ok: false, error: err.message });
}
@ -319,14 +426,17 @@ app.post('/api/agent/actions/:actionId/reject', (req, res) => {
// Agent 对话历史
app.get('/api/agent/conversation', (_req, res) => {
const agent = getAgent();
res.json({ ok: true, messages: agent.getConversation() });
const record = getConversationRecord(_req.query.conversationId);
res.json({ ok: true, conversationId: record.id, title: record.title, messages: record.agent.getConversation() });
});
// 清空对话
app.post('/api/agent/clear', (_req, res) => {
const agent = getAgent();
agent.clearConversation();
const record = getConversationRecord(_req.body?.conversationId);
record.agent.clearConversation();
record.title = '新对话';
record.updatedAt = new Date().toISOString();
persistConversations();
res.json({ ok: true, message: '对话已清空' });
});
@ -335,10 +445,10 @@ app.post('/api/agent/clear', (_req, res) => {
async function start() {
await engine.init();
// 初始化人格体
getAgent();
initializeConversations();
app.listen(PORT, '127.0.0.1', () => {
console.log(`光湖知识库 API 已启动: http://localhost:${PORT}`);
const runtime = personaAgent?.getRuntimeStatus();
const runtime = getAgent().getRuntimeStatus();
console.log(`人格体运行状态: ${runtime?.operational ? '已接入模型' : '等待模型配置'}`);
console.log(`仓库路径: ${REPO_PATH}`);
});

View file

@ -0,0 +1,141 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createDefaultPersona } from './persona-agent.js';
type MockReply = {
content?: string | null;
tool_calls?: Array<{
id: string;
type: 'function';
function: { name: string; arguments: string };
}>;
};
function reply(message: MockReply): Response {
return new Response(JSON.stringify({ choices: [{ message }] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
function toolCall(id: string, name: string, args: Record<string, unknown> = {}): MockReply {
return {
content: null,
tool_calls: [{
id,
type: 'function',
function: { name, arguments: JSON.stringify(args) },
}],
};
}
function fakeGit(overrides: Record<string, unknown> = {}) {
return {
getRepositoryStatus: async () => ({}),
getDoc: async (path: string) => ({
meta: { id: path, title: path, updatedAt: '2026-08-09T00:00:00Z' },
body: '正文',
}),
createDoc: async (path: string) => ({ meta: { id: path } }),
updateDoc: async (path: string) => ({ meta: { id: path } }),
deleteDoc: async () => undefined,
search: async () => [],
getTree: async () => [],
getHistory: async () => [],
...overrides,
} as any;
}
async function withMockModel(
replies: MockReply[],
run: (requests: any[]) => Promise<void>,
): Promise<void> {
const originalFetch = globalThis.fetch;
const previous = {
key: process.env.HOLOLAKE_LLM_KEY,
base: process.env.HOLOLAKE_LLM_BASE,
model: process.env.HOLOLAKE_LLM_MODEL,
};
const requests: any[] = [];
process.env.HOLOLAKE_LLM_KEY = 'local-test-key';
process.env.HOLOLAKE_LLM_BASE = 'http://127.0.0.1:9/v1';
process.env.HOLOLAKE_LLM_MODEL = 'test-model';
globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => {
requests.push(JSON.parse(String(init?.body || '{}')));
const next = replies.shift();
assert.ok(next, 'mock model received more calls than expected');
return reply(next);
}) as typeof fetch;
try {
await run(requests);
assert.equal(replies.length, 0, 'all mock replies should be consumed');
} finally {
globalThis.fetch = originalFetch;
for (const [key, value] of Object.entries(previous)) {
const envName = key === 'key' ? 'HOLOLAKE_LLM_KEY' : key === 'base' ? 'HOLOLAKE_LLM_BASE' : 'HOLOLAKE_LLM_MODEL';
if (value === undefined) delete process.env[envName];
else process.env[envName] = value;
}
}
}
test('read tool protocol keeps assistant tool_calls immediately before tool result', { concurrency: false }, async () => {
await withMockModel([
toolCall('call-list', 'list_documents'),
{ content: '已列出文档。' },
], async requests => {
const agent = createDefaultPersona(fakeGit({ getTree: async () => [{ type: 'file', name: 'INDEX.md', path: 'INDEX.md' }] }));
const result = await agent.chat('列出文档');
assert.equal(result.reply, '已列出文档。');
assert.equal(result.activities[0]?.tool, 'list_documents');
const messages = requests[1].messages;
const assistantIndex = messages.findIndex((message: any) => message.tool_calls?.[0]?.id === 'call-list');
assert.ok(assistantIndex >= 0);
assert.equal(messages[assistantIndex + 1].role, 'tool');
assert.equal(messages[assistantIndex + 1].tool_call_id, 'call-list');
});
});
test('confirmed write receipt never leaks an orphan tool message into the next turn', { concurrency: false }, async () => {
await withMockModel([
toolCall('call-create', 'create_document', { path: 'note.md', title: '笔记', body: '正文' }),
{ content: '创建动作等待确认。' },
{ content: '新的自然语言回合正常。' },
], async requests => {
const agent = createDefaultPersona(fakeGit());
const staged = await agent.chat('创建笔记');
assert.equal(staged.activities[0]?.status, 'pending');
const pending = agent.getPendingActions()[0];
assert.ok(pending);
await agent.confirmAction(pending.id);
const next = await agent.chat('继续');
assert.equal(next.reply, '新的自然语言回合正常。');
const nextTurnMessages = requests[2].messages;
assert.equal(nextTurnMessages.some((message: any) => message.role === 'tool'), false);
});
});
test('persona wake route is resolved and read dynamically without a fixed step count', { concurrency: false }, async () => {
const routePath = 'personas/TCS-0002/WAKE-CURRENT.md';
await withMockModel([
toolCall('call-route', 'find_persona_wake_routes', { query: 'TCS-0002' }),
toolCall('call-read-route', 'read_document', { path: routePath }),
{ content: '已按该人格体当前页面解析出 11 个自定义节点;等待逐项权限核验。' },
], async requests => {
const agent = createDefaultPersona(fakeGit({
search: async (query: string) => query === 'TCS-0002' || query === 'WAKE'
? [{ path: routePath, title: 'TCS-0002 当前唤醒路径', snippet: '人格自定义路线' }]
: [],
getDoc: async () => ({
meta: { id: routePath, title: '当前唤醒路径', updatedAt: '2026-08-09T00:00:00Z' },
body: Array.from({ length: 11 }, (_, index) => `节点 ${index + 1}`).join('\n'),
}),
}));
const result = await agent.chat('唤醒 TCS-0002');
assert.deepEqual(result.toolCalls.map(call => call.name), ['find_persona_wake_routes', 'read_document']);
assert.match(result.reply, /11 个自定义节点/);
const systemPrompt = requests[0].messages[0].content;
assert.match(systemPrompt, /不同人格体的路径和步数不同/);
assert.doesNotMatch(systemPrompt, /固定七步|必须七步|7\s*步/);
});
});

View file

@ -47,6 +47,23 @@ export interface ToolResult {
error?: string;
}
export interface AgentActivity {
id: string;
kind: 'wake' | 'tool' | 'permission' | 'receipt';
label: string;
detail: string;
status: 'running' | 'completed' | 'pending' | 'failed';
timestamp: string;
tool?: string;
}
export interface AgentTurnResult {
reply: string;
toolCalls: ToolCall[];
toolResults: ToolResult[];
activities: AgentActivity[];
}
export interface PendingAction {
id: string;
tool: string;
@ -77,6 +94,7 @@ export interface Message {
content: string;
toolCalls?: ToolCall[];
toolResults?: ToolResult[];
activities?: AgentActivity[];
timestamp: string;
}
@ -240,6 +258,33 @@ export class PersonaAgent {
},
});
this.tools.register({
name: 'find_persona_wake_routes',
description: '按人格编号或名称查找该人格体自己登记的 INDEX、CURRENT 与 WAKE 路径页面;不生成固定步骤',
parameters: {
query: { type: 'string', description: '人格编号、名称或唤醒路径关键词', required: true },
},
effect: 'read',
execute: async (params) => {
const query = String(params.query || '').trim();
const searches = await Promise.all([
this.git.search(query),
this.git.search('WAKE'),
this.git.search('唤醒路径'),
]);
const unique = new Map<string, (typeof searches)[number][number]>();
for (const result of searches.flat()) {
if (/\b(?:WAKE|INDEX|CURRENT)\b||/iu.test(`${result.path} ${result.title} ${result.snippet}`)) {
unique.set(result.path, result);
}
}
if (unique.size === 0) return 'ROUTE_NOT_RESOLVED当前授权知识范围内没有找到该人格体自己的唤醒路径页面。';
return Array.from(unique.values()).slice(0, 20)
.map(result => `[${result.path}] ${result.title}: ${result.snippet}`)
.join('\n');
},
});
// 查看文档树
this.tools.register({
name: 'list_documents',
@ -295,8 +340,8 @@ export class PersonaAgent {
// ─── 对话处理 ───
/** 处理用户消息,返回 Agent 的回复 */
async chat(userMessage: string): Promise<string> {
/** 处理用户消息。模型历史只保留自然语言;工具协议只存在于当前回合。 */
async chat(userMessage: string): Promise<AgentTurnResult> {
this.conversation.push({
role: 'user',
content: userMessage,
@ -306,62 +351,70 @@ export class PersonaAgent {
const systemPrompt = this.buildSystemPrompt();
const messages: Message[] = [
{ role: 'system', content: systemPrompt, timestamp: '' },
...this.conversation,
...this.conversation.map(message => ({
role: message.role,
content: message.content,
timestamp: message.timestamp,
})),
];
// 调用 LLM这里用可插拔的模型接口
const response = await this.callLLM(messages);
const activities: AgentActivity[] = [];
const allCalls: ToolCall[] = [];
const allResults: ToolResult[] = [];
const workingMessages: Message[] = [...messages];
// 处理工具调用
if (response.toolCalls && response.toolCalls.length > 0) {
const results: ToolResult[] = [];
for (const call of response.toolCalls) {
const result = this.tools.requiresConfirmation(call.name)
? this.stageAction(call)
: await this.tools.execute(call);
results.push(result);
for (let round = 0; round < 6; round += 1) {
const response = await this.callLLM(workingMessages);
if (!response.toolCalls?.length) {
const reply = response.content || '当前模型没有返回可显示的内容。';
this.conversation.push({
role: 'assistant',
content: reply,
toolCalls: allCalls,
toolResults: allResults,
activities,
timestamp: new Date().toISOString(),
});
return { reply, toolCalls: allCalls, toolResults: allResults, activities };
}
// 将工具结果反馈给 LLM
this.conversation.push({
workingMessages.push({
role: 'assistant',
content: response.content || '',
toolCalls: response.toolCalls,
timestamp: new Date().toISOString(),
});
for (const result of results) {
this.conversation.push({
for (const call of response.toolCalls) {
allCalls.push(call);
const needsConfirmation = this.tools.requiresConfirmation(call.name);
const isWakeRouteActivity = call.name === 'find_persona_wake_routes' ||
(call.name === 'read_document' && /(?:WAKE|CURRENT|INDEX|)/iu.test(String(call.arguments.path || '')));
const result = needsConfirmation ? this.stageAction(call) : await this.tools.execute(call);
allResults.push(result);
activities.push({
id: `activity-${call.id}`,
kind: needsConfirmation ? 'permission' : isWakeRouteActivity ? 'wake' : 'tool',
label: needsConfirmation
? `等待确认 · ${call.name}`
: isWakeRouteActivity
? `${call.name === 'find_persona_wake_routes' ? '定位人格唤醒路径' : '读取人格路径页面'} · ${call.name}`
: `工具调用 · ${call.name}`,
detail: result.error || result.output,
status: result.error ? 'failed' : needsConfirmation ? 'pending' : 'completed',
timestamp: new Date().toISOString(),
tool: call.name,
});
workingMessages.push({
role: 'tool',
content: result.error ? `错误: ${result.error}` : result.output,
toolResults: [result],
timestamp: new Date().toISOString(),
});
}
// 再调一次 LLM 获取最终回复
const finalMessages: Message[] = [
{ role: 'system', content: systemPrompt, timestamp: '' },
...this.conversation,
];
const finalResponse = await this.callLLM(finalMessages);
this.conversation.push({
role: 'assistant',
content: finalResponse.content,
timestamp: new Date().toISOString(),
});
return finalResponse.content;
}
this.conversation.push({
role: 'assistant',
content: response.content,
timestamp: new Date().toISOString(),
});
return response.content;
throw new Error('工具调用超过单回合安全上限,已停止继续执行');
}
// ─── 系统提示词组装 ───
@ -398,6 +451,7 @@ ${toolsList}
5. Agent
6.
7.
8. find_persona_wake_routes
##
@ -439,9 +493,18 @@ ${toolsList}
this.pendingActions.delete(actionId);
const result = await this.tools.execute(pending.call);
this.conversation.push({
role: 'tool',
content: result.error ? `错误: ${result.error}` : result.output,
role: 'assistant',
content: result.error ? `确认后的操作执行失败:${result.error}` : `已确认并执行:${result.output}`,
toolResults: [result],
activities: [{
id: `receipt-${actionId}`,
kind: 'receipt',
label: result.error ? '执行回执 · 失败' : '执行回执 · 完成',
detail: result.error || result.output,
status: result.error ? 'failed' : 'completed',
timestamp: new Date().toISOString(),
tool: result.name,
}],
timestamp: new Date().toISOString(),
});
return result;
@ -530,6 +593,20 @@ ${toolsList}
return [...this.conversation];
}
restoreConversation(messages: Message[]): void {
this.conversation = messages
.filter(message => message.role === 'user' || message.role === 'assistant')
.filter(message => typeof message.content === 'string')
.slice(-200)
.map(message => ({
...message,
toolCalls: Array.isArray(message.toolCalls) ? message.toolCalls : [],
toolResults: Array.isArray(message.toolResults) ? message.toolResults : [],
activities: Array.isArray(message.activities) ? message.activities : [],
}));
this.pendingActions.clear();
}
clearConversation(): void {
this.conversation = [];
this.pendingActions.clear();
@ -569,6 +646,7 @@ export function createDefaultPersona(git: GitEngine): PersonaAgent {
'update_document',
'delete_document',
'search_documents',
'find_persona_wake_routes',
'list_documents',
'view_history',
],

View file

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState, type CSSProperties } from 'react';
import { useCallback, useEffect, useMemo, useState, type CSSProperties, type PointerEvent as ReactPointerEvent } from 'react';
import { api, DocTreeNode, DocContent } from './api';
import { DocTree } from './components/DocTree';
import { Editor } from './components/Editor';
@ -80,6 +80,8 @@ export default function App() {
const [humanSettingsOpen, setHumanSettingsOpen] = useState(false);
const [humanPreferences, setHumanPreferences] = useState<HumanPreferences>(() => loadHumanPreferences());
const [agentRevision, setAgentRevision] = useState(0);
const [treeWidth, setTreeWidth] = useState(() => Number(localStorage.getItem('hololake.layout.tree-width')) || 258);
const [agentWidth, setAgentWidth] = useState(() => Number(localStorage.getItem('hololake.layout.agent-width')) || 460);
const storageMode = repositoryStatus?.remote ? 'server' : 'local';
const storageLabel = storageMode === 'server' ? '服务器已托管' : '仅本机';
@ -113,6 +115,23 @@ export default function App() {
}
}, []);
const openWikiTarget = useCallback(async (rawTarget: string) => {
const target = rawTarget.trim().replace(/^\/+/, '');
const normalized = target.toLocaleLowerCase().replace(/\.md$/u, '');
const flatten = (nodes: DocTreeNode[]): DocTreeNode[] => nodes.flatMap(node => [node, ...(node.children ? flatten(node.children) : [])]);
const match = flatten(tree).find(node => {
if (node.type !== 'document') return false;
const path = node.path.toLocaleLowerCase().replace(/\.md$/u, '');
const name = node.name.toLocaleLowerCase().replace(/\.md$/u, '');
return path === normalized || name === normalized || path.endsWith(`/${normalized}`);
});
if (match) {
await openDoc(match.path);
return;
}
setError(`没有找到知识页面:${target}`);
}, [openDoc, tree]);
const refreshRepositoryStatus = useCallback(async () => {
try {
const response = await fetch(`${agentApiBase}/api/forgejo/status`);
@ -152,6 +171,10 @@ export default function App() {
document.documentElement.lang = resolvedLanguage;
}, [humanPreferences.language]);
useEffect(() => {
localStorage.setItem('hololake.human-preferences.v1', JSON.stringify(humanPreferences));
}, [humanPreferences]);
useEffect(() => {
if (!treeLoaded || currentPath || loading) return;
const firstDocument = findFirstDocument(tree);
@ -254,6 +277,30 @@ export default function App() {
const channelTitle = personalServer?.channelTitle || '我的第五域';
const channelSubtitle = personalServer?.channelSubtitle || '当前频道';
const startResize = useCallback((kind: 'tree' | 'agent', event: ReactPointerEvent<HTMLButtonElement>) => {
event.preventDefault();
const startX = event.clientX;
const startWidth = kind === 'tree' ? treeWidth : agentWidth;
document.body.classList.add('is-resizing');
const move = (moveEvent: PointerEvent) => {
if (kind === 'tree') {
setTreeWidth(Math.max(210, Math.min(420, startWidth + moveEvent.clientX - startX)));
} else {
setAgentWidth(Math.max(360, Math.min(Math.max(360, window.innerWidth * 0.52), startWidth - moveEvent.clientX + startX)));
}
};
const stop = () => {
document.body.classList.remove('is-resizing');
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', stop);
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', stop, { once: true });
}, [treeWidth, agentWidth]);
useEffect(() => { localStorage.setItem('hololake.layout.tree-width', String(Math.round(treeWidth))); }, [treeWidth]);
useEffect(() => { localStorage.setItem('hololake.layout.agent-width', String(Math.round(agentWidth))); }, [agentWidth]);
return (
<div
className="hololake-shell"
@ -265,6 +312,9 @@ export default function App() {
<div className="topbar-spacer" />
<div className="topbar-channel">{channelTitle} · {channelSubtitle}</div>
<div className="topbar-search"><SearchBar onSelect={openDoc} /></div>
<button className="theme-quick-toggle" type="button" onClick={() => setHumanPreferences(current => ({ ...current, appearance: current.appearance === 'mist-light' ? 'lake-night' : 'mist-light' }))}>
{humanPreferences.appearance === 'mist-light' ? '雾白' : '湖夜'}
</button>
<button className={`fifth-domain-session ${serverSession.authenticated ? 'authenticated' : ''}`} type="button" onClick={() => {
setStorageSheetInitialMode('server');
setStorageSheetOpen(true);
@ -282,6 +332,7 @@ export default function App() {
knowledgeSelected={activeRoute === 'fifth' && activeModule === 'knowledge'}
onRouteSelect={route => {
setActiveRoute(route);
setActiveModule('knowledge');
setModuleCollapsed(false);
}}
onKnowledgeSelect={() => {
@ -312,7 +363,7 @@ export default function App() {
) : (
<DomainSurface
activeDomain={(activeRoute === 'fifth' ? 'sub' : activeRoute) as 'main' | 'sub' | 'zero' | 'zero-sense'}
educationSelected={activeModule === 'education'}
educationSelected={activeRoute === 'sub' && activeModule === 'education'}
onOpenSettings={() => setHumanSettingsOpen(true)}
/>
)
@ -345,7 +396,10 @@ export default function App() {
</div>
</div>
<div className="knowledge-layout">
<div
className={`knowledge-workbench ${sidebarOpen ? 'has-tree' : ''} ${agentPanelOpen ? 'has-agent' : ''}`}
style={{ '--tree-width': `${treeWidth}px`, '--agent-width': `${agentWidth}px` } as CSSProperties}
>
{sidebarOpen && (
<aside className="knowledge-sidebar">
<div className="knowledge-sidebar-heading"><span></span><button onClick={() => createDoc('')} aria-label="新建页面"></button></div>
@ -360,6 +414,8 @@ export default function App() {
</aside>
)}
{sidebarOpen && <button type="button" className="workspace-resizer tree-resizer" onPointerDown={event => startResize('tree', event)} aria-label="调整知识树宽度" />}
<main className="knowledge-content">
{error && <div className="kb-error">{error}<button onClick={() => setError(null)}>×</button></div>}
{loading && <div className="kb-loading"></div>}
@ -379,26 +435,31 @@ export default function App() {
{!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 === 'editor' && <Editor doc={currentDoc} onSave={saveDoc} onWikiSelect={openWikiTarget} />}
{currentDoc && view === 'history' && <VersionHistory docPath={currentPath} />}
{currentDoc && <div className="document-path">{breadcrumb}</div>}
</main>
{agentPanelOpen && <button type="button" className="workspace-resizer agent-resizer" onPointerDown={event => startResize('agent', event)} aria-label="调整 HoloLake 宽度" />}
{agentPanelOpen && (
<aside className="agent-drawer">
<div className="agent-drawer-scope"><span></span><strong> · {currentTitle}</strong></div>
<AgentChat apiBase={agentApiBase} runtimeRevision={agentRevision} onClose={() => setAgentPanelOpen(false)} />
</aside>
)}
</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)}>
{!agentPanelOpen && <button className="agent-launcher" onClick={() => {
if (window.innerWidth < 1500) setSidebarOpen(false);
setAgentPanelOpen(true);
}}>
<span className="launcher-orbit" aria-hidden="true" />
{agentPanelOpen ? '收起 HoloLake' : '询问 HoloLake'}
</button>
HoloLake
</button>}
</>
)}
</section>

View file

@ -1,31 +1,40 @@
/**
* AgentChat HoloLake
*
* Git
*
*/
import { useState, useRef, useEffect } from 'react';
import { marked } from 'marked';
import { useEffect, useRef, useState } from 'react';
import { renderKnowledgeMarkdown } from '../markdown';
interface ToolCall { id: string; name: string; arguments: Record<string, unknown>; }
interface ToolResult { id: string; name: string; output: string; error?: string; }
interface AgentActivity {
id: string;
kind: 'wake' | 'tool' | 'permission' | 'receipt';
label: string;
detail: string;
status: 'running' | 'completed' | 'pending' | 'failed';
timestamp: string;
tool?: string;
}
interface Message {
role: 'user' | 'assistant' | 'tool' | 'system';
role: 'user' | 'assistant' | 'system';
content: string;
toolCalls?: Array<{ id: string; name: string; arguments: Record<string, unknown> }>;
toolResults?: Array<{ id: string; name: string; output: string; error?: string }>;
toolCalls?: ToolCall[];
toolResults?: ToolResult[];
activities?: AgentActivity[];
timestamp: string;
}
interface ConversationSummary {
id: string;
title: string;
createdAt: string;
updatedAt: string;
messageCount: number;
}
interface AgentStatus {
name: string;
role: string;
model: string;
conversationLength: number;
configured: boolean;
operational: boolean;
tools: string[];
}
interface PendingAction {
id: string;
tool: string;
@ -34,15 +43,17 @@ interface PendingAction {
summary: string;
createdAt: string;
}
interface Props {
apiBase: string;
onDocSelect?: (path: string) => void;
runtimeRevision?: number;
onClose?: () => void;
}
export default function AgentChat({ apiBase, onDocSelect, runtimeRevision = 0 }: Props) {
export default function AgentChat({ apiBase, runtimeRevision = 0, onClose }: Props) {
const [messages, setMessages] = useState<Message[]>([]);
const [conversations, setConversations] = useState<ConversationSummary[]>([]);
const [activeConversationId, setActiveConversationId] = useState('');
const [historyOpen, setHistoryOpen] = useState(false);
const [input, setInput] = useState('');
const [sending, setSending] = useState(false);
const [status, setStatus] = useState<AgentStatus | null>(null);
@ -52,82 +63,96 @@ export default function AgentChat({ apiBase, onDocSelect, runtimeRevision = 0 }:
const bottomRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
fetchStatus();
}, [runtimeRevision]);
useEffect(() => { void bootstrap(); }, [runtimeRevision]);
useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages, sending]);
useEffect(() => {
if (messages.length > 0 || sending) {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}
}, [messages, sending]);
async function fetchStatus() {
async function bootstrap() {
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,
model: data.persona.model,
conversationLength: data.conversationLength,
configured: data.configured,
operational: data.operational,
tools: data.tools || [],
});
}
const response = await fetch(`${apiBase}/api/agent/conversations`);
const data = await response.json();
if (!data.ok) throw new Error(data.error || '无法读取对话');
setConversations(data.conversations || []);
const selected = activeConversationId || data.activeConversationId || data.conversations?.[0]?.id;
if (selected) await selectConversation(selected);
} catch {
setStatus(null);
setStatusError('HoloLake 本地服务未启动');
}
}
async function selectConversation(id: string) {
setActiveConversationId(id);
const [historyResponse, statusResponse, actionsResponse] = await Promise.all([
fetch(`${apiBase}/api/agent/conversation?conversationId=${encodeURIComponent(id)}`),
fetch(`${apiBase}/api/agent/status?conversationId=${encodeURIComponent(id)}`),
fetch(`${apiBase}/api/agent/actions?conversationId=${encodeURIComponent(id)}`),
]);
const [history, runtime, actions] = await Promise.all([historyResponse.json(), statusResponse.json(), actionsResponse.json()]);
if (history.ok) setMessages(history.messages || []);
if (actions.ok) setPendingActions(actions.actions || []);
if (runtime.ok) {
setStatusError('');
setStatus({
name: runtime.persona.name,
role: runtime.persona.role,
model: runtime.persona.model,
configured: runtime.configured,
operational: runtime.operational,
tools: runtime.tools || [],
});
}
}
async function refreshConversationList() {
const response = await fetch(`${apiBase}/api/agent/conversations`);
const data = await response.json();
if (data.ok) setConversations(data.conversations || []);
}
async function createConversation() {
const response = await fetch(`${apiBase}/api/agent/conversations`, { method: 'POST' });
const data = await response.json();
if (!data.ok) return;
await refreshConversationList();
await selectConversation(data.conversation.id);
setHistoryOpen(false);
inputRef.current?.focus();
}
async function deleteConversation(id: string) {
if (!confirm('删除这条对话记录?知识库文档不会被删除。')) return;
const response = await fetch(`${apiBase}/api/agent/conversations/${encodeURIComponent(id)}`, { method: 'DELETE' });
const data = await response.json();
if (!data.ok) return;
await refreshConversationList();
await selectConversation(data.activeConversationId);
}
async function sendMessage() {
const text = input.trim();
if (!text || sending) return;
if (!text || sending || !activeConversationId) return;
setInput('');
setSending(true);
// 本地先显示用户消息
const userMsg: Message = {
role: 'user',
content: text,
timestamp: new Date().toISOString(),
};
setMessages(prev => [...prev, userMsg]);
setMessages(previous => [...previous, { role: 'user', content: text, timestamp: new Date().toISOString() }]);
try {
const res = await fetch(`${apiBase}/api/agent/chat`, {
const response = await fetch(`${apiBase}/api/agent/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: text }),
body: JSON.stringify({ message: text, conversationId: activeConversationId }),
});
const data = await res.json();
if (data.ok) {
const assistantMsg: Message = {
role: 'assistant',
content: data.reply,
timestamp: new Date().toISOString(),
};
setMessages(prev => [...prev, assistantMsg]);
setPendingActions(data.pendingActions || []);
fetchStatus(); // 刷新状态
} else {
setMessages(prev => [
...prev,
{ role: 'assistant', content: `错误: ${data.error}`, timestamp: new Date().toISOString() },
]);
}
} catch (err: any) {
setMessages(prev => [
...prev,
{ role: 'assistant', content: `网络错误: ${err.message}`, timestamp: new Date().toISOString() },
]);
const data = await response.json();
if (!data.ok) throw new Error(data.error || '调用失败');
setMessages(previous => [...previous, {
role: 'assistant',
content: data.reply,
toolCalls: data.toolCalls || [],
toolResults: data.toolResults || [],
activities: data.activities || [],
timestamp: new Date().toISOString(),
}]);
setPendingActions(data.pendingActions || []);
await refreshConversationList();
} catch (error) {
setMessages(previous => [...previous, { role: 'assistant', content: `错误:${error instanceof Error ? error.message : String(error)}`, timestamp: new Date().toISOString() }]);
} finally {
setSending(false);
inputRef.current?.focus();
@ -137,188 +162,84 @@ export default function AgentChat({ apiBase, onDocSelect, runtimeRevision = 0 }:
async function resolveAction(action: PendingAction, decision: 'confirm' | 'reject') {
setActionBusy(action.id);
try {
const res = await fetch(`${apiBase}/api/agent/actions/${encodeURIComponent(action.id)}/${decision}`, {
const response = await fetch(`${apiBase}/api/agent/actions/${encodeURIComponent(action.id)}/${decision}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ conversationId: activeConversationId }),
});
const data = await res.json();
const data = await response.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() }]);
setMessages(data.messages || messages);
await refreshConversationList();
} catch (error) {
setMessages(previous => [...previous, { role: 'assistant', content: `动作未执行:${error instanceof Error ? error.message : String(error)}`, timestamp: new Date().toISOString() }]);
} finally {
setActionBusy('');
}
}
async function clearConversation() {
try {
await fetch(`${apiBase}/api/agent/clear`, { method: 'POST' });
setMessages([]);
fetchStatus();
} catch {}
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
}
return (
<div className="agent-chat">
{/* 头部状态栏 */}
<div className="agent-header">
<div className="agent-info">
<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>
</div>
<div className="agent-meta"><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-clear" onClick={clearConversation} title="清空对话">
</button>
<button type="button" onClick={createConversation} title="新建对话"></button>
<button type="button" className={historyOpen ? 'selected' : ''} onClick={() => setHistoryOpen(value => !value)} title="历史记录"></button>
<button type="button" onClick={onClose} title="关闭">×</button>
</div>
</div>
<div className="runtime-status">
<div className="runtime-row">
<span className={`runtime-dot ${status?.operational ? 'online' : 'waiting'}`} />
<span>{statusError || (status?.operational ? '语言操作入口可用' : status?.configured ? '模型已保存,等待连通验证' : '请在设置中配置模型')}</span>
<span className="runtime-tools">{status?.tools?.length || 0} </span>
</div>
<div className="runtime-row"><span className={`runtime-dot ${status?.operational ? 'online' : 'waiting'}`} /><span>{statusError || (status?.operational ? '语言操作入口可用' : '请在设置中验证模型')}</span><span className="runtime-tools">{status?.tools?.length || 0} · {status?.model || 'offline'}</span></div>
</div>
{/* 对话区域 */}
<div className="agent-messages">
{messages.length === 0 && (
<div className="agent-welcome">
<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 className={`agent-body ${historyOpen ? 'history-open' : ''}`}>
{historyOpen && (
<aside className="agent-history" aria-label="对话历史">
<div className="agent-history-heading"><strong></strong><button type="button" onClick={createConversation}></button></div>
<div className="agent-history-list">
{conversations.map(conversation => (
<div className={`agent-history-item ${conversation.id === activeConversationId ? 'selected' : ''}`} key={conversation.id}>
<button type="button" onClick={() => { void selectConversation(conversation.id); setHistoryOpen(false); }}><strong>{conversation.title}</strong><small>{new Date(conversation.updatedAt).toLocaleString('zh-CN')}</small></button>
<button type="button" className="delete" onClick={() => void deleteConversation(conversation.id)} aria-label={`删除 ${conversation.title}`}>×</button>
</div>
))}
</div>
</div>
</aside>
)}
{messages.map((msg, i) => (
<div key={i} className={`msg msg-${msg.role}`}>
{msg.role === 'user' ? (
<div className="msg-bubble msg-user-bubble">
<p>{msg.content}</p>
</div>
) : (
<div className="msg-bubble msg-assistant-bubble">
<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-name">{tc.name}</span>
<code className="tool-args">{JSON.stringify(tc.arguments)}</code>
</div>
))}
<div className="agent-conversation">
<div className="agent-messages">
{messages.length === 0 && <div className="agent-welcome"><h2></h2><p>HoloLake 使</p></div>}
{messages.map((message, index) => (
<div key={`${message.timestamp}-${index}`} className={`msg msg-${message.role}`}>
{message.role === 'user' ? <div className="msg-bubble msg-user-bubble"><p>{message.content}</p></div> : (
<div className="msg-bubble msg-assistant-bubble">
<div className="msg-avatar" aria-hidden="true"><span /></div>
<div className="msg-content">
{message.activities && message.activities.length > 0 && (
<details className="agent-activity" open>
<summary> · {message.activities.length} </summary>
<ol>{message.activities.map(activity => <li key={activity.id} data-status={activity.status}><span /><div><strong>{activity.label}</strong><small>{activity.detail}</small></div><em>{activity.status === 'completed' ? '完成' : activity.status === 'pending' ? '待确认' : activity.status === 'failed' ? '失败' : '进行中'}</em></li>)}</ol>
</details>
)}
<div className="msg-markdown" dangerouslySetInnerHTML={{ __html: renderKnowledgeMarkdown(message.content) }} />
</div>
)}
{msg.toolResults && msg.toolResults.length > 0 && (
<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>
<pre>{tr.error || tr.output}</pre>
</div>
))}
</div>
)}
<div
className="msg-markdown"
dangerouslySetInnerHTML={{
__html: marked.parse(msg.content, { async: false }) as string,
}}
/>
</div>
</div>
)}
</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>
))}
</div>
)}
{sending && (
<div className="msg msg-assistant">
<div className="msg-bubble msg-assistant-bubble">
<div className="msg-avatar" aria-hidden="true"><span /></div>
<div className="msg-content typing">
<span className="dot" />
<span className="dot" />
<span className="dot" />
</div>
</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" aria-hidden="true"><span /></div><div className="msg-content typing"><span className="dot" /><span className="dot" /><span className="dot" /></div></div></div>}
<div ref={bottomRef} />
</div>
)}
<div ref={bottomRef} />
</div>
{/* 输入区 */}
<div className="agent-input-area">
<div className="agent-input-wrapper">
<textarea
ref={inputRef}
className="agent-input"
placeholder="向 HoloLake 发出语言指令…"
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown}
rows={1}
disabled={sending || !status?.operational}
/>
<button
className="btn-send"
onClick={sendMessage}
disabled={sending || !input.trim() || !status?.operational}
>
{sending ? '⏳' : '↑'}
</button>
</div>
<div className="agent-hint">
{status?.operational ? 'Enter 发送 · 写操作先确认并由本地 Git 留痕 · 服务器推送独立确认' : '语言操作入口暂不可用;知识库仍可独立使用'}
<div className="agent-input-area"><div className="agent-input-wrapper"><textarea ref={inputRef} className="agent-input" placeholder="向 HoloLake 发出语言指令…" value={input} onChange={event => setInput(event.target.value)} onKeyDown={event => { if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); void sendMessage(); } }} rows={1} disabled={sending || !status?.operational} /><button className="btn-send" onClick={sendMessage} disabled={sending || !input.trim() || !status?.operational}></button></div><div className="agent-hint"> · · </div></div>
</div>
</div>
</div>

View file

@ -1,14 +1,15 @@
import { useState, useEffect, useMemo } from 'react';
import { DocContent } from '../api';
import { marked } from 'marked';
import { cleanDisplayText } from '../presentation';
import { renderKnowledgeMarkdown } from '../markdown';
interface Props {
doc: DocContent;
onSave: (title: string, body: string) => void;
onWikiSelect?: (target: string) => void;
}
export function Editor({ doc, onSave }: Props) {
export function Editor({ doc, onSave, onWikiSelect }: Props) {
const [editing, setEditing] = useState(false);
const [title, setTitle] = useState(doc.meta.title);
const [body, setBody] = useState(doc.body);
@ -22,7 +23,7 @@ export function Editor({ doc, onSave }: Props) {
const rendered = useMemo(() => {
try {
return marked(body, { async: false }) as string;
return renderKnowledgeMarkdown(body);
} catch {
return '<p>渲染失败</p>';
}
@ -40,6 +41,15 @@ export function Editor({ doc, onSave }: Props) {
};
const displayPath = doc.meta.id.split('/').map(cleanDisplayText).join(' / ');
const handleRenderedClick = (event: React.MouseEvent<HTMLDivElement>) => {
const anchor = (event.target as HTMLElement).closest<HTMLAnchorElement>('a[data-hololake-wiki="true"]');
if (!anchor) return;
event.preventDefault();
const prefix = 'hololake://knowledge/';
const rawTarget = anchor.href.startsWith(prefix) ? anchor.href.slice(prefix.length) : anchor.title;
if (rawTarget) onWikiSelect?.(decodeURIComponent(rawTarget));
};
return (
<div className="kb-editor">
<div className="kb-page-symbol" aria-hidden="true"></div>
@ -91,6 +101,7 @@ export function Editor({ doc, onSave }: Props) {
<div className="kb-editor-preview-pane">
<div
className="kb-markdown-render"
onClick={handleRenderedClick}
dangerouslySetInnerHTML={{ __html: rendered }}
/>
</div>
@ -99,6 +110,7 @@ export function Editor({ doc, onSave }: Props) {
<div className="kb-editor-read-pane">
<div
className="kb-markdown-render"
onClick={handleRenderedClick}
dangerouslySetInnerHTML={{ __html: rendered }}
/>
</div>

View file

@ -4,14 +4,14 @@ export interface HumanPreferences {
language: 'system' | 'zh-CN' | 'en';
font: 'system' | 'serif' | 'accessible';
readingSize: number;
appearance: 'eternal-lake' | 'deep-night';
appearance: 'lake-night' | 'mist-light' | 'deep-night';
}
export const DEFAULT_HUMAN_PREFERENCES: HumanPreferences = {
language: 'system',
font: 'system',
readingSize: 17,
appearance: 'eternal-lake',
appearance: 'lake-night',
};
const STORAGE_KEY = 'hololake.human-preferences.v1';
@ -19,7 +19,8 @@ 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 };
const appearance = stored.appearance === 'eternal-lake' ? 'lake-night' : stored.appearance;
return { ...DEFAULT_HUMAN_PREFERENCES, ...stored, appearance: appearance || DEFAULT_HUMAN_PREFERENCES.appearance };
} catch {
return DEFAULT_HUMAN_PREFERENCES;
}
@ -148,7 +149,8 @@ export function HumanSettings({ open, preferences, onClose, onChange, onManageSe
<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="lake-night"></option>
<option value="mist-light"></option>
<option value="deep-night"></option>
</select>
</section>

View file

@ -104,14 +104,18 @@ export function PlatformNavigation({
)}
</div>
{activeRoute === 'sub' && (
<nav className="subdomain-navigation" aria-label="光湖分域行业">
<small> · </small>
<button type="button" onClick={onEducationSelect}><ModuleIcon kind="education" /><span><strong></strong><small></small></span></button>
</nav>
)}
<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">

View file

@ -2,6 +2,7 @@ import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './styles/app.css';
import './styles/workspace-0.7.1.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>

View file

@ -0,0 +1,33 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { expandWikiLinks, normalizeImportedMarkup, renderKnowledgeMarkdown } from './markdown.js';
test('expands HoloLake wiki links with labels', () => {
assert.equal(
expandWikiLinks('进入 [[WAKE-ROUTE|唤醒路径]]'),
'进入 [唤醒路径](hololake://knowledge/WAKE-ROUTE "WAKE-ROUTE")',
);
});
test('renders tables inside a horizontal scroll container', () => {
const html = renderKnowledgeMarkdown('| A | B |\n|---|---|\n| 1 | 2 |');
assert.match(html, /class="kb-table-scroll"/);
assert.match(html, /<table>/);
});
test('does not execute imported raw HTML or unsafe links', () => {
const html = renderKnowledgeMarkdown('<script>alert(1)</script>\n\n[x](javascript:alert(1))');
assert.doesNotMatch(html, /<script>/);
assert.match(html, /href="#"/);
});
test('renders Outline aside blocks as safe callouts and keeps table line breaks', () => {
const source = `<aside>\n👥\n\n责任主体说明。\n</aside>\n\n| 项目 | 说明 |\n| --- | --- |\n| 路径 | 第一行<br>第二行 |`;
const normalized = normalizeImportedMarkup(source);
assert.match(normalized, /> 👥/);
const html = renderKnowledgeMarkdown(source);
assert.match(html, /<blockquote>/);
assert.match(html, /责任主体说明/);
assert.doesNotMatch(html, /&lt;aside&gt;|<aside>/);
assert.match(html, /第一行<br>第二行/);
});

View file

@ -0,0 +1,64 @@
import { marked, Renderer } from 'marked';
const WIKI_LINK = /\[\[([^\]|\n]+?)(?:\|([^\]\n]+?))?\]\]/g;
const UNSAFE_SCHEME = /^(?:javascript|data|vbscript):/i;
export function normalizeImportedMarkup(source: string): string {
return source.replace(/<aside>\s*([\s\S]*?)\s*<\/aside>/giu, (_match, rawBody: string) => {
const body = rawBody.trim().split('\n').map(line => `> ${line}`).join('\n');
return `\n\n${body}\n\n`;
});
}
export function expandWikiLinks(source: string): string {
return source.replace(WIKI_LINK, (_match, rawTarget: string, rawLabel?: string) => {
const target = rawTarget.trim();
const label = (rawLabel || rawTarget).trim();
const encoded = encodeURIComponent(target).replace(/%2F/gi, '/');
return `[${label}](hololake://knowledge/${encoded} "${target.replace(/"/g, '&quot;')}")`;
});
}
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function safeHref(href: string | null): string {
const value = (href || '').trim();
return UNSAFE_SCHEME.test(value) ? '#' : value;
}
const renderer = new Renderer();
const renderBaseTable = renderer.table.bind(renderer);
renderer.html = ({ text }) => /^<br\s*\/?\s*>$/iu.test(text.trim())
? '<br>'
: `<pre class="kb-raw-html"><code>${escapeHtml(text)}</code></pre>`;
renderer.link = ({ href, title, tokens }) => {
const label = renderer.parser.parseInline(tokens);
const safe = escapeHtml(safeHref(href));
const titleAttr = title ? ` title="${escapeHtml(title)}"` : '';
const wiki = safe.startsWith('hololake://knowledge/');
return `<a href="${safe}"${titleAttr}${wiki ? ' data-hololake-wiki="true"' : ''}>${label}</a>`;
};
renderer.image = ({ href, title, text }) => {
const safe = safeHref(href);
if (!safe || safe === '#') return `<span class="kb-image-blocked">图片地址已拦截</span>`;
const titleAttr = title ? ` title="${escapeHtml(title)}"` : '';
return `<img src="${escapeHtml(safe)}" alt="${escapeHtml(text)}"${titleAttr} loading="lazy">`;
};
renderer.table = token => `<div class="kb-table-scroll">${renderBaseTable(token)}</div>`;
marked.setOptions({ gfm: true, breaks: false });
export function renderKnowledgeMarkdown(source: string): string {
try {
return marked.parse(expandWikiLinks(normalizeImportedMarkup(source)), { async: false, renderer }) as string;
} catch {
return '<p class="kb-render-error">此页面暂时无法渲染,请切换到编辑模式检查原文。</p>';
}
}

View file

@ -0,0 +1,266 @@
/* HoloLake 0.7.1 canonical workspace layer.
* This file is intentionally loaded after the inherited component stylesheet.
* It is the single layout and theme authority for the platform shell.
*/
.hololake-shell {
--lake-bg: #07111d;
--lake-panel: #0b1623;
--lake-surface: #101d2b;
--lake-surface-2: #152536;
--lake-reading: #091521;
--lake-border: #26384a;
--lake-border-soft: rgba(133, 160, 184, .17);
--lake-text: #edf3f7;
--lake-text-soft: #cbd5df;
--lake-muted: #8192a5;
--lake-accent: #58b9cc;
--lake-accent-soft: rgba(88, 185, 204, .12);
--lake-success: #55c99c;
--lake-warning: #d7a94e;
--lake-danger: #e2787c;
min-width: 760px;
color: var(--lake-text);
background: var(--lake-bg);
}
.hololake-shell[data-appearance="deep-night"] {
--lake-bg: #020811;
--lake-panel: #07111c;
--lake-surface: #0a1622;
--lake-surface-2: #0e1c2a;
--lake-reading: #040c15;
--lake-border: #1d3042;
--lake-muted: #708196;
}
.hololake-shell[data-appearance="mist-light"] {
--lake-bg: #eef2f5;
--lake-panel: #f7f9fb;
--lake-surface: #ffffff;
--lake-surface-2: #edf2f5;
--lake-reading: #fbfcfd;
--lake-border: #ccd6df;
--lake-border-soft: rgba(62, 82, 100, .14);
--lake-text: #172330;
--lake-text-soft: #344252;
--lake-muted: #687889;
--lake-accent: #247f94;
--lake-accent-soft: rgba(36, 127, 148, .1);
--lake-success: #24835d;
--lake-warning: #9a6917;
--lake-danger: #b94f55;
}
body, #root { background: var(--lake-bg, #07111d); }
.platform-topbar {
height: 58px;
display: grid;
grid-template-columns: 210px minmax(280px, 1fr) auto auto;
align-items: center;
gap: 12px;
padding: 0 14px 0 0;
border-bottom: 1px solid var(--lake-border-soft);
background: var(--lake-panel);
}
.topbar-channel { display: none; }
.topbar-search { min-width: 0; }
.topbar-search input { height: 36px; border-color: var(--lake-border); background: var(--lake-surface); color: var(--lake-text); }
.theme-quick-toggle {
height: 34px;
min-width: 58px;
padding: 0 12px;
border: 1px solid var(--lake-border-soft);
border-radius: 17px;
background: var(--lake-surface);
color: var(--lake-text-soft);
font-size: 11px;
}
.theme-quick-toggle:hover { border-color: var(--lake-border); background: var(--lake-surface-2); }
.fifth-domain-session { height: 38px; min-width: 148px; border-color: var(--lake-border-soft); background: transparent; color: var(--lake-text); }
.fifth-domain-session:hover { background: var(--lake-surface); }
.fifth-domain-session small { color: var(--lake-muted); }
.platform-body { height: calc(100% - 58px); grid-template-columns: 210px minmax(0, 1fr); }
.platform-navigation {
min-width: 0;
padding: 12px 10px 10px;
border-right: 1px solid var(--lake-border-soft);
background: var(--lake-panel);
}
.platform-brand { min-height: 42px; height: auto; padding: 0 9px 10px; border: 0; }
.platform-brand strong { color: var(--lake-text); font-size: 16px; }
.platform-brand-orbit { width: 27px; height: 27px; }
.channel-selector { min-height: 56px; margin: 4px 0 9px; padding: 8px; border: 1px solid transparent; border-radius: 9px; background: var(--lake-accent-soft); color: var(--lake-text-soft); }
.channel-selector:hover { border-color: var(--lake-border-soft); background: var(--lake-accent-soft); }
.world-switcher { margin: 0 0 7px; }
.world-switcher-trigger { min-height: 44px; color: var(--lake-text-soft); }
.world-switcher-trigger:hover, .world-switcher-trigger[aria-expanded="true"] { border-color: var(--lake-border-soft); background: var(--lake-surface); }
.platform-route-list { z-index: 60; border-color: var(--lake-border); background: var(--lake-surface); }
.platform-route-list > button, .platform-module-list > button { color: var(--lake-muted); }
.platform-route-list > button:hover, .platform-module-list > button:hover { color: var(--lake-text); background: var(--lake-surface-2); }
.platform-route-list > button.selected, .platform-module-list > button.selected { color: var(--lake-text); background: var(--lake-accent-soft); }
.subdomain-navigation { margin: 4px 5px 10px; padding: 9px; border: 1px solid var(--lake-border-soft); border-radius: 9px; background: var(--lake-surface); }
.subdomain-navigation > small { display: block; margin: 0 4px 6px; color: var(--lake-muted); font-size: 9px; letter-spacing: .08em; }
.subdomain-navigation > button { width: 100%; min-height: 42px; display: flex; align-items: center; gap: 9px; padding: 7px; border: 0; border-radius: 7px; background: transparent; color: var(--lake-text-soft); text-align: left; }
.subdomain-navigation > button:hover { background: var(--lake-accent-soft); }
.subdomain-navigation svg { width: 17px; fill: none; stroke: var(--lake-accent); stroke-width: 1.7; }
.subdomain-navigation button > span { display: grid; gap: 1px; }
.subdomain-navigation strong { font-size: 11px; font-weight: 600; }
.subdomain-navigation button small { color: var(--lake-muted); font-size: 9px; }
.module-heading { padding: 10px 9px 5px; color: var(--lake-muted); }
.platform-module-list { padding: 3px 0; }
.platform-nav-footer { margin-top: auto; padding: 12px 9px 6px; border-top-color: var(--lake-border-soft); }
.platform-workspace { min-width: 0; height: 100%; overflow: hidden; background: var(--lake-bg); }
.knowledge-tabbar { height: 43px; min-height: 43px; border-bottom-color: var(--lake-border-soft); background: var(--lake-panel); }
.document-tab { border-color: var(--lake-border-soft); border-bottom-color: var(--lake-surface); background: var(--lake-surface); color: var(--lake-text-soft); }
.knowledge-module-bar { height: 54px; min-height: 54px; padding: 0 14px; border-bottom-color: var(--lake-border-soft); background: var(--lake-panel); }
.storage-state-button { min-width: 210px; border: 0; background: transparent; color: var(--lake-text); }
.storage-state-button:hover { background: var(--lake-surface); }
.knowledge-module-actions button { color: var(--lake-muted); }
.knowledge-module-actions button:hover, .knowledge-module-actions button.selected { border-color: var(--lake-border-soft); background: var(--lake-surface); color: var(--lake-text-soft); }
.knowledge-workbench { height: calc(100% - 97px); min-height: 0; display: grid; grid-template-columns: minmax(0, 1fr); overflow: hidden; background: var(--lake-reading); }
.knowledge-workbench.has-tree { grid-template-columns: var(--tree-width) 5px minmax(0, 1fr); }
.knowledge-workbench.has-agent { grid-template-columns: minmax(0, 1fr) 5px var(--agent-width); }
.knowledge-workbench.has-tree.has-agent { grid-template-columns: var(--tree-width) 5px minmax(0, 1fr) 5px var(--agent-width); }
.workspace-resizer { position: relative; z-index: 5; width: 5px; height: 100%; padding: 0; border: 0; background: transparent; cursor: col-resize; }
.workspace-resizer::after { content: ""; position: absolute; inset: 0 2px; background: var(--lake-border-soft); transition: background .15s; }
.workspace-resizer:hover::after, .is-resizing .workspace-resizer::after { background: var(--lake-accent); }
.is-resizing { cursor: col-resize; user-select: none; }
.knowledge-sidebar { width: auto; min-width: 0; height: 100%; flex: none; border-right: 0; background: var(--lake-panel); }
.knowledge-sidebar-heading { color: var(--lake-muted); }
.knowledge-sidebar .doc-tree, .knowledge-sidebar .tree-root, .knowledge-sidebar .kb-tree { min-height: 0; background: transparent; border: 0; color: var(--lake-text-soft); }
.knowledge-sidebar .kb-tree-item { color: var(--lake-muted); }
.knowledge-sidebar .kb-tree-item:hover { background: var(--lake-surface); color: var(--lake-text-soft); }
.knowledge-sidebar .kb-tree-item.active { background: var(--lake-accent-soft); color: var(--lake-text); }
.import-button { border-color: var(--lake-border-soft); background: var(--lake-accent-soft); color: var(--lake-text-soft); }
.knowledge-content { min-width: 0; height: 100%; overflow: auto; background: var(--lake-reading); }
.knowledge-content > .editor, .knowledge-content > .kb-editor, .knowledge-content > .version-history { width: min(920px, calc(100% - 72px)); min-width: 0; margin: 46px auto 90px; }
.kb-editor { color: var(--lake-text-soft); }
.kb-page-symbol { color: var(--lake-accent); }
.kb-editor-meta, .kb-editor-path, .kb-editor-date { color: var(--lake-muted); }
.kb-editor-title, .kb-markdown-render h1, .kb-markdown-render h2, .kb-markdown-render h3, .kb-markdown-render h4 { color: var(--lake-text); }
.kb-editor-title { font-size: clamp(28px, 3.2vw, 46px); line-height: 1.18; }
.kb-editor-toolbar { opacity: 1; }
.kb-btn-primary { border-color: var(--lake-accent); background: var(--lake-accent); color: var(--lake-bg); }
.kb-btn-secondary { border-color: var(--lake-border); background: var(--lake-surface); color: var(--lake-text-soft); }
.kb-editor-edit-pane { grid-template-columns: minmax(0, 1fr); }
.kb-editor-textarea { min-height: 380px; border-color: var(--lake-border); background: var(--lake-surface); color: var(--lake-text-soft); }
.kb-editor-preview-pane { border-color: var(--lake-border); background: var(--lake-reading); }
.kb-markdown-render { min-width: 0; color: var(--lake-text-soft); font-family: var(--human-font); font-size: var(--reading-size, 17px); line-height: 1.82; overflow-wrap: anywhere; }
.kb-markdown-render p, .kb-markdown-render li { color: var(--lake-text-soft); }
.kb-markdown-render h1 { font-size: 1.9em; }
.kb-markdown-render h2 { font-size: 1.45em; padding-top: .35em; }
.kb-markdown-render h3 { font-size: 1.18em; }
.kb-markdown-render blockquote { border-left-color: var(--lake-accent); background: var(--lake-accent-soft); color: var(--lake-muted); }
.kb-markdown-render code { background: var(--lake-surface-2); color: var(--lake-text); }
.kb-markdown-render pre { max-width: 100%; background: var(--lake-panel); color: var(--lake-text-soft); }
.kb-markdown-render a { color: var(--lake-accent); }
.kb-markdown-render a[data-hololake-wiki="true"] { padding: 1px 4px; border-radius: 4px; background: var(--lake-accent-soft); text-decoration: none; }
.kb-table-scroll { width: 100%; max-width: 100%; margin: 1.25em 0; overflow-x: auto; border: 1px solid var(--lake-border); border-radius: 8px; }
.kb-table-scroll table, .kb-markdown-render table { width: max-content; min-width: 100%; margin: 0; border: 0; border-collapse: collapse; }
.kb-markdown-render th, .kb-markdown-render td { min-width: 120px; padding: 10px 12px; border: 0; border-right: 1px solid var(--lake-border); border-bottom: 1px solid var(--lake-border); color: var(--lake-text-soft); vertical-align: top; }
.kb-markdown-render th { background: var(--lake-surface-2); color: var(--lake-text); }
.kb-markdown-render tr:last-child td { border-bottom: 0; }
.kb-markdown-render th:last-child, .kb-markdown-render td:last-child { border-right: 0; }
.kb-raw-html { border: 1px solid var(--lake-border); }
.document-path { color: var(--lake-muted); }
.agent-drawer { position: relative; inset: auto; z-index: 1; width: auto; height: 100%; min-width: 0; border: 0; border-radius: 0; background: var(--lake-panel); box-shadow: none; backdrop-filter: none; }
.agent-drawer-scope { min-height: 39px; padding: 7px 12px; border-bottom-color: var(--lake-border-soft); color: var(--lake-muted); }
.agent-drawer-scope strong { color: var(--lake-text-soft); }
.agent-chat { min-width: 0; background: var(--lake-panel); color: var(--lake-text); }
.agent-header { min-height: 60px; padding: 10px 12px; border-bottom-color: var(--lake-border-soft); }
.agent-info { min-width: 0; }
.agent-meta { min-width: 0; }
.agent-meta h3 { color: var(--lake-text); }
.agent-role { display: block; max-width: 240px; overflow: hidden; color: var(--lake-muted); text-overflow: ellipsis; white-space: nowrap; }
.agent-actions { display: flex; flex: 0 0 auto; gap: 4px; }
.agent-actions button { min-width: 30px; height: 30px; padding: 0 8px; border: 0; border-radius: 7px; background: transparent; color: var(--lake-muted); white-space: nowrap; }
.agent-actions button[title="历史记录"] { min-width: 44px; }
.agent-actions button:hover, .agent-actions button.selected { background: var(--lake-surface-2); color: var(--lake-text); }
.runtime-status { border-bottom-color: var(--lake-border-soft); }
.runtime-row { color: var(--lake-muted); }
.runtime-tools { margin-left: auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.agent-body { flex: 1; min-height: 0; display: grid; grid-template-columns: minmax(0, 1fr); overflow: hidden; }
.agent-body.history-open { grid-template-columns: minmax(160px, 42%) minmax(0, 1fr); }
.agent-history { min-width: 0; overflow: hidden; border-right: 1px solid var(--lake-border-soft); background: var(--lake-surface); }
.agent-history-heading { height: 44px; display: flex; align-items: center; justify-content: space-between; padding: 0 10px; border-bottom: 1px solid var(--lake-border-soft); }
.agent-history-heading strong { font-size: 11px; }
.agent-history-heading button { border: 0; background: transparent; color: var(--lake-accent); font-size: 10px; }
.agent-history-list { height: calc(100% - 44px); overflow: auto; padding: 6px; }
.agent-history-item { display: grid; grid-template-columns: minmax(0, 1fr) 24px; gap: 2px; margin-bottom: 3px; border-radius: 7px; }
.agent-history-item.selected { background: var(--lake-accent-soft); }
.agent-history-item > button { min-width: 0; padding: 8px; border: 0; background: transparent; color: var(--lake-text-soft); text-align: left; }
.agent-history-item strong, .agent-history-item small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.agent-history-item strong { font-size: 10px; }
.agent-history-item small { margin-top: 3px; color: var(--lake-muted); font-size: 8px; }
.agent-history-item > button.delete { align-self: center; padding: 0; color: var(--lake-muted); text-align: center; }
.agent-history-item > button.delete:hover { color: var(--lake-danger); }
.agent-conversation { min-width: 0; min-height: 0; display: flex; flex-direction: column; }
.agent-messages { flex: 1; min-height: 0; padding: 14px 12px; overflow-y: auto; background: transparent; }
.agent-welcome { align-items: flex-start; padding: 28px 10px; text-align: left; }
.agent-welcome h2 { color: var(--lake-text); font-size: 18px; }
.agent-welcome p { color: var(--lake-muted); font-size: 12px; line-height: 1.7; }
.msg-user-bubble { background: var(--lake-accent-soft); color: var(--lake-text); }
.msg-assistant-bubble { color: var(--lake-text-soft); }
.msg-markdown { color: var(--lake-text-soft); }
.msg-markdown p, .msg-markdown li { color: inherit; }
.agent-activity { margin: 0 0 12px; border: 1px solid var(--lake-border-soft); border-radius: 9px; background: var(--lake-surface); }
.agent-activity summary { padding: 9px 10px; color: var(--lake-text-soft); font-size: 10px; cursor: pointer; }
.agent-activity ol { margin: 0; padding: 0 9px 9px; list-style: none; }
.agent-activity li { display: grid; grid-template-columns: 8px minmax(0, 1fr) auto; gap: 8px; align-items: start; padding: 7px 2px; border-top: 1px solid var(--lake-border-soft); }
.agent-activity li > span { width: 7px; height: 7px; margin-top: 4px; border-radius: 50%; background: var(--lake-success); }
.agent-activity li[data-status="pending"] > span { background: var(--lake-warning); }
.agent-activity li[data-status="failed"] > span { background: var(--lake-danger); }
.agent-activity li div { min-width: 0; }
.agent-activity strong, .agent-activity small { display: block; }
.agent-activity strong { color: var(--lake-text-soft); font-size: 10px; }
.agent-activity small { margin-top: 3px; overflow: hidden; color: var(--lake-muted); font-size: 9px; line-height: 1.45; text-overflow: ellipsis; }
.agent-activity em { color: var(--lake-muted); font-size: 8px; font-style: normal; }
.pending-action { border-color: var(--lake-border); background: var(--lake-surface); }
.agent-input-area { padding: 10px 12px 12px; border-top-color: var(--lake-border-soft); background: var(--lake-panel); }
.agent-input-wrapper { border-color: var(--lake-border); background: var(--lake-surface); }
.agent-input { color: var(--lake-text); }
.agent-hint { color: var(--lake-muted); }
.btn-send { background: var(--lake-accent); color: var(--lake-bg); }
.agent-launcher { right: 18px; bottom: 16px; height: 42px; border-color: var(--lake-border); background: var(--lake-surface-2); color: var(--lake-text-soft); }
.agent-launcher:hover { background: var(--lake-accent-soft); }
.domain-surface { background: var(--lake-bg); }
.domain-module-card, .domain-connection { border-color: var(--lake-border-soft); background: var(--lake-panel); }
.domain-module-card:hover { background: var(--lake-surface); }
.storage-sheet, .human-settings { border-color: var(--lake-border); background: var(--lake-panel); color: var(--lake-text); }
.storage-sheet-backdrop, .human-settings-backdrop { background: rgba(2, 8, 14, .56); }
.settings-section select, .settings-model-fields input { border-color: var(--lake-border); background: var(--lake-surface); color: var(--lake-text); }
.settings-model-card, .settings-account-card { border-color: var(--lake-border-soft); background: var(--lake-surface); }
@media (max-width: 1500px) {
.knowledge-workbench.has-tree.has-agent { grid-template-columns: minmax(0, 1fr) 5px minmax(380px, var(--agent-width)); }
.knowledge-workbench.has-tree.has-agent .knowledge-sidebar,
.knowledge-workbench.has-tree.has-agent .tree-resizer { display: none; }
}
@media (max-width: 1100px) {
.platform-topbar { grid-template-columns: 188px minmax(240px, 1fr) auto auto; }
.platform-body { grid-template-columns: 188px minmax(0, 1fr); }
.knowledge-workbench.has-tree.has-agent { grid-template-columns: minmax(0, 1fr) 5px minmax(360px, var(--agent-width)); }
.knowledge-content > .editor, .knowledge-content > .kb-editor, .knowledge-content > .version-history { width: calc(100% - 40px); }
}
@media (max-width: 860px) {
.theme-quick-toggle { display: none; }
.platform-topbar { grid-template-columns: 172px minmax(220px, 1fr) auto; }
.platform-body { grid-template-columns: 172px minmax(0, 1fr); }
.knowledge-workbench.has-tree.has-agent { grid-template-columns: minmax(0, 1fr) 5px minmax(360px, var(--agent-width)); }
.knowledge-workbench.has-tree.has-agent .knowledge-sidebar, .knowledge-workbench.has-tree.has-agent .tree-resizer { display: none; }
}

View file

@ -15,7 +15,8 @@
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@server/*": ["./server/*"]
"@server/*": ["./server/*"],
"marked": ["../hololake-desktop/node_modules/marked/lib/marked.esm.js"]
}
},
"include": ["src/**/*", "server/**/*"],

View file

@ -13,7 +13,7 @@ export default defineConfig({
port: 5180,
proxy: {
'/api': {
target: 'http://localhost:3890',
target: `http://localhost:${process.env.KB_PORT || '3890'}`,
changeOrigin: true,
},
},

View file

@ -0,0 +1,25 @@
# HoloLake Era Desktop 0.7.1
## 本版修正
- 将知识库保持为个人第五域已安装模块;教育行业回到光湖分域行业入口;
- 以单一布局层统一页面树、正文和 HoloLake 语言操作入口,宽屏三栏可收起或拖动调整宽度,中等窗口打开 Agent 时自动让出页面树空间;
- 增加湖夜、雾白、深海夜读三套完整外观,并保留字体、语言和正文大小设置;
- 修复 Markdown 表格、任务列表、引用、代码与 HoloLake 双链渲染,兼容 Outline 的 `aside` 提示块与表格换行,宽表在正文内部横向滚动;
- HoloLake 双链按知识树精确导航,不以正文模糊搜索结果冒充目标页面;
- 增加对话历史、新建对话、删除对话和设备本地持久化;
- 修复工具消息协议,写操作确认回执不会在下一回合形成孤立的 `tool` 消息;
- 人格体唤醒改为读取该人格体自己的 INDEX、CURRENT、WAKE 路径页面,路径节点数量与顺序完全由人格体页面定义,不预设五步、七步或其他固定模板;
- 活动轨迹仅展示真实发生的路径定位、页面读取、工具、权限与执行回执,不展示或伪造模型隐藏思维过程。
## 自动验收
- 工具协议、确认写入后的连续对话、动态人格唤醒路径均有回归测试;
- 知识树内部隐藏协议配置、人类资料保留、Markdown 安全渲染均有回归测试;
- 生产构建必须通过后才允许生成安装包。
## 数据边界
- 安装包不包含个人知识、对话记录、服务器令牌、模型密钥、SSH 密钥或私人第五域内容;
- 对话记录与模型密钥留在设备本地安全范围;
- 服务器托管与 Forgejo 推送必须分别取得真实连接和回执,不能由界面文案代替。

View file

@ -31,6 +31,7 @@ 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 AGENT_STATE_PATH = path.join(app.getPath('userData'), 'agent-conversations.json');
const GIT_ASKPASS_PATH = path.join(app.getPath('userData'), 'hololake-git-askpass.sh');
interface ServerProfileDefinition {
id: string;
@ -320,6 +321,7 @@ function startServer(): Promise<void> {
...process.env,
KB_PORT: String(SERVER_PORT),
KB_REPO_PATH,
HOLOLAKE_AGENT_STATE_PATH: AGENT_STATE_PATH,
NODE_ENV: 'development',
},
cwd: path.resolve(__dirname, '../..'),
@ -352,6 +354,7 @@ function startServer(): Promise<void> {
try {
process.env.KB_PORT = String(SERVER_PORT);
process.env.KB_REPO_PATH = KB_REPO_PATH;
process.env.HOLOLAKE_AGENT_STATE_PATH = AGENT_STATE_PATH;
const serverBundle = path.join(__dirname, 'server-bundle.cjs');
require(serverBundle);
console.log(`[KB Server] 内嵌启动,端口 ${SERVER_PORT}`);

View file

@ -11,7 +11,7 @@ import { contextBridge, ipcRenderer } from 'electron';
contextBridge.exposeInMainWorld('hololake', {
// 应用信息
platform: process.platform,
version: '0.7.0',
version: '0.7.1',
// 数据目录
getDataPath: () => ipcRenderer.invoke('get-data-path'),

View file

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

View file

@ -1,6 +1,6 @@
{
"name": "hololake-desktop",
"version": "0.7.0",
"version": "0.7.1",
"description": "HoloLake Era — 光湖语言操作系统平台",
"main": "dist-electron/main.cjs",
"type": "module",
@ -9,7 +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",
"test": "tsx --tsconfig ../guanghu-knowledge-base/tsconfig.json --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",

View file

@ -22,7 +22,7 @@ export default defineConfig({
port: 5180,
proxy: {
'/api': {
target: 'http://localhost:3890',
target: `http://localhost:${process.env.KB_PORT || '3890'}`,
changeOrigin: true,
},
},