From 787ba2ad3f3d66d2622aa7113ddd35f6a42aaa82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=B0=E6=9C=94?= <565183519@qq.com> Date: Sun, 9 Aug 2026 10:43:56 +0800 Subject: [PATCH] fix(hololake): ship dynamic persona routes and coherent workspace --- ...H-HOLOLAKE-DESKTOP-0.7.1-20260809-001.json | 63 +++ .../guanghu-knowledge-base/server/index.ts | 158 ++++++-- .../server/persona-agent.test.ts | 141 +++++++ .../server/persona-agent.ts | 160 ++++++-- .../guanghu-knowledge-base/src/App.tsx | 87 +++- .../src/components/AgentChat.tsx | 381 +++++++----------- .../src/components/Editor.tsx | 18 +- .../src/components/HumanSettings.tsx | 10 +- .../src/components/PlatformNavigation.tsx | 10 +- .../guanghu-knowledge-base/src/main.tsx | 1 + .../src/markdown.test.ts | 33 ++ .../guanghu-knowledge-base/src/markdown.ts | 64 +++ .../src/styles/workspace-0.7.1.css | 266 ++++++++++++ .../guanghu-knowledge-base/tsconfig.json | 3 +- .../guanghu-knowledge-base/vite.config.ts | 2 +- .../hololake-desktop/RELEASE-0.7.1.md | 25 ++ .../hololake-desktop/electron/main.ts | 3 + .../hololake-desktop/electron/preload.ts | 2 +- .../hololake-desktop/package-lock.json | 4 +- product-source/hololake-desktop/package.json | 4 +- .../hololake-desktop/vite.config.ts | 2 +- 21 files changed, 1111 insertions(+), 326 deletions(-) create mode 100644 deployment/receipts/GH-HOLOLAKE-DESKTOP-0.7.1-20260809-001.json create mode 100644 product-source/guanghu-knowledge-base/server/persona-agent.test.ts create mode 100644 product-source/guanghu-knowledge-base/src/markdown.test.ts create mode 100644 product-source/guanghu-knowledge-base/src/markdown.ts create mode 100644 product-source/guanghu-knowledge-base/src/styles/workspace-0.7.1.css create mode 100644 product-source/hololake-desktop/RELEASE-0.7.1.md diff --git a/deployment/receipts/GH-HOLOLAKE-DESKTOP-0.7.1-20260809-001.json b/deployment/receipts/GH-HOLOLAKE-DESKTOP-0.7.1-20260809-001.json new file mode 100644 index 0000000..d8a5a0d --- /dev/null +++ b/deployment/receipts/GH-HOLOLAKE-DESKTOP-0.7.1-20260809-001.json @@ -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" +} diff --git a/product-source/guanghu-knowledge-base/server/index.ts b/product-source/guanghu-knowledge-base/server/index.ts index bea81ae..b34775c 100644 --- a/product-source/guanghu-knowledge-base/server/index.ts +++ b/product-source/guanghu-knowledge-base/server/index.ts @@ -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(); +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}`); }); diff --git a/product-source/guanghu-knowledge-base/server/persona-agent.test.ts b/product-source/guanghu-knowledge-base/server/persona-agent.test.ts new file mode 100644 index 0000000..e5e7547 --- /dev/null +++ b/product-source/guanghu-knowledge-base/server/persona-agent.test.ts @@ -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 = {}): MockReply { + return { + content: null, + tool_calls: [{ + id, + type: 'function', + function: { name, arguments: JSON.stringify(args) }, + }], + }; +} + +function fakeGit(overrides: Record = {}) { + 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, +): Promise { + 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*步/); + }); +}); diff --git a/product-source/guanghu-knowledge-base/server/persona-agent.ts b/product-source/guanghu-knowledge-base/server/persona-agent.ts index 8c913f7..17e3cdd 100644 --- a/product-source/guanghu-knowledge-base/server/persona-agent.ts +++ b/product-source/guanghu-knowledge-base/server/persona-agent.ts @@ -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(); + 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 { + /** 处理用户消息。模型历史只保留自然语言;工具协议只存在于当前回合。 */ + async chat(userMessage: string): Promise { 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', ], diff --git a/product-source/guanghu-knowledge-base/src/App.tsx b/product-source/guanghu-knowledge-base/src/App.tsx index 9063a30..2a00d22 100644 --- a/product-source/guanghu-knowledge-base/src/App.tsx +++ b/product-source/guanghu-knowledge-base/src/App.tsx @@ -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(() => 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) => { + 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 (
{channelTitle} · {channelSubtitle}
+
-
+
{sidebarOpen && ( )} + {sidebarOpen &&
} {loading &&
正在打开页面…
} @@ -379,26 +435,31 @@ export default function App() { {!currentDoc && emptyDismissed && !loading && (

知识库已准备好

导入文件夹或新建页面开始使用。

)} - {currentDoc && view === 'editor' && } + {currentDoc && view === 'editor' && } {currentDoc && view === 'history' && } {currentDoc &&
{breadcrumb}
} + + {agentPanelOpen &&
)} {contentVisible && ( <> - {agentPanelOpen && ( - - )} - + 询问 HoloLake + } )} diff --git a/product-source/guanghu-knowledge-base/src/components/AgentChat.tsx b/product-source/guanghu-knowledge-base/src/components/AgentChat.tsx index 6e82377..a7eb96b 100644 --- a/product-source/guanghu-knowledge-base/src/components/AgentChat.tsx +++ b/product-source/guanghu-knowledge-base/src/components/AgentChat.tsx @@ -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; } +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 }>; - 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([]); + const [conversations, setConversations] = useState([]); + const [activeConversationId, setActiveConversationId] = useState(''); + const [historyOpen, setHistoryOpen] = useState(false); const [input, setInput] = useState(''); const [sending, setSending] = useState(false); const [status, setStatus] = useState(null); @@ -52,82 +63,96 @@ export default function AgentChat({ apiBase, onDocSelect, runtimeRevision = 0 }: const bottomRef = useRef(null); const inputRef = useRef(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 (
- {/* 头部状态栏 */}
-
-

{status?.name || 'HoloLake'}

- {status?.role || '语言操作入口'} -
+

{status?.name || 'HoloLake'}

{status?.role || '语言操作入口'}
- {status?.model || 'offline'} - + + +
-
- - {statusError || (status?.operational ? '语言操作入口可用' : status?.configured ? '模型已保存,等待连通验证' : '请在设置中配置模型')} - {status?.tools?.length || 0} 项受控能力 -
+
{statusError || (status?.operational ? '语言操作入口可用' : '请在设置中验证模型')}{status?.tools?.length || 0} 项受控能力 · {status?.model || 'offline'}
- {/* 对话区域 */} -
- {messages.length === 0 && ( -
-

询问 HoloLake

-

{statusError || (status?.operational ? '可以检索、整理和编辑当前知识库,并为操作保留版本记录。' : '知识库可以独立使用;模型服务在设置中配置并验证。')}

-

试试说:

-
- - - +
+ {historyOpen && ( +
+ )} - {messages.map((msg, i) => ( -
- {msg.role === 'user' ? ( -
-

{msg.content}

-
- ) : ( -
- -
- {msg.toolCalls && msg.toolCalls.length > 0 && ( -
- {msg.toolCalls.map((tc, j) => ( -
- 运行 - {tc.name} - {JSON.stringify(tc.arguments)} -
- ))} +
+
+ {messages.length === 0 &&

语言操作入口

人格体唤醒时,HoloLake 会先查找该人格体自己的路径页面。活动栏只显示真实发生的读取、工具、权限与回执,不使用固定步骤模板。

} + {messages.map((message, index) => ( +
+ {message.role === 'user' ?

{message.content}

: ( +
+ +
+ {message.activities && message.activities.length > 0 && ( +
+ 运行活动 · {message.activities.length} 项 +
    {message.activities.map(activity =>
  1. {activity.label}{activity.detail}
    {activity.status === 'completed' ? '完成' : activity.status === 'pending' ? '待确认' : activity.status === 'failed' ? '失败' : '进行中'}
  2. )}
+
+ )} +
- )} - {msg.toolResults && msg.toolResults.length > 0 && ( -
- {msg.toolResults.map((tr, j) => ( -
- {tr.error ? '失败' : '完成'} -
{tr.error || tr.output}
-
- ))} -
- )} -
-
-
- )} -
- ))} - - {pendingActions.length > 0 && ( -
-
待确认动作
- {pendingActions.map(action => ( -
-
- {action.effect === 'delete' ? '删除' : '写入'} · {action.target} - {action.summary} -
-
- - -
+
+ )}
))} -
- )} - {sending && ( -
-
- -
- - - -
-
+ {pendingActions.length > 0 &&
待确认动作
{pendingActions.map(action =>
{action.effect === 'delete' ? '删除' : '写入'} · {action.target}{action.summary}
)}
} + {sending &&
} +
- )} -
-
- {/* 输入区 */} -
-
-