feat: add HoloLake personal channel module runtime
This commit is contained in:
parent
bb96d5eb89
commit
a106b23f9b
21 changed files with 786 additions and 31 deletions
|
|
@ -0,0 +1,35 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyChannelPatch, defaultChannelState, KNOWLEDGE_MODULE_ID, normalizeChannelState } from './channel-runtime.js';
|
||||
|
||||
test('module removal unmounts the module but preserves a reversible before state', () => {
|
||||
const receipt = applyChannelPatch(defaultChannelState('2026-08-09T00:00:00.000Z'), {
|
||||
operation: 'set_module_state',
|
||||
moduleId: KNOWLEDGE_MODULE_ID,
|
||||
installed: false,
|
||||
}, '2026-08-09T00:01:00.000Z');
|
||||
assert.equal(receipt.after.modules[0].installed, false);
|
||||
assert.equal(receipt.after.modules[0].mounted, false);
|
||||
assert.equal(receipt.before.modules[0].installed, true);
|
||||
assert.equal(receipt.after.revision, 1);
|
||||
});
|
||||
|
||||
test('unknown modules fail closed instead of becoming UI-only entries', () => {
|
||||
assert.throws(() => applyChannelPatch(defaultChannelState(), {
|
||||
operation: 'set_module_state',
|
||||
moduleId: 'UNREGISTERED-MODULE',
|
||||
installed: true,
|
||||
}), /未在光湖注册表登记/);
|
||||
});
|
||||
|
||||
test('stored state is normalized against the current official registry', () => {
|
||||
const state = normalizeChannelState({
|
||||
schema: 'hololake.personal-channel-state/v1',
|
||||
channelId: 'CHANNEL-ICE-001',
|
||||
revision: 3,
|
||||
updatedAt: '2026-08-09T00:00:00.000Z',
|
||||
modules: [{ id: KNOWLEDGE_MODULE_ID, installed: false, mounted: true, order: 0 }],
|
||||
});
|
||||
assert.equal(state.modules[0].installed, false);
|
||||
assert.equal(state.modules[0].mounted, false);
|
||||
});
|
||||
126
product-source/guanghu-knowledge-base/server/channel-runtime.ts
Normal file
126
product-source/guanghu-knowledge-base/server/channel-runtime.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import crypto from 'crypto';
|
||||
|
||||
export const KNOWLEDGE_MODULE_ID = 'HL-MOD-KNOWLEDGE-001';
|
||||
|
||||
export interface ModuleManifest {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
kind: 'native-application';
|
||||
state: 'LIVE';
|
||||
description: string;
|
||||
capabilities: string[];
|
||||
dataPolicy: string;
|
||||
}
|
||||
|
||||
export interface ChannelModuleState {
|
||||
id: string;
|
||||
installed: boolean;
|
||||
mounted: boolean;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export interface ChannelState {
|
||||
schema: 'hololake.personal-channel-state/v1';
|
||||
channelId: string;
|
||||
revision: number;
|
||||
updatedAt: string;
|
||||
modules: ChannelModuleState[];
|
||||
}
|
||||
|
||||
export interface ChannelPatch {
|
||||
operation: 'set_module_state';
|
||||
moduleId: string;
|
||||
installed?: boolean;
|
||||
mounted?: boolean;
|
||||
}
|
||||
|
||||
export interface ChannelReceipt {
|
||||
schema: 'hololake.channel-receipt/v1';
|
||||
id: string;
|
||||
operation: ChannelPatch;
|
||||
before: ChannelState;
|
||||
after: ChannelState;
|
||||
createdAt: string;
|
||||
reversible: true;
|
||||
}
|
||||
|
||||
export const MODULE_REGISTRY: ModuleManifest[] = [{
|
||||
id: KNOWLEDGE_MODULE_ID,
|
||||
name: '知识库',
|
||||
version: '0.8.0',
|
||||
kind: 'native-application',
|
||||
state: 'LIVE',
|
||||
description: 'Git 驱动的个人知识空间,支持导入、编辑、检索、历史与 HoloLake 语言操作。',
|
||||
capabilities: [
|
||||
'knowledge.import',
|
||||
'knowledge.read',
|
||||
'knowledge.search',
|
||||
'knowledge.edit',
|
||||
'knowledge.history',
|
||||
],
|
||||
dataPolicy: '移除模块只卸载频道入口,不删除用户文档、历史或服务器绑定。',
|
||||
}];
|
||||
|
||||
export function defaultChannelState(now = new Date().toISOString()): ChannelState {
|
||||
return {
|
||||
schema: 'hololake.personal-channel-state/v1',
|
||||
channelId: 'CHANNEL-PERSONAL-DEFAULT',
|
||||
revision: 0,
|
||||
updatedAt: now,
|
||||
modules: [{ id: KNOWLEDGE_MODULE_ID, installed: true, mounted: true, order: 0 }],
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeChannelState(input: unknown): ChannelState {
|
||||
const fallback = defaultChannelState();
|
||||
if (!input || typeof input !== 'object') return fallback;
|
||||
const candidate = input as Partial<ChannelState>;
|
||||
const known = new Set(MODULE_REGISTRY.map(module => module.id));
|
||||
const modules = Array.isArray(candidate.modules)
|
||||
? candidate.modules
|
||||
.filter(module => module && known.has(module.id))
|
||||
.map((module, index) => ({
|
||||
id: module.id,
|
||||
installed: module.installed !== false,
|
||||
mounted: module.installed !== false && module.mounted !== false,
|
||||
order: Number.isFinite(module.order) ? Number(module.order) : index,
|
||||
}))
|
||||
: fallback.modules;
|
||||
for (const manifest of MODULE_REGISTRY) {
|
||||
if (!modules.some(module => module.id === manifest.id)) {
|
||||
modules.push({ id: manifest.id, installed: false, mounted: false, order: modules.length });
|
||||
}
|
||||
}
|
||||
return {
|
||||
schema: 'hololake.personal-channel-state/v1',
|
||||
channelId: typeof candidate.channelId === 'string' && candidate.channelId ? candidate.channelId : fallback.channelId,
|
||||
revision: Number.isInteger(candidate.revision) && Number(candidate.revision) >= 0 ? Number(candidate.revision) : 0,
|
||||
updatedAt: typeof candidate.updatedAt === 'string' ? candidate.updatedAt : fallback.updatedAt,
|
||||
modules: modules.sort((a, b) => a.order - b.order),
|
||||
};
|
||||
}
|
||||
|
||||
export function applyChannelPatch(currentInput: ChannelState, patch: ChannelPatch, now = new Date().toISOString()): ChannelReceipt {
|
||||
const before = normalizeChannelState(currentInput);
|
||||
if (patch.operation !== 'set_module_state') throw new Error('不支持的频道动作');
|
||||
if (!MODULE_REGISTRY.some(module => module.id === patch.moduleId)) throw new Error('模块未在光湖注册表登记');
|
||||
if (typeof patch.installed !== 'boolean' && typeof patch.mounted !== 'boolean') throw new Error('频道动作没有状态变化');
|
||||
|
||||
const modules = before.modules.map(module => {
|
||||
if (module.id !== patch.moduleId) return module;
|
||||
const installed = typeof patch.installed === 'boolean' ? patch.installed : module.installed;
|
||||
const mounted = installed && (typeof patch.mounted === 'boolean' ? patch.mounted : module.mounted);
|
||||
return { ...module, installed, mounted };
|
||||
});
|
||||
const after = { ...before, revision: before.revision + 1, updatedAt: now, modules };
|
||||
return {
|
||||
schema: 'hololake.channel-receipt/v1',
|
||||
id: `HL-CHANNEL-RCPT-${crypto.randomUUID()}`,
|
||||
operation: patch,
|
||||
before,
|
||||
after,
|
||||
createdAt: now,
|
||||
reversible: true,
|
||||
};
|
||||
}
|
||||
|
|
@ -10,8 +10,17 @@ import simpleGit, { SimpleGit, LogResult } from 'simple-git';
|
|||
import matter from 'gray-matter';
|
||||
import { diffLines } from 'diff';
|
||||
import path from 'path';
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs/promises';
|
||||
import fsSync from 'fs';
|
||||
import {
|
||||
applyChannelPatch as compileChannelPatch,
|
||||
defaultChannelState,
|
||||
normalizeChannelState,
|
||||
type ChannelPatch,
|
||||
type ChannelReceipt,
|
||||
type ChannelState,
|
||||
} from './channel-runtime.js';
|
||||
|
||||
// ─── 类型定义 ───
|
||||
|
||||
|
|
@ -74,15 +83,66 @@ export class GitEngine {
|
|||
private git: SimpleGit;
|
||||
private docsDir: string;
|
||||
private repoPath: string;
|
||||
private channelStatePath: string;
|
||||
private channelReceiptsDir: string;
|
||||
|
||||
constructor(repoPath: string) {
|
||||
this.repoPath = repoPath;
|
||||
this.docsDir = path.join(repoPath, 'docs');
|
||||
this.channelStatePath = path.join(repoPath, '.hololake', 'channel-state.json');
|
||||
this.channelReceiptsDir = path.join(repoPath, '.hololake', 'receipts');
|
||||
// simple-git 要求目录先存在,先同步创建
|
||||
fsSync.mkdirSync(repoPath, { recursive: true });
|
||||
this.git = simpleGit(repoPath);
|
||||
}
|
||||
|
||||
async getChannelState(): Promise<ChannelState> {
|
||||
try {
|
||||
return normalizeChannelState(JSON.parse(await fs.readFile(this.channelStatePath, 'utf8')));
|
||||
} catch {
|
||||
return defaultChannelState();
|
||||
}
|
||||
}
|
||||
|
||||
async applyChannelPatch(patch: ChannelPatch): Promise<ChannelReceipt> {
|
||||
const receipt = compileChannelPatch(await this.getChannelState(), patch);
|
||||
await this.persistChannelReceipt(receipt, `channel: ${patch.operation} ${patch.moduleId}`);
|
||||
return receipt;
|
||||
}
|
||||
|
||||
async undoChannelPatch(receiptId: string): Promise<ChannelReceipt> {
|
||||
if (!/^HL-CHANNEL-RCPT-[0-9a-f-]+$/i.test(receiptId)) throw new Error('无效的频道回执编号');
|
||||
const receiptPath = path.join(this.channelReceiptsDir, `${receiptId}.json`);
|
||||
const original = JSON.parse(await fs.readFile(receiptPath, 'utf8')) as ChannelReceipt;
|
||||
const current = await this.getChannelState();
|
||||
if (current.revision !== original.after.revision) throw new Error('频道状态已经继续变化,不能静默覆盖;请按当前状态重新操作');
|
||||
const restored = {
|
||||
...normalizeChannelState(original.before),
|
||||
revision: current.revision + 1,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
const undoReceipt: ChannelReceipt = {
|
||||
schema: 'hololake.channel-receipt/v1',
|
||||
id: `HL-CHANNEL-RCPT-${crypto.randomUUID()}`,
|
||||
operation: original.operation,
|
||||
before: current,
|
||||
after: restored,
|
||||
createdAt: restored.updatedAt,
|
||||
reversible: true,
|
||||
};
|
||||
await this.persistChannelReceipt(undoReceipt, `channel: undo ${receiptId}`);
|
||||
return undoReceipt;
|
||||
}
|
||||
|
||||
private async persistChannelReceipt(receipt: ChannelReceipt, message: string): Promise<void> {
|
||||
await fs.mkdir(this.channelReceiptsDir, { recursive: true });
|
||||
const receiptPath = path.join(this.channelReceiptsDir, `${receipt.id}.json`);
|
||||
await fs.writeFile(this.channelStatePath, `${JSON.stringify(receipt.after, null, 2)}\n`, { mode: 0o600 });
|
||||
await fs.writeFile(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`, { mode: 0o600 });
|
||||
await this.git.add([this.channelStatePath, receiptPath]);
|
||||
await this.git.commit(message, [this.channelStatePath, receiptPath]);
|
||||
}
|
||||
|
||||
/** 初始化仓库(如果不存在则创建) */
|
||||
async init(): Promise<void> {
|
||||
const repoRoot = path.dirname(this.docsDir);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { projectHumanKnowledgeTree } from './language-protocol-kernel.js';
|
|||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import crypto from 'crypto';
|
||||
import { MODULE_REGISTRY } from './channel-runtime.js';
|
||||
|
||||
// __dirname 兼容 CJS 和 ESM
|
||||
const _dirname = typeof __dirname !== 'undefined' ? __dirname : process.cwd();
|
||||
|
|
@ -44,7 +45,7 @@ const p = (v: unknown): string => Array.isArray(v) ? v.join('/') : String(v);
|
|||
app.get('/api/health', (_req, res) => {
|
||||
res.json({
|
||||
module: 'guanghu-knowledge-base',
|
||||
version: '0.1.0',
|
||||
version: '0.8.0',
|
||||
engine: 'git',
|
||||
repo: REPO_PATH,
|
||||
status: 'online',
|
||||
|
|
@ -52,6 +53,38 @@ app.get('/api/health', (_req, res) => {
|
|||
});
|
||||
});
|
||||
|
||||
// ─── 初始化频道与模块运行时 ───
|
||||
|
||||
app.get('/api/modules', (_req, res) => {
|
||||
res.json({ ok: true, registry: MODULE_REGISTRY });
|
||||
});
|
||||
|
||||
app.get('/api/channel', async (_req, res) => {
|
||||
try {
|
||||
res.json({ ok: true, channel: await engine.getChannelState() });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/channel/patch', async (req, res) => {
|
||||
try {
|
||||
const receipt = await engine.applyChannelPatch(req.body);
|
||||
res.json({ ok: true, channel: receipt.after, receipt });
|
||||
} catch (err: any) {
|
||||
res.status(400).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/channel/undo/:receiptId', async (req, res) => {
|
||||
try {
|
||||
const receipt = await engine.undoChannelPatch(req.params.receiptId);
|
||||
res.json({ ok: true, channel: receipt.after, receipt });
|
||||
} catch (err: any) {
|
||||
res.status(409).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── 文档树 ───
|
||||
|
||||
app.get('/api/tree', async (_req, res) => {
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ function toolCall(id: string, name: string, args: Record<string, unknown> = {}):
|
|||
function fakeGit(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
getRepositoryStatus: async () => ({}),
|
||||
getChannelState: async () => ({ revision: 0, modules: [{ id: 'HL-MOD-KNOWLEDGE-001', installed: true, mounted: true, order: 0 }] }),
|
||||
applyChannelPatch: async () => ({ id: 'HL-CHANNEL-RCPT-TEST', after: { revision: 1 } }),
|
||||
getDoc: async (path: string) => ({
|
||||
meta: { id: path, title: path, updatedAt: '2026-08-09T00:00:00Z' },
|
||||
body: '正文',
|
||||
|
|
@ -115,6 +117,29 @@ test('confirmed write receipt never leaks an orphan tool message into the next t
|
|||
});
|
||||
});
|
||||
|
||||
test('channel module changes use the same confirmation gate as document writes', { concurrency: false }, async () => {
|
||||
let executed = 0;
|
||||
await withMockModel([
|
||||
toolCall('call-channel', 'set_channel_module_state', { moduleId: 'HL-MOD-KNOWLEDGE-001', mounted: false }),
|
||||
{ content: '频道动作等待确认。' },
|
||||
], async () => {
|
||||
const agent = createDefaultPersona(fakeGit({
|
||||
applyChannelPatch: async () => {
|
||||
executed += 1;
|
||||
return { id: 'HL-CHANNEL-RCPT-TEST', after: { revision: 1 } };
|
||||
},
|
||||
}));
|
||||
const staged = await agent.chat('收起知识库');
|
||||
assert.equal(staged.activities[0]?.status, 'pending');
|
||||
assert.equal(executed, 0);
|
||||
const pending = agent.getPendingActions()[0];
|
||||
assert.equal(pending.target, 'HL-MOD-KNOWLEDGE-001');
|
||||
const receipt = await agent.confirmAction(pending.id);
|
||||
assert.equal(executed, 1);
|
||||
assert.match(receipt.output, /HL-CHANNEL-RCPT-TEST/);
|
||||
});
|
||||
});
|
||||
|
||||
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([
|
||||
|
|
|
|||
|
|
@ -185,6 +185,34 @@ export class PersonaAgent {
|
|||
execute: async () => JSON.stringify(await this.git.getRepositoryStatus(), null, 2),
|
||||
});
|
||||
|
||||
this.tools.register({
|
||||
name: 'inspect_channel',
|
||||
description: '读取当前个人初始化频道的模块安装与挂载状态(只读)',
|
||||
parameters: {},
|
||||
effect: 'read',
|
||||
execute: async () => JSON.stringify(await this.git.getChannelState(), null, 2),
|
||||
});
|
||||
|
||||
this.tools.register({
|
||||
name: 'set_channel_module_state',
|
||||
description: '安装、打开、收起或移除已在光湖注册表登记的频道模块;执行前必须由人类确认',
|
||||
parameters: {
|
||||
moduleId: { type: 'string', description: '光湖稳定模块编号', required: true },
|
||||
installed: { type: 'boolean', description: '是否安装;不改变时可以省略', required: false },
|
||||
mounted: { type: 'boolean', description: '是否在当前频道展开;不改变时可以省略', required: false },
|
||||
},
|
||||
effect: 'write',
|
||||
execute: async (params) => {
|
||||
const receipt = await this.git.applyChannelPatch({
|
||||
operation: 'set_module_state',
|
||||
moduleId: String(params.moduleId || ''),
|
||||
installed: typeof params.installed === 'boolean' ? params.installed : undefined,
|
||||
mounted: typeof params.mounted === 'boolean' ? params.mounted : undefined,
|
||||
});
|
||||
return `频道状态已更新;回执 ${receipt.id};当前修订 ${receipt.after.revision}`;
|
||||
},
|
||||
});
|
||||
|
||||
// 读取知识库文档
|
||||
this.tools.register({
|
||||
name: 'read_document',
|
||||
|
|
@ -465,7 +493,7 @@ ${toolsList}
|
|||
if (!tool || tool.effect === 'read') {
|
||||
return { id: call.id, name: call.name, output: '', error: `动作不可登记: ${call.name}` };
|
||||
}
|
||||
const target = String(call.arguments.path || call.arguments.title || '当前知识库');
|
||||
const target = String(call.arguments.path || call.arguments.moduleId || call.arguments.title || '当前知识库');
|
||||
const actionId = `action-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
|
||||
const action: PendingAction = {
|
||||
id: actionId,
|
||||
|
|
@ -641,6 +669,8 @@ export function createDefaultPersona(git: GitEngine): PersonaAgent {
|
|||
systemPromptBase: '你是 HoloLake 官方语言操作入口的当前模型计算实例。语言协议、权限边界、路径校验和可核验回执高于模型自由发挥。',
|
||||
tools: [
|
||||
'inspect_repository',
|
||||
'inspect_channel',
|
||||
'set_channel_module_state',
|
||||
'read_document',
|
||||
'create_document',
|
||||
'update_document',
|
||||
|
|
|
|||
Loading…
Reference in a new issue