实体与视图
实体拥有内容和版本规则;View 只组织本次使用的成员。成员退出不删除来源内容,已经捕获的快照保持原值。
先执行 pnpm build,再执行下列命令。所有示例均在本机 Node.js 中运行。
让不同业务实体共享材料读取方式
观察 Plan、Notes 和 Memory 如何通过各自来源适配进入同一个 View,同时保留独立的更新规则。运行后核对旧快照不变、旧读取依据被拒绝,以及显式纳入新便签后成员数增加。
sh
node examples/entities.mjsjs
// 运行:pnpm build && node examples/entities.mjs
// 三种独立内容经过相同的 View/资源/调用;不调用模型,不保存到磁盘。
import assert from 'node:assert/strict';
import { Memories, MemoryViewSource } from '@weiliang0121/capix-memory';
import { Resource, ResourceOffer } from '@weiliang0121/capix-resources';
import { Node, Scene } from '@weiliang0121/capix-structure';
import { Resources, Runner } from '@weiliang0121/capix-runtime';
import { View, createViewSource, openView } from '@weiliang0121/capix-view';
import { Plan, Notes } from './entities/domain.mjs';
// 1. 业务拥有自己的形状和更新规则;Memory 只是现成的总结实体。
const plan = new Plan({ title: '准备发布', completed: 0 });
const notes = new Notes();
const note = notes.append({ title: '验收范围', text: '先验证公开包的独立消费。' });
const memories = new Memories();
const memory = memories.apply([{ action: 'create', value: { content: '用户希望例子简单,边界明确。' } }]).changed[0];
// 2. 单值对象只有一个 key;适配只负责读取,不替业务规定内容或版本规则。
const requirePlan = (key) => {
if (key !== 'current') throw new RangeError('Unknown plan key');
return plan.read();
};
const sources = {
plan: createViewSource({
id: plan.id,
scope: 'whole',
version: () => String(plan.read().basis.revision),
keys: () => ['current'],
inspect: (key) => ({ title: requirePlan(key).value.title }),
read: requirePlan,
}),
notes: createViewSource({
id: notes.id,
scope: 'whole',
version: () => notes.version,
keys: () => notes.keys(),
inspect: (key) => ({ title: notes.read(key).value.title }),
read: (key) => notes.read(key),
}),
memory: new MemoryViewSource(memories),
};
const view = new View({
revision: 0,
sources: { plan: { id: plan.id, scope: 'whole' }, notes: { id: notes.id, scope: 'whole' }, memory: { id: memories.id, scope: 'whole' } },
members: [
{ source: 'plan', key: 'current' },
{ source: 'notes', key: note.id },
{ source: 'memory', key: memory.id },
],
});
// 3. 登记普通资源。查看材料和修改业务是两个独立申请。
const materials = new ResourceOffer({ expose: ({ view, sources }, activity) => openView(view, sources, activity) });
const plans = new ResourceOffer({
methods: {
revise(origin, { value, expected }) {
return origin.revise(value, expected);
},
},
});
const resources = new Resources().register(new Resource({ view, sources }), materials).register(new Resource(plan), plans);
const prepare = new Node({
deps: { materials: materials.request(), plan: plans.request() },
async run(work) {
const snapshot = await work.resources.materials.capture();
const previous = snapshot.entries.find((entry) => entry.source === 'plan').value;
await work.resources.plan.revise({ value: { ...previous.value, completed: previous.value.completed + 1 }, expected: previous.basis });
return snapshot; // 已取得的材料保持原值,与业务对象后续更新分开。
},
});
const runner = new Runner(new Scene(prepare), resources).build();
const before = await view.index(sources);
const first = await runner.run();
// 4. 新材料不会自动纳入 View,哪些值得保留由业务策略选择。
const nextNote = notes.append({ title: '已完成验收', text: '公开入口可以独立安装并运行。' });
memories.apply([{ action: 'update', memoryId: memory.id, value: { content: '公开包已验收,下一步整理发布说明。' } }]);
view.includeSync(sources, [{ source: 'notes', key: nextNote.id }]);
const second = await runner.run();
assert.equal(first.entries[0].value.value.completed, 0);
assert.equal(second.entries[0].value.value.completed, 1);
assert.equal(plan.read().value.completed, 2);
assert.equal(first.entries[2].value.value.content, '用户希望例子简单,边界明确。');
await assert.rejects(view.capture(sources, before), (error) => error.code === 'VIEW_CONFLICT');
console.log('三种实体共享一条读取链:', first.entries.map((entry) => entry.source).join(' / '));
console.log('两次材料中的完成数:0 → 1;业务对象当前完成数:2。');
console.log(`明确纳入新材料后,成员数:${first.entries.length} → ${second.entries.length}。旧依据拒绝,旧材料不变。`);
// 可选 CP/记录的接线与边界见 docs/custom-entities.md。
// 保存材料不等于保存或回滚这三个实体,更不等于可以安全重做 revise。准备材料后调整下一次读取范围
将消息、总结和静态指令组织成材料,再用独立管理操作让旧助手消息退出 View。运行后两次捕获的成员数为 4 和 3,原始两条消息仍存在,第一份快照仍有 4 项。
sh
node examples/view.mjsjs
import assert from 'node:assert/strict';
// 运行:pnpm build && node examples/view.mjs
// 全部使用内存数据。本例准备材料并维护视图,不调用外部模型。
import { Transcript, TranscriptViewSource } from '@weiliang0121/capix-transcript';
import { Memories, MemoryViewSource } from '@weiliang0121/capix-memory';
import { View, openView, openViewManager } from '@weiliang0121/capix-view';
import { Resource, ResourceOffer } from '@weiliang0121/capix-resources';
import { Node, Scene, Sequence } from '@weiliang0121/capix-structure';
import { Resources, Runner } from '@weiliang0121/capix-runtime';
import { createSkillSource } from './view/skills.mjs';
// 1. 宿主建立独立实体。消息与记忆继续由各自实体管理。
const transcript = new Transcript([
{ role: 'user', content: '翻译时请用简短的中文。' },
{ role: 'assistant', content: '好的,我会简短回答。' },
]);
const memories = new Memories();
const {
changed: [preference],
} = memories.apply([
{
action: 'create',
reason: '记录用户明确表达的翻译偏好',
value: { content: '使用简短的中文。' },
},
]);
// 2. 来源提供 index / inspect / read / check;槽位名只在这个 View 内使用。
const sources = {
messages: new TranscriptViewSource(transcript),
memories: new MemoryViewSource(memories),
skills: createSkillSource(),
};
const view = new View({
revision: 0,
sources: {
messages: { id: sources.messages.id, scope: sources.messages.scope },
memories: { id: sources.memories.id, scope: sources.memories.scope },
skills: { id: sources.skills.id, scope: sources.skills.scope },
},
members: [
{ source: 'messages', key: 0 },
{ source: 'messages', key: 1 },
{ source: 'memories', key: preference.id },
{ source: 'skills', key: 'translate' },
],
});
// 3. 资源适配显式连接 View、来源与当前访问窗口。
// 同一材料提供 read/manage 两种操作;read 端点不能修改成员。
const material = new Resource({ view, sources });
const read = new ResourceOffer({
expose: ({ view, sources }, activity) => openView(view, sources, activity),
});
const manage = new ResourceOffer({
expose: ({ view, sources }, activity) => openViewManager(view, sources, activity),
});
const resources = new Resources().register(material, read).register(material, manage);
// 4. 业务节点只申请所需操作,不取得原始实体或来源适配器。
const prepare = new Node({
deps: { context: read.request() },
async run(work) {
const index = await work.resources.context.index(); // 当前成员的摘要与版本依据。
const snapshot = await work.resources.context.capture(index); // 按同一依据读取正文。
// 后续模型节点可以按业务规则把 input 和 snapshot 组织成模型请求。
return { input: work.input, snapshot };
},
});
const maintain = new Node({
deps: { context: manage.request() },
run(work) {
const index = work.resources.context.index();
// 本例自定的后处理策略:旧助手消息退出视图。框架不预设这个规则。
const members = index.entries.filter((entry) => entry.source !== 'messages' || entry.summary.role !== 'assistant').map(({ source, key }) => ({ source, key }));
work.resources.context.apply({ basis: index.basis, members });
// 这次已经取得的 snapshot 保持不变;成员调整影响下一次捕获。
return work.input;
},
});
// 5. 顺序预设组合业务节点,Runner 装配结构与资源,再启动调用。
const scene = new Scene(new Sequence([prepare, maintain]));
const runner = new Runner(scene, resources).build();
const first = await runner.run('hello');
const second = await runner.run('good morning');
console.log('第一次准备的材料:');
console.table(first.snapshot.entries.map(({ source, value }) => ({ source, content: value.content ?? value.value?.content ?? value.instructions })));
console.log(`两次捕获的成员数:${first.snapshot.entries.length} → ${second.snapshot.entries.length}`);
console.log(`原始实体仍保留:${transcript.length} 条消息、${memories.values().length} 条记忆`);
console.log(`首份快照仍包含:${first.snapshot.entries.length} 项`);
console.log(`视图修订:${view.exportState().revision}(第二次维护没有成员变化,不增加修订)`);
assert.equal(first.snapshot.entries.length, 4);
assert.equal(second.snapshot.entries.length, 3);
assert.equal(first.snapshot.entries.filter((entry) => entry.source === 'messages').length, 2);
assert.equal(second.snapshot.entries.filter((entry) => entry.source === 'messages').length, 1);
assert.equal(transcript.length, 2);
assert.equal(memories.values().length, 1);
assert.equal(view.exportState().revision, 1);
assert.equal(runner.isRunning, false);按已知引用维护成员
用刚追加的消息索引纳入新成员,并让指定旧成员退出,无需先读取全部正文。运行后核对重复执行同一维护决定不增加 View 修订,来源消息和此前快照都保持完整。
sh
node examples/view-membership.mjsjs
import assert from 'node:assert/strict';
// pnpm build && node examples/view-membership.mjs
// 已知引用的维护策略:通过原子操作修改成员,不先读取所有材料。
import { Resource, ResourceOffer } from '@weiliang0121/capix-resources';
import { Resources, Runner } from '@weiliang0121/capix-runtime';
import { Node, Scene } from '@weiliang0121/capix-structure';
import { Transcript, TranscriptViewSource } from '@weiliang0121/capix-transcript';
import { View, openViewManager } from '@weiliang0121/capix-view';
const transcript = new Transcript([{ role: 'user', content: '旧话题:介绍一下这个项目。' }]);
const sources = { messages: new TranscriptViewSource(transcript) };
const view = new View({
revision: 0,
sources: { messages: { id: sources.messages.id, scope: sources.messages.scope } },
members: [{ source: 'messages', key: 0 }],
});
const before = view.captureSync(sources);
// 宿主连接实体与来源。只把受控的管理端点提供给策略节点。
const manage = new ResourceOffer({ expose: ({ view, sources }, activity) => openViewManager(view, sources, activity) });
const resources = new Resources().register(new Resource({ view, sources }), manage);
// 策略只决定哪些消息纳入或退出;去重、顺序、来源核验由 View 完成。
// 这里的两个操作分别提交;如果策略需要基于快照整体筛选或重排,使用 apply。
const maintain = new Node({
deps: { view: manage.request() },
run(work) {
work.resources.view.include(work.input.appended.map((key) => ({ source: 'messages', key })));
work.resources.view.exclude(work.input.retired.map((key) => ({ source: 'messages', key })));
return work.resources.view.members();
},
});
const runner = new Runner(new Scene(maintain), resources).build();
// 正式写入与视图维护分开。业务提供刚落实的索引,无需重新扫描历史。
transcript.append({ role: 'user', content: '现在讨论资源包。' }, { role: 'assistant', content: 'Resource 保存实体,Offer 定义访问操作。' });
const decision = { appended: [1, 2], retired: [0] };
const members = await runner.run(decision);
assert.deepEqual(members, [
{ source: 'messages', key: 1 },
{ source: 'messages', key: 2 },
]);
console.log('维护后的成员:', members);
const revision = view.exportState().revision;
await runner.run(decision);
console.log(`重复执行没有变更:revision ${revision} → ${view.exportState().revision}`);
console.log(
'当前材料:',
view.captureSync(sources).entries.map((entry) => entry.value.content),
);
console.log(
'此前快照仍是:',
before.entries.map((entry) => entry.value.content),
);
console.log(`来源仍保留 ${transcript.length} 条正式消息;退出视图不删除消息。`);
assert.equal(view.exportState().revision, revision);
assert.equal(transcript.length, 3);
assert.deepEqual(
before.entries.map((entry) => entry.value.content),
['旧话题:介绍一下这个项目。'],
);
assert.deepEqual(
view.captureSync(sources).entries.map((entry) => entry.key),
[1, 2],
);
assert.equal(runner.isRunning, false);为业务内容定义自己的更新规则
对照 Plan 的预期版本核验与 Notes 的只追加规则,理解内容格式、身份和版本如何由业务对象维护。这是上方实体示例使用的定义文件;固定旧材料并不代表保存了可查询历史或持久数据。
js
import { randomUUID } from 'node:crypto';
import { stableValue } from '@weiliang0121/capix-contracts';
// 这是示例自己的业务对象,没有 Entity 基类,也没有隐含持久化。
export class Plan {
id = randomUUID();
#material;
constructor(value) {
if (!value.title || !Number.isSafeInteger(value.completed) || value.completed < 0) throw new TypeError('Invalid plan');
this.#material = stableValue({ basis: { id: this.id, revision: 0 }, value });
}
read() {
return this.#material;
}
revise(value, expected) {
if (expected.id !== this.id || expected.revision !== this.#material.basis.revision) throw new Error('Plan has changed');
if (!value.title || !Number.isSafeInteger(value.completed) || value.completed < 0) throw new TypeError('Invalid plan');
// 固定下一版再替换指向。旧材料继续有效,但本例不保存可查询的历史版本。
this.#material = stableValue({ basis: { id: this.id, revision: this.#material.basis.revision + 1 }, value });
return this.#material;
}
}
// 普通追加材料不需要消息 role、记忆 topic 或来源列表。
// #region notes
export class Notes {
id = randomUUID();
#revision = 0;
#items = new Map();
get version() {
return String(this.#revision);
}
keys() {
return [...this.#items.keys()];
}
read(key) {
if (!this.#items.has(key)) throw new RangeError('Note does not exist');
return this.#items.get(key);
}
append(value) {
if (!value.title || !value.text) throw new TypeError('A note requires title and text');
const item = stableValue({ id: randomUUID(), value });
this.#items.set(item.id, item);
this.#revision++;
return item;
}
}
// #endregion notes把静态指令接入材料视图
查看如何用来源适配提供指令的摘要和正文,再回到上方 View 示例核对它进入材料快照。这份示例内容构造后不变,因此版本固定;它不提供框架级 Skill 注册或执行能力。
js
import { randomUUID } from 'node:crypto';
import { createViewSource } from '@weiliang0121/capix-view';
// 示例自己的静态 Skill 来源,不是框架新增的 Skill 实体或注册平台。
// 内容构造后不变,因此版本固定;可变来源必须在成员/正文变化时换版本。
export function createSkillSource() {
const values = new Map([['translate', Object.freeze({ instructions: '把输入翻译为中文;遵循材料中的用户偏好。' })]]);
const requireSkill = (key) => {
if (!values.has(key)) throw new RangeError('Skill does not exist in this source scope');
return values.get(key);
};
return createViewSource({
id: randomUUID(),
scope: 'whole',
version: () => '1',
keys: () => [...values.keys()],
inspect(key) {
requireSkill(key);
return { title: '中文翻译' };
},
read: requireSkill,
});
}