cortico-world-canvas 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/tools.ts ADDED
@@ -0,0 +1,111 @@
1
+ import type { ToolSchema, ToolTag } from 'cortico/core/types.ts';
2
+ import { PAINT_BRUSHES } from './brushes.ts';
3
+
4
+ const point = {
5
+ type: 'object', properties: { x: { type: 'number' }, y: { type: 'number' } }, required: ['x', 'y'], additionalProperties: false,
6
+ };
7
+ const title = { type: 'string', minLength: 1, maxLength: 120 };
8
+ const id = { type: 'string', description: '快照中返回的完整 ID。' };
9
+ const parameters = (properties: Record<string, unknown>, required: string[] = []) => ({ type: 'object', properties, required, additionalProperties: false });
10
+ const inspection = {
11
+ region: { ...parameters({ x: { type: 'integer', minimum: 0 }, y: { type: 'integer', minimum: 0 }, width: { type: 'integer', minimum: 1 }, height: { type: 'integer', minimum: 1 } }, ['x', 'y', 'width', 'height']), description: '可选局部区域,使用原图像素坐标;小区域最多放大四倍。' },
12
+ grid: { type: 'boolean', description: '附加原图像素坐标网格,便于定位;不写入画布或参考图。' },
13
+ };
14
+ const color = { type: 'string', pattern: '^#[0-9a-fA-F]{6}$' };
15
+ const ink = {
16
+ brush: { type: 'string', enum: PAINT_BRUSHES, default: 'liner', description: 'liner 为清晰线稿;2B/HB/2H/cpencil 为铅笔,marker 为马克笔,charcoal 为炭笔,spray 为喷点。' },
17
+ color,
18
+ width: { type: 'number', minimum: 0.2, maximum: 128, default: 3, description: '标称笔宽,像素;纹理笔刷实际覆盖范围因压力和散布而异。' },
19
+ };
20
+ const action = parameters({
21
+ label: { ...title, description: '部件名称,便于定位修改,如“左侧刘海底色”“左眼上眼睑”。' },
22
+ path: { type: 'string', maxLength: 16000, description: '绝对像素路径,每段都写命令:M x y 起笔;L x y 直线;Q cx cy x y 二次曲线;C c1x c1y c2x c2y x y 三次曲线;Z 闭合。可用多个 M 表示断开的笔画。填色和排线的每个子路径都必须 Z 闭合。' },
23
+ fill: parameters({ color, mode: { type: 'string', enum: ['solid', 'watercolor'], default: 'solid' }, opacity: { type: 'number', minimum: 0.01, maximum: 1, default: 1 }, bleed: { type: 'number', minimum: 0, maximum: 0.2, default: 0.025, description: '水彩边缘扩散。' }, texture: { type: 'number', minimum: 0, maximum: 1, default: 0.25, description: '水彩纸纹强度。' } }, ['color']),
24
+ stroke: parameters({ ...ink, pressure: { type: 'array', items: { type: 'number', minimum: 0.05, maximum: 3 }, minItems: 3, maxItems: 3, description: '起笔、中段、收笔压力,默认 [0.7,1,0.7];[1,1,1] 为等宽线。' } }, ['color']),
25
+ hatch: parameters({ ...ink, spacing: { type: 'number', minimum: 2, maximum: 128, default: 6 }, angle: { type: 'number', minimum: -360, maximum: 360, default: 45, description: '排线方向,度。' } }, ['color']),
26
+ clip_to: { ...id, description: '可选:将本动作限制在更早的闭合路径步骤内,填写该步骤 ID。适合阴影、高光和纹理。' },
27
+ matrix: { type: 'array', items: { type: 'number' }, minItems: 6, maxItems: 6, description: '可选仿射矩阵 [a,b,c,d,e,f];通常保留快照或 previous 中的值,整体改比例用 canvas_transform。' },
28
+ instances: { type: 'array', items: { type: 'array', items: { type: 'number' }, minItems: 6, maxItems: 6 }, minItems: 1, maxItems: 64, description: '可选:用多个局部仿射矩阵重复同一路径,如雨点或刻度。先应用每个 instances,再应用全局 matrix;所有实例共用一个步骤 ID。省略时绘制一次,每批最多 256 个实例。' },
29
+ }, ['label', 'path']);
30
+ const nullable = (schema: unknown) => ({ anyOf: [schema, { type: 'null' }] });
31
+ const patchProperties = Object.fromEntries(Object.entries(action.properties).map(([key, schema]) => {
32
+ if (key === 'fill' || key === 'stroke' || key === 'hatch') {
33
+ const nested = schema as ReturnType<typeof parameters>;
34
+ return [key, nullable(parameters(Object.fromEntries(Object.entries(nested.properties).map(([field, value]) => [field, nullable(value)]))))];
35
+ }
36
+ return [key, key === 'clip_to' || key === 'instances' ? nullable(schema) : schema];
37
+ }));
38
+ const patch = { ...parameters(patchProperties), description: '仅修改提供的字段;fill/stroke/hatch 按子字段合并。null 删除样式或样式子字段(可选字段恢复默认值),clip_to:null 清除裁剪,instances:null 恢复单次绘制。' };
39
+
40
+ export const CANVAS_TOOLS: (ToolSchema & { tags: readonly ToolTag[] })[] = [
41
+ {
42
+ name: 'canvas_new', tags: ['act'], description: '新建并命名画布;原画布自动存档,参考图队列保留。返回新画布截图。',
43
+ parameters: parameters({ title, width: { type: 'integer', minimum: 64, maximum: 2048, default: 1024 }, height: { type: 'integer', minimum: 64, maximum: 2048, default: 1024 }, background: { type: 'string', description: '#RRGGBB,默认 #ffffff。' } }, ['title']),
44
+ },
45
+ {
46
+ name: 'canvas_save', tags: ['act'], description: '将当前画布保存为 PNG 文件,返回文件路径和下载地址。每次保存生成新文件。',
47
+ parameters: parameters({ filename: { type: 'string', description: '可选文件名(无需扩展名),不能包含目录。' } }),
48
+ },
49
+ {
50
+ name: 'canvas_draw', tags: ['act'], description: '用 p5.brush 按顺序绘制一批路径部件。每个部件至少指定 fill、stroke、hatch 一项。可组合铺色、带压力的笔画、阴影排线;后画的在上层。整批成功后返回步骤 ID 与截图。坐标原点在左上,单位像素。',
51
+ parameters: parameters({
52
+ actions: { type: 'array', minItems: 1, maxItems: 64, items: action },
53
+ }, ['actions']),
54
+ },
55
+ {
56
+ name: 'canvas_transform', tags: ['act'], description: '整体移动、缩放或旋转选定的 p5.brush 部件,保持笔宽、ID 和遮挡顺序,返回截图和 previous 供 canvas_edit 还原。共同变换轮廓与其阴影时,须同时选中相关步骤。',
57
+ parameters: parameters({ step_ids: { type: 'array', items: id, minItems: 1, maxItems: 64, uniqueItems: true }, dx: { type: 'number', minimum: -4096, maximum: 4096, default: 0 }, dy: { type: 'number', minimum: -4096, maximum: 4096, default: 0 }, scale_x: { type: 'number', minimum: 0.05, maximum: 20, default: 1 }, scale_y: { type: 'number', minimum: 0.05, maximum: 20, description: '默认与 scale_x 相同。' }, rotate: { type: 'number', minimum: -360, maximum: 360, default: 0, description: '顺时针角度。先缩放,再旋转,最后平移。' }, origin: { ...point, description: '缩放和旋转中心,默认 (0,0)。' } }, ['step_ids']),
58
+ },
59
+ {
60
+ name: 'canvas_edit', tags: ['act'], description: '按 step_id 原位修改部件:action 完整替换,或 patch 只改指定字段,二选一。保留 ID、随机种子及遮挡顺序。整批成功后返回截图和 previous;将 previous 作为 updates 再调用即可还原。后续连通填充会重新计算。',
61
+ parameters: parameters({ updates: { type: 'array', minItems: 1, maxItems: 64, items: { ...parameters({ step_id: id, action, patch }, ['step_id']), oneOf: [{ required: ['action'], not: { required: ['patch'] } }, { required: ['patch'], not: { required: ['action'] } }] } } }, ['updates']),
62
+ },
63
+ {
64
+ name: 'canvas_snapshot', tags: ['read', 'snapshot'], description: '查看当前画布截图、尺寸、标题和全部有效绘制步骤(含 ID 和参数)。',
65
+ parameters: parameters(inspection),
66
+ },
67
+ {
68
+ name: 'canvas_undo', tags: ['act'], description: '撤销指定 step_id 的单个步骤,并重放其余步骤。若有 clip_to 引用它,须先修改或撤销那些依赖步骤。返回撤销后的截图。',
69
+ parameters: parameters({ step_id: id }, ['step_id']),
70
+ },
71
+ {
72
+ name: 'canvas_references', tags: ['read', 'snapshot'], description: '列出人类上传的参考图名称与 ID。传入 ids 可查看最多 8 张原图;不传则查看队列前 8 张。',
73
+ parameters: parameters({ ids: { type: 'array', items: id, maxItems: 8, description: '查看 region 局部时须选择一张图片。' }, ...inspection }),
74
+ },
75
+ {
76
+ name: 'canvas_game_enter', tags: ['act'], description: '进入「你画我猜」:画室网页切换为游戏界面(画板 + 计分板),回执附完整主持流程与计分规则。已在游戏中时只重发说明。',
77
+ parameters: parameters({}),
78
+ },
79
+ {
80
+ name: 'canvas_game_round', tags: ['act'], description: '开始一局:登记题目并启动倒计时,网页弹出计时器、字数和公开提示,题目本身不显示。倒计时结束会收到事件。上一局未揭示或未取消时不能开新局。',
81
+ parameters: parameters({
82
+ answer: { type: 'string', minLength: 1, maxLength: 40, description: '题目,观众看不到;揭示前不能说出来。' },
83
+ seconds: { type: 'integer', minimum: 10, maximum: 1800, default: 300, description: '倒计时秒数。' },
84
+ hint: { type: 'string', maxLength: 40, description: '可选公开提示,如「水果」「四字成语」。' },
85
+ }, ['answer']),
86
+ },
87
+ {
88
+ name: 'canvas_game_timer', tags: ['act'], description: '调整本局倒计时:seconds 从现在起重新计时(延长或缩短);stop 作废本局且不揭示答案。二选一。',
89
+ parameters: parameters({ seconds: { type: 'integer', minimum: 10, maximum: 1800 }, stop: { type: 'boolean' } }),
90
+ },
91
+ {
92
+ name: 'canvas_game_reveal', tags: ['act'], description: '结算本局:停止倒计时,网页弹出揭示动画公布题目。winners 是本局得分者名字(按名次),只用于展示;分数须先用 canvas_game_score 写入。',
93
+ parameters: parameters({ winners: { type: 'array', items: { type: 'string', minLength: 1, maxLength: 40 }, maxItems: 20 } }),
94
+ },
95
+ {
96
+ name: 'canvas_game_scoreboard', tags: ['read', 'snapshot'], description: '查看游戏状态:模式、本局题目与剩余秒数、上一局揭示结果,以及按分数排序的完整计分板。',
97
+ parameters: parameters({}),
98
+ },
99
+ {
100
+ name: 'canvas_game_score', tags: ['act'], description: '写入计分板,单个或批量:每项 name 配 add(加减分)或 set(设定分)。remove 删除名字;reset 先清空再写入。回执为更新后的排名。',
101
+ parameters: parameters({
102
+ entries: { type: 'array', maxItems: 64, items: parameters({ name: { type: 'string', minLength: 1, maxLength: 40 }, add: { type: 'integer', minimum: -100000, maximum: 100000 }, set: { type: 'integer', minimum: -100000, maximum: 100000 } }, ['name']) },
103
+ remove: { type: 'array', maxItems: 64, items: { type: 'string', minLength: 1, maxLength: 40 } },
104
+ reset: { type: 'boolean', description: '清空计分板;与 entries 同时给时先清空再写入。' },
105
+ }),
106
+ },
107
+ {
108
+ name: 'canvas_game_exit', tags: ['act'], description: '退出游戏回到自由画板:未揭示的一局作废,计分板保留(下次进入仍在,reset 才清零)。',
109
+ parameters: parameters({}),
110
+ },
111
+ ];
package/src/world.ts ADDED
@@ -0,0 +1,120 @@
1
+ import { fork, type ChildProcess } from 'node:child_process';
2
+ import { logLines } from 'cortico/core/ipc-logger.ts';
3
+ import { nowIso } from 'cortico/core/util.ts';
4
+ import { fileURLToPath } from 'node:url';
5
+ import type { World, WorldHost, WorldConsoleDecl, ToolDef } from 'cortico/core/types.ts';
6
+ import { CANVAS_CONFIG_GROUP, type CanvasConfigSection } from './config.ts';
7
+ import { CANVAS_TOOLS } from './tools.ts';
8
+ import type { CanvasReply } from './server.ts';
9
+ import type { Reference } from './store.ts';
10
+
11
+ export class CanvasWorld implements World {
12
+ readonly id = 'canvas';
13
+ private child: ChildProcess | null = null;
14
+ private host: WorldHost | null = null;
15
+ private url = '';
16
+ private nextId = 0;
17
+ private closed: Promise<void> = Promise.resolve();
18
+ private pending = new Map<number, { resolve(value: unknown): void; reject(error: Error): void }>();
19
+
20
+ constructor(private readonly options: { cfg: CanvasConfigSection; directory: string; timezone?: string }) {}
21
+ envPromptVars() { return {}; }
22
+
23
+ console(): WorldConsoleDecl {
24
+ return {
25
+ lamps: [{ label: '画布', state: this.child?.connected ? 'online' : 'offline' }],
26
+ links: this.url ? [{ label: '打开画室', href: this.url }, { label: '直播 Overlay', href: `${this.url}/overlay` }] : [],
27
+ config: [CANVAS_CONFIG_GROUP],
28
+ promptDocs: [
29
+ { key: 'worlds.canvas.envPrompt', title: '画布 · 环境提示词', description: '绘图、参考图与多模态回执的使用规则。', role: 'envPrompt', path: fileURLToPath(new URL('./ENV_PROMPT.md', import.meta.url)) },
30
+ { key: 'worlds.canvas.gameGuide', title: '画布 · 你画我猜主持说明', description: '进入游戏时随 canvas_game_enter 回执发出的流程与计分规则。', path: fileURLToPath(new URL('./GAME_GUIDE.md', import.meta.url)) },
31
+ ],
32
+ };
33
+ }
34
+
35
+ tools(): ToolDef[] {
36
+ return CANVAS_TOOLS.map((tool) => ({ ...tool, handler: async (args, ctx) => {
37
+ ctx.signal?.throwIfAborted();
38
+ const abort = () => this.fail(new Error('画布调用已取消;服务需重新启动'));
39
+ ctx.signal?.addEventListener('abort', abort, { once: true });
40
+ try {
41
+ const result = await this.rpc(tool.name, args) as CanvasReply;
42
+ // 图随回执落库:每张的文本形态(fallback)由 core 接在正文后,这里不重复写进 text。
43
+ const blobs = result.images?.map((image) => ({ bytes: Buffer.from(image.png, 'base64'), mime: 'image/png', fallbackText: image.fallback }));
44
+ return { text: result.text, ...(blobs?.length ? { blobs } : {}) };
45
+ } finally { ctx.signal?.removeEventListener('abort', abort); }
46
+ } }));
47
+ }
48
+
49
+ async start(host: WorldHost): Promise<void> {
50
+ await this.closed;
51
+ this.host = host;
52
+ const child = fork(fileURLToPath(new URL('./engine-child.ts', import.meta.url)), [], {
53
+ execArgv: ['--import', 'tsx'], stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
54
+ });
55
+ this.child = child;
56
+ this.closed = new Promise<void>((resolve) => { child.once('exit', () => resolve()); child.once('error', () => resolve()); });
57
+ logLines(child.stderr, host.log.child('stdio'), 'warn', 'stderr');
58
+ child.on('error', (error) => { if (this.child === child) { host.log.emit('error', '画布引擎子进程出错', { event: 'child-error', err: error }); this.fail(error); } });
59
+ child.on('exit', (code, signal) => {
60
+ if (this.child !== child) return;
61
+ host.log.emit('error', '画布引擎子进程退出', { event: 'exit', data: { exitCode: code, signal } });
62
+ this.fail(new Error('画布引擎已退出'));
63
+ });
64
+ child.on('message', (message: { id?: number; value?: unknown; error?: string; event?: string; references?: Reference[]; text?: string }) => {
65
+ if (message.event === 'references') {
66
+ void host.pushEvent({ source: this.id, type: 'canvas.references', ts: nowIso(this.options.timezone ?? 'Asia/Shanghai'),
67
+ text: `[画布] 人类更新了参考图:${JSON.stringify(message.references)}。可用 canvas_references 查看。`,
68
+ }).catch((error) => host.log.error(String(error)));
69
+ return;
70
+ }
71
+ if (message.event === 'game') {
72
+ void host.pushEvent({ source: this.id, type: 'canvas.game', ts: nowIso(this.options.timezone ?? 'Asia/Shanghai'), text: message.text!, origin: 'internal' }, { trigger: 'flush' })
73
+ .catch((error) => host.log.error(String(error)));
74
+ return;
75
+ }
76
+ const pending = this.pending.get(message.id!);
77
+ if (!pending) return;
78
+ this.pending.delete(message.id!);
79
+ if (message.error) pending.reject(new Error(message.error));
80
+ else pending.resolve(message.value);
81
+ });
82
+ try {
83
+ this.url = (await this.rpc('init', {
84
+ directory: this.options.directory, port: this.options.cfg.port, browserPath: this.options.cfg.browserFile.trim(),
85
+ }) as { url: string }).url;
86
+ host.log.info(`画布网页已启动 ${this.url}`);
87
+ } catch (error) { this.fail(error as Error); throw error; }
88
+ }
89
+
90
+ private fail(error: Error): void {
91
+ const child = this.child;
92
+ this.child = null;
93
+ this.url = '';
94
+ for (const pending of this.pending.values()) pending.reject(error);
95
+ this.pending.clear();
96
+ if (child && child.exitCode === null) {
97
+ // Disconnect lets the engine close its owned Chromium before the final process exit.
98
+ if (child.connected) child.disconnect();
99
+ const timer = setTimeout(() => child.kill(), 15000);
100
+ timer.unref(); child.once('exit', () => clearTimeout(timer));
101
+ }
102
+ }
103
+
104
+ private rpc(name: string, args: Record<string, unknown>): Promise<unknown> {
105
+ const child = this.child;
106
+ if (!child?.connected) return Promise.reject(new Error('画布服务未启动'));
107
+ const id = ++this.nextId;
108
+ return new Promise((resolve, reject) => {
109
+ const timer = setTimeout(() => this.fail(new Error('画布操作超时,服务已停止以取消待执行动作')), 60000);
110
+ this.pending.set(id, { resolve: (value) => { clearTimeout(timer); resolve(value); }, reject: (error) => { clearTimeout(timer); reject(error); } });
111
+ child.send({ id, name, args }, (error) => { if (error) this.fail(error); });
112
+ });
113
+ }
114
+
115
+ async stop(): Promise<void> {
116
+ const child = this.child;
117
+ if (!child) { await this.closed; this.host = null; return; }
118
+ try { await this.rpc('stop', {}); } finally { this.fail(new Error('画布服务已停止')); await this.closed; this.host = null; }
119
+ }
120
+ }