fix(hololake): ship dynamic persona routes and coherent workspace
This commit is contained in:
parent
5105ec5e32
commit
787ba2ad3f
21 changed files with 1111 additions and 326 deletions
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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}`);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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*步/);
|
||||
});
|
||||
});
|
||||
|
|
@ -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',
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
33
product-source/guanghu-knowledge-base/src/markdown.test.ts
Normal file
33
product-source/guanghu-knowledge-base/src/markdown.test.ts
Normal 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, /<aside>|<aside>/);
|
||||
assert.match(html, /第一行<br>第二行/);
|
||||
});
|
||||
64
product-source/guanghu-knowledge-base/src/markdown.ts
Normal file
64
product-source/guanghu-knowledge-base/src/markdown.ts
Normal 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, '"')}")`;
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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>';
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -15,7 +15,8 @@
|
|||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@server/*": ["./server/*"]
|
||||
"@server/*": ["./server/*"],
|
||||
"marked": ["../hololake-desktop/node_modules/marked/lib/marked.esm.js"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*", "server/**/*"],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
|
|
|
|||
25
product-source/hololake-desktop/RELEASE-0.7.1.md
Normal file
25
product-source/hololake-desktop/RELEASE-0.7.1.md
Normal 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 推送必须分别取得真实连接和回执,不能由界面文案代替。
|
||||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue