@ai-composer/core 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/LICENSE +21 -0
- package/README.md +6 -0
- package/dist/commands/builtins.d.ts +41 -0
- package/dist/commands/builtins.d.ts.map +1 -0
- package/dist/commands/builtins.js +116 -0
- package/dist/commands/builtins.js.map +1 -0
- package/dist/commands/registry.d.ts +27 -0
- package/dist/commands/registry.d.ts.map +1 -0
- package/dist/commands/registry.js +29 -0
- package/dist/commands/registry.js.map +1 -0
- package/dist/editor.d.ts +185 -0
- package/dist/editor.d.ts.map +1 -0
- package/dist/editor.js +558 -0
- package/dist/editor.js.map +1 -0
- package/dist/errors.d.ts +15 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +22 -0
- package/dist/errors.js.map +1 -0
- package/dist/events/event-bus.d.ts +19 -0
- package/dist/events/event-bus.d.ts.map +1 -0
- package/dist/events/event-bus.js +51 -0
- package/dist/events/event-bus.js.map +1 -0
- package/dist/events/events.d.ts +94 -0
- package/dist/events/events.d.ts.map +1 -0
- package/dist/events/events.js +6 -0
- package/dist/events/events.js.map +1 -0
- package/dist/factory.d.ts +17 -0
- package/dist/factory.d.ts.map +1 -0
- package/dist/factory.js +18 -0
- package/dist/factory.js.map +1 -0
- package/dist/history/history.d.ts +48 -0
- package/dist/history/history.d.ts.map +1 -0
- package/dist/history/history.js +71 -0
- package/dist/history/history.js.map +1 -0
- package/dist/index.d.ts +43 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +30 -0
- package/dist/index.js.map +1 -0
- package/dist/model/document.d.ts +54 -0
- package/dist/model/document.d.ts.map +1 -0
- package/dist/model/document.js +177 -0
- package/dist/model/document.js.map +1 -0
- package/dist/model/nodes.d.ts +80 -0
- package/dist/model/nodes.d.ts.map +1 -0
- package/dist/model/nodes.js +64 -0
- package/dist/model/nodes.js.map +1 -0
- package/dist/model/selection.d.ts +40 -0
- package/dist/model/selection.d.ts.map +1 -0
- package/dist/model/selection.js +79 -0
- package/dist/model/selection.js.map +1 -0
- package/dist/nodes/registry.d.ts +39 -0
- package/dist/nodes/registry.d.ts.map +1 -0
- package/dist/nodes/registry.js +93 -0
- package/dist/nodes/registry.js.map +1 -0
- package/dist/package.json +21 -0
- package/dist/plugins/registry.d.ts +53 -0
- package/dist/plugins/registry.d.ts.map +1 -0
- package/dist/plugins/registry.js +102 -0
- package/dist/plugins/registry.js.map +1 -0
- package/dist/serialization/builtins.d.ts +30 -0
- package/dist/serialization/builtins.d.ts.map +1 -0
- package/dist/serialization/builtins.js +102 -0
- package/dist/serialization/builtins.js.map +1 -0
- package/dist/serialization/index.d.ts +4 -0
- package/dist/serialization/index.d.ts.map +1 -0
- package/dist/serialization/index.js +3 -0
- package/dist/serialization/index.js.map +1 -0
- package/dist/serialization/registry.d.ts +17 -0
- package/dist/serialization/registry.d.ts.map +1 -0
- package/dist/serialization/registry.js +35 -0
- package/dist/serialization/registry.js.map +1 -0
- package/dist/serialization/serializers.d.ts +12 -0
- package/dist/serialization/serializers.d.ts.map +1 -0
- package/dist/serialization/serializers.js +3 -0
- package/dist/serialization/serializers.js.map +1 -0
- package/dist/state/state.d.ts +54 -0
- package/dist/state/state.d.ts.map +1 -0
- package/dist/state/state.js +6 -0
- package/dist/state/state.js.map +1 -0
- package/dist/triggers/engine.d.ts +102 -0
- package/dist/triggers/engine.d.ts.map +1 -0
- package/dist/triggers/engine.js +235 -0
- package/dist/triggers/engine.js.map +1 -0
- package/dist/types.d.ts +37 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/html.d.ts +3 -0
- package/dist/utils/html.d.ts.map +1 -0
- package/dist/utils/html.js +10 -0
- package/dist/utils/html.js.map +1 -0
- package/package.json +20 -0
- package/project.json +56 -0
- package/src/commands/builtins.ts +142 -0
- package/src/commands/registry.ts +53 -0
- package/src/commands.test.ts +118 -0
- package/src/editor.test.ts +311 -0
- package/src/editor.ts +826 -0
- package/src/errors.ts +25 -0
- package/src/events/event-bus.test.ts +62 -0
- package/src/events/event-bus.ts +72 -0
- package/src/events/events.ts +120 -0
- package/src/factory.ts +21 -0
- package/src/history/history.ts +108 -0
- package/src/index.ts +201 -0
- package/src/model/document.test.ts +130 -0
- package/src/model/document.ts +207 -0
- package/src/model/nodes.ts +151 -0
- package/src/model/selection.ts +107 -0
- package/src/nodes/registry.ts +142 -0
- package/src/plugins/registry.ts +163 -0
- package/src/serialization/builtins.ts +123 -0
- package/src/serialization/index.ts +20 -0
- package/src/serialization/registry.ts +49 -0
- package/src/serialization/serializers.ts +15 -0
- package/src/state/state.ts +57 -0
- package/src/triggers/engine.ts +317 -0
- package/src/triggers.test.ts +138 -0
- package/src/types.ts +42 -0
- package/src/utils/html.ts +9 -0
- package/tsconfig.json +15 -0
- package/tsconfig.lib.json +15 -0
- package/tsconfig.spec.json +16 -0
- package/vitest.config.ts +11 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node type registry. Core registers the built-in node types; plugins can add
|
|
3
|
+
* custom ones or override serialization for built-ins (ADR-0004).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { Unsubscribe } from '../events/event-bus';
|
|
7
|
+
import { escapeHtml } from '../utils/html';
|
|
8
|
+
import type {
|
|
9
|
+
AttachmentNode,
|
|
10
|
+
CommandNode,
|
|
11
|
+
CustomNode,
|
|
12
|
+
MentionNode,
|
|
13
|
+
AIComposerNode,
|
|
14
|
+
TextNode,
|
|
15
|
+
VariableNode,
|
|
16
|
+
} from '../model/nodes';
|
|
17
|
+
|
|
18
|
+
export interface NodeDefinition {
|
|
19
|
+
/** Node type identifier, e.g. 'mention'. `custom` nodes also carry `nodeType`. */
|
|
20
|
+
type: string;
|
|
21
|
+
/** Owning plugin id (informational, for custom nodes). */
|
|
22
|
+
plugin?: string;
|
|
23
|
+
/** Atomic nodes are indivisible units with model length 1. */
|
|
24
|
+
isAtomic: boolean;
|
|
25
|
+
/** Plain-text projection used by the `text` serializer and a11y announcements. */
|
|
26
|
+
toText?(node: AIComposerNode): string;
|
|
27
|
+
/** Markdown projection. */
|
|
28
|
+
toMarkdown?(node: AIComposerNode): string;
|
|
29
|
+
/** Safe HTML projection — must escape user content (used for copy/export, never for trusting input). */
|
|
30
|
+
toHtml?(node: AIComposerNode): string;
|
|
31
|
+
/** Short chip label for DOM rendering. */
|
|
32
|
+
toDisplay?(node: AIComposerNode): string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface NodeRegistry {
|
|
36
|
+
register(definition: NodeDefinition): Unsubscribe;
|
|
37
|
+
get(type: string): NodeDefinition | undefined;
|
|
38
|
+
has(type: string): boolean;
|
|
39
|
+
list(): NodeDefinition[];
|
|
40
|
+
clear(): void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function createNodeRegistry(): NodeRegistry {
|
|
44
|
+
const definitions = new Map<string, NodeDefinition>();
|
|
45
|
+
return {
|
|
46
|
+
register(definition) {
|
|
47
|
+
definitions.set(definition.type, definition);
|
|
48
|
+
return () => {
|
|
49
|
+
if (definitions.get(definition.type) === definition) definitions.delete(definition.type);
|
|
50
|
+
};
|
|
51
|
+
},
|
|
52
|
+
get(type) {
|
|
53
|
+
return definitions.get(type);
|
|
54
|
+
},
|
|
55
|
+
has(type) {
|
|
56
|
+
return definitions.has(type);
|
|
57
|
+
},
|
|
58
|
+
list() {
|
|
59
|
+
return [...definitions.values()];
|
|
60
|
+
},
|
|
61
|
+
clear() {
|
|
62
|
+
definitions.clear();
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// Built-in definitions
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
const as = <T extends AIComposerNode>(node: AIComposerNode): T => node as T;
|
|
72
|
+
|
|
73
|
+
export const TEXT_NODE_DEFINITION: NodeDefinition = {
|
|
74
|
+
type: 'text',
|
|
75
|
+
isAtomic: false,
|
|
76
|
+
toText: (node) => as<TextNode>(node).text,
|
|
77
|
+
toMarkdown: (node) => as<TextNode>(node).text,
|
|
78
|
+
toHtml: (node) => escapeHtml(as<TextNode>(node).text).replace(/\n/g, '<br>'),
|
|
79
|
+
toDisplay: (node) => as<TextNode>(node).text,
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export const MENTION_NODE_DEFINITION: NodeDefinition = {
|
|
83
|
+
type: 'mention',
|
|
84
|
+
isAtomic: true,
|
|
85
|
+
toText: (node) => `@${as<MentionNode>(node).label}`,
|
|
86
|
+
toMarkdown: (node) => `[@${as<MentionNode>(node).label}](mention:${as<MentionNode>(node).id})`,
|
|
87
|
+
toHtml: (node) =>
|
|
88
|
+
`<span data-aic-node="mention" data-aic-id="${escapeHtml(as<MentionNode>(node).id)}">@${escapeHtml(as<MentionNode>(node).label)}</span>`,
|
|
89
|
+
toDisplay: (node) => `@${as<MentionNode>(node).label}`,
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export const COMMAND_NODE_DEFINITION: NodeDefinition = {
|
|
93
|
+
type: 'command',
|
|
94
|
+
isAtomic: true,
|
|
95
|
+
toText: (node) => `/${as<CommandNode>(node).label}`,
|
|
96
|
+
toMarkdown: (node) => `[/${as<CommandNode>(node).label}](command:${as<CommandNode>(node).id})`,
|
|
97
|
+
toHtml: (node) =>
|
|
98
|
+
`<span data-aic-node="command" data-aic-id="${escapeHtml(as<CommandNode>(node).id)}">/${escapeHtml(as<CommandNode>(node).label)}</span>`,
|
|
99
|
+
toDisplay: (node) => `/${as<CommandNode>(node).label}`,
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export const VARIABLE_NODE_DEFINITION: NodeDefinition = {
|
|
103
|
+
type: 'variable',
|
|
104
|
+
isAtomic: true,
|
|
105
|
+
toText: (node) => `{{${as<VariableNode>(node).name}}}`,
|
|
106
|
+
toMarkdown: (node) => `{{${as<VariableNode>(node).name}}}`,
|
|
107
|
+
toHtml: (node) =>
|
|
108
|
+
`<span data-aic-node="variable" data-aic-name="${escapeHtml(as<VariableNode>(node).name)}">{{${escapeHtml(as<VariableNode>(node).name)}}}</span>`,
|
|
109
|
+
toDisplay: (node) => `{{${as<VariableNode>(node).name}}}`,
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
export const ATTACHMENT_NODE_DEFINITION: NodeDefinition = {
|
|
113
|
+
type: 'attachment',
|
|
114
|
+
isAtomic: true,
|
|
115
|
+
toText: (node) => as<AttachmentNode>(node).name,
|
|
116
|
+
toMarkdown: (node) => `[${as<AttachmentNode>(node).name}](attachment:${as<AttachmentNode>(node).id})`,
|
|
117
|
+
toHtml: (node) =>
|
|
118
|
+
`<span data-aic-node="attachment" data-aic-id="${escapeHtml(as<AttachmentNode>(node).id)}" data-aic-mime="${escapeHtml(as<AttachmentNode>(node).mimeType)}">${escapeHtml(as<AttachmentNode>(node).name)}</span>`,
|
|
119
|
+
toDisplay: (node) => as<AttachmentNode>(node).name,
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
export const CUSTOM_NODE_DEFINITION: NodeDefinition = {
|
|
123
|
+
type: 'custom',
|
|
124
|
+
isAtomic: true,
|
|
125
|
+
toText: (node) => JSON.stringify(as<CustomNode>(node).data),
|
|
126
|
+
toMarkdown: (node) => escapeHtml(JSON.stringify(as<CustomNode>(node).data)),
|
|
127
|
+
toHtml: (node) =>
|
|
128
|
+
`<span data-aic-node="custom" data-aic-plugin="${escapeHtml(as<CustomNode>(node).plugin)}" data-aic-type="${escapeHtml(as<CustomNode>(node).nodeType)}" data-aic-data="${escapeHtml(JSON.stringify(as<CustomNode>(node).data))}">${escapeHtml(String(as<CustomNode>(node).data ?? ''))}</span>`,
|
|
129
|
+
toDisplay: (node) => String(as<CustomNode>(node).data ?? ''),
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/** Registry pre-loaded with all built-in node types. */
|
|
133
|
+
export function createDefaultNodeRegistry(): NodeRegistry {
|
|
134
|
+
const registry = createNodeRegistry();
|
|
135
|
+
registry.register(TEXT_NODE_DEFINITION);
|
|
136
|
+
registry.register(MENTION_NODE_DEFINITION);
|
|
137
|
+
registry.register(COMMAND_NODE_DEFINITION);
|
|
138
|
+
registry.register(VARIABLE_NODE_DEFINITION);
|
|
139
|
+
registry.register(ATTACHMENT_NODE_DEFINITION);
|
|
140
|
+
registry.register(CUSTOM_NODE_DEFINITION);
|
|
141
|
+
return registry;
|
|
142
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin architecture (ADR-0004). Plugins are plain objects with declarative
|
|
3
|
+
* contributions plus an optional `setup()` for imperative wiring. They receive
|
|
4
|
+
* a scoped context — never raw editor internals.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Unsubscribe, EventBus } from '../events/event-bus';
|
|
8
|
+
import type { AIComposerEventMap } from '../events/events';
|
|
9
|
+
import type { AIComposerCommand } from '../commands/registry';
|
|
10
|
+
import type { AIComposerTrigger } from '../triggers/engine';
|
|
11
|
+
import type { NodeDefinition } from '../nodes/registry';
|
|
12
|
+
import type { Serializer } from '../serialization/serializers';
|
|
13
|
+
import type { CommandRegistry } from '../commands/registry';
|
|
14
|
+
import type { TriggerRegistry } from '../triggers/engine';
|
|
15
|
+
import type { NodeRegistry } from '../nodes/registry';
|
|
16
|
+
import type { SerializerRegistry } from '../serialization/registry';
|
|
17
|
+
import type { AIComposer } from '../editor';
|
|
18
|
+
import type { AIComposerConfig } from '../types';
|
|
19
|
+
|
|
20
|
+
/** Controlled capabilities exposed to plugins. */
|
|
21
|
+
export interface PluginContext {
|
|
22
|
+
editor: AIComposer;
|
|
23
|
+
getConfig(): AIComposerConfig;
|
|
24
|
+
commands: CommandRegistry;
|
|
25
|
+
triggers: TriggerRegistry;
|
|
26
|
+
nodes: NodeRegistry;
|
|
27
|
+
serializers: SerializerRegistry;
|
|
28
|
+
/** Subscribe to editor events (emit is intentionally not handed out). */
|
|
29
|
+
events: Pick<EventBus<AIComposerEventMap>, 'on' | 'once' | 'off'>;
|
|
30
|
+
/** Register cleanup run when the plugin is destroyed. */
|
|
31
|
+
onCleanup(cleanup: () => void): void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface AIComposerPlugin {
|
|
35
|
+
/** Unique plugin name. */
|
|
36
|
+
name: string;
|
|
37
|
+
version?: string;
|
|
38
|
+
/** Imperative wiring — called once when the plugin is attached. */
|
|
39
|
+
setup?(context: PluginContext): void;
|
|
40
|
+
commands?: AIComposerCommand[];
|
|
41
|
+
triggers?: AIComposerTrigger[];
|
|
42
|
+
nodeTypes?: NodeDefinition[];
|
|
43
|
+
serializers?: Serializer[];
|
|
44
|
+
/** Called when the plugin is removed or the editor destroyed. */
|
|
45
|
+
destroy?(): void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface InstalledPlugin {
|
|
49
|
+
plugin: AIComposerPlugin;
|
|
50
|
+
disposers: Array<() => void>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface PluginRegistry {
|
|
54
|
+
add(plugin: AIComposerPlugin): Unsubscribe;
|
|
55
|
+
get(name: string): AIComposerPlugin | undefined;
|
|
56
|
+
list(): AIComposerPlugin[];
|
|
57
|
+
/** Set up a plugin immediately with the given context (used by the factory). */
|
|
58
|
+
install(plugin: AIComposerPlugin, context: PluginContext): Unsubscribe;
|
|
59
|
+
destroyAll(): void;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function createPluginRegistry(): PluginRegistry {
|
|
63
|
+
const installed: InstalledPlugin[] = [];
|
|
64
|
+
|
|
65
|
+
const teardown = (entry: InstalledPlugin): void => {
|
|
66
|
+
for (const dispose of entry.disposers.splice(0)) {
|
|
67
|
+
try {
|
|
68
|
+
dispose();
|
|
69
|
+
} catch (error) {
|
|
70
|
+
console.error(`[ai-composer] plugin "${entry.plugin.name}" cleanup failed`, error);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
entry.plugin.destroy?.();
|
|
75
|
+
} catch (error) {
|
|
76
|
+
console.error(`[ai-composer] plugin "${entry.plugin.name}" destroy failed`, error);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const registry: PluginRegistry = {
|
|
81
|
+
add(plugin) {
|
|
82
|
+
const entry: InstalledPlugin = { plugin, disposers: [] };
|
|
83
|
+
installed.push(entry);
|
|
84
|
+
return () => {
|
|
85
|
+
const index = installed.indexOf(entry);
|
|
86
|
+
if (index >= 0) {
|
|
87
|
+
installed.splice(index, 1);
|
|
88
|
+
teardown(entry);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
get(name) {
|
|
94
|
+
return installed.find((entry) => entry.plugin.name === name)?.plugin;
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
list() {
|
|
98
|
+
return installed.map((entry) => entry.plugin);
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
install(plugin, context) {
|
|
102
|
+
const entry: InstalledPlugin = { plugin, disposers: [] };
|
|
103
|
+
installed.push(entry);
|
|
104
|
+
|
|
105
|
+
const scoped: PluginContext = {
|
|
106
|
+
...context,
|
|
107
|
+
onCleanup: (cleanup: () => void) => {
|
|
108
|
+
entry.disposers.push(cleanup);
|
|
109
|
+
},
|
|
110
|
+
events: {
|
|
111
|
+
on: (type, handler) => {
|
|
112
|
+
const off = context.events.on(type, handler);
|
|
113
|
+
entry.disposers.push(off);
|
|
114
|
+
return off;
|
|
115
|
+
},
|
|
116
|
+
once: (type, handler) => {
|
|
117
|
+
const off = context.events.once(type, handler);
|
|
118
|
+
entry.disposers.push(off);
|
|
119
|
+
return off;
|
|
120
|
+
},
|
|
121
|
+
off: (type, handler) => context.events.off(type, handler),
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
for (const command of plugin.commands ?? []) {
|
|
126
|
+
entry.disposers.push(context.commands.register(command));
|
|
127
|
+
}
|
|
128
|
+
for (const trigger of plugin.triggers ?? []) {
|
|
129
|
+
entry.disposers.push(context.triggers.register(trigger));
|
|
130
|
+
}
|
|
131
|
+
for (const nodeType of plugin.nodeTypes ?? []) {
|
|
132
|
+
entry.disposers.push(context.nodes.register(nodeType));
|
|
133
|
+
}
|
|
134
|
+
for (const serializer of plugin.serializers ?? []) {
|
|
135
|
+
entry.disposers.push(context.serializers.register(serializer));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
plugin.setup?.(scoped);
|
|
140
|
+
} catch (error) {
|
|
141
|
+
// roll this plugin back; the editor stays alive
|
|
142
|
+
const index = installed.indexOf(entry);
|
|
143
|
+
if (index >= 0) installed.splice(index, 1);
|
|
144
|
+
teardown(entry);
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return () => {
|
|
149
|
+
const index = installed.indexOf(entry);
|
|
150
|
+
if (index >= 0) {
|
|
151
|
+
installed.splice(index, 1);
|
|
152
|
+
teardown(entry);
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
destroyAll() {
|
|
158
|
+
for (const entry of installed.splice(0)) teardown(entry);
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
return registry;
|
|
163
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in serializers. Every projection goes through the node registry so
|
|
3
|
+
* plugins control how their custom nodes serialize.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { escapeHtml } from '../utils/html';
|
|
7
|
+
import type { AIComposerDocument } from '../model/document';
|
|
8
|
+
import type { AIComposerNode } from '../model/nodes';
|
|
9
|
+
import type { NodeDefinition } from '../nodes/registry';
|
|
10
|
+
import type { Serializer, SerializerContext } from './serializers';
|
|
11
|
+
|
|
12
|
+
/** Structured payload handed to AI providers — provider-neutral by design (see docs). */
|
|
13
|
+
export interface AIComposerPayload {
|
|
14
|
+
text: string;
|
|
15
|
+
entities: Array<{
|
|
16
|
+
type: string;
|
|
17
|
+
key: string;
|
|
18
|
+
id?: string;
|
|
19
|
+
label?: string;
|
|
20
|
+
name?: string;
|
|
21
|
+
data?: unknown;
|
|
22
|
+
}>;
|
|
23
|
+
metadata?: Record<string, unknown>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function definitionOf(context: SerializerContext, node: AIComposerNode): NodeDefinition | undefined {
|
|
27
|
+
return context.nodes.get(node.type);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Plain-text projection of a single node. */
|
|
31
|
+
export function nodeToText(node: AIComposerNode, context: SerializerContext): string {
|
|
32
|
+
const def = definitionOf(context, node);
|
|
33
|
+
if (def?.toText) return def.toText(node);
|
|
34
|
+
if (node.type === 'text') return node.text;
|
|
35
|
+
return '';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function documentToText(document: AIComposerDocument, context: SerializerContext): string {
|
|
39
|
+
return document.nodes.map((node) => nodeToText(node, context)).join('');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function nodeToMarkdown(node: AIComposerNode, context: SerializerContext): string {
|
|
43
|
+
const def = definitionOf(context, node);
|
|
44
|
+
if (def?.toMarkdown) return def.toMarkdown(node);
|
|
45
|
+
return nodeToText(node, context);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function nodeToHtml(node: AIComposerNode, context: SerializerContext): string {
|
|
49
|
+
const def = definitionOf(context, node);
|
|
50
|
+
if (def?.toHtml) return def.toHtml(node);
|
|
51
|
+
return escapeHtml(nodeToText(node, context));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function createJsonSerializer(): Serializer {
|
|
55
|
+
return {
|
|
56
|
+
format: 'json',
|
|
57
|
+
serialize(document) {
|
|
58
|
+
// Round-trip safe: plain JSON structures only.
|
|
59
|
+
return JSON.parse(JSON.stringify(document)) as AIComposerDocument;
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function createTextSerializer(): Serializer {
|
|
65
|
+
return {
|
|
66
|
+
format: 'text',
|
|
67
|
+
serialize(document, context) {
|
|
68
|
+
return documentToText(document, context);
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function createMarkdownSerializer(): Serializer {
|
|
74
|
+
return {
|
|
75
|
+
format: 'markdown',
|
|
76
|
+
serialize(document, context) {
|
|
77
|
+
return document.nodes.map((node) => nodeToMarkdown(node, context)).join('');
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function createHtmlSerializer(): Serializer {
|
|
83
|
+
return {
|
|
84
|
+
format: 'html',
|
|
85
|
+
serialize(document, context) {
|
|
86
|
+
const body = document.nodes.map((node) => nodeToHtml(node, context)).join('');
|
|
87
|
+
return `<div data-aic-document>${body}</div>`;
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function createAiSerializer(): Serializer {
|
|
93
|
+
return {
|
|
94
|
+
format: 'ai',
|
|
95
|
+
serialize(document, context): AIComposerPayload {
|
|
96
|
+
return {
|
|
97
|
+
text: documentToText(document, context),
|
|
98
|
+
entities: document.nodes
|
|
99
|
+
.filter((node) => node.type !== 'text')
|
|
100
|
+
.map((node) => {
|
|
101
|
+
const base: AIComposerPayload['entities'][number] = { type: node.type, key: node.key };
|
|
102
|
+
const record = node as unknown as Record<string, unknown>;
|
|
103
|
+
if (typeof record.id === 'string') base.id = record.id;
|
|
104
|
+
if (typeof record.label === 'string') base.label = record.label;
|
|
105
|
+
if (typeof record.name === 'string') base.name = record.name;
|
|
106
|
+
if (node.type === 'custom') base.data = record.data;
|
|
107
|
+
return base;
|
|
108
|
+
}),
|
|
109
|
+
...(document.metadata ? { metadata: document.metadata } : {}),
|
|
110
|
+
};
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function createDefaultSerializers(): Serializer[] {
|
|
116
|
+
return [
|
|
117
|
+
createJsonSerializer(),
|
|
118
|
+
createTextSerializer(),
|
|
119
|
+
createMarkdownSerializer(),
|
|
120
|
+
createHtmlSerializer(),
|
|
121
|
+
createAiSerializer(),
|
|
122
|
+
];
|
|
123
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
SerializationFormat,
|
|
3
|
+
Serializer,
|
|
4
|
+
SerializerContext,
|
|
5
|
+
} from './serializers';
|
|
6
|
+
export {
|
|
7
|
+
createSerializerRegistry,
|
|
8
|
+
type SerializerRegistry,
|
|
9
|
+
} from './registry';
|
|
10
|
+
export {
|
|
11
|
+
createAiSerializer,
|
|
12
|
+
createDefaultSerializers,
|
|
13
|
+
createHtmlSerializer,
|
|
14
|
+
createJsonSerializer,
|
|
15
|
+
createMarkdownSerializer,
|
|
16
|
+
createTextSerializer,
|
|
17
|
+
documentToText,
|
|
18
|
+
nodeToText,
|
|
19
|
+
type AIComposerPayload,
|
|
20
|
+
} from './builtins';
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serializer registry (ADR-0006). Built-in formats: json, text, markdown,
|
|
3
|
+
* html, ai. Plugins can add formats or override built-ins by re-registering
|
|
4
|
+
* the same format id (last registration wins).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Unsubscribe } from '../events/event-bus';
|
|
8
|
+
import { AIComposerError } from '../errors';
|
|
9
|
+
import type { AIComposerDocument } from '../model/document';
|
|
10
|
+
import type { SerializationFormat, Serializer, SerializerContext } from './serializers';
|
|
11
|
+
|
|
12
|
+
export interface SerializerRegistry {
|
|
13
|
+
register(serializer: Serializer): Unsubscribe;
|
|
14
|
+
get(format: SerializationFormat): Serializer | undefined;
|
|
15
|
+
formats(): SerializationFormat[];
|
|
16
|
+
serialize(format: SerializationFormat, document: AIComposerDocument, context: SerializerContext): unknown;
|
|
17
|
+
clear(): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function createSerializerRegistry(): SerializerRegistry {
|
|
21
|
+
const serializers = new Map<SerializationFormat, Serializer>();
|
|
22
|
+
return {
|
|
23
|
+
register(serializer) {
|
|
24
|
+
serializers.set(serializer.format, serializer);
|
|
25
|
+
return () => {
|
|
26
|
+
if (serializers.get(serializer.format) === serializer) serializers.delete(serializer.format);
|
|
27
|
+
};
|
|
28
|
+
},
|
|
29
|
+
get(format) {
|
|
30
|
+
return serializers.get(format);
|
|
31
|
+
},
|
|
32
|
+
formats() {
|
|
33
|
+
return [...serializers.keys()];
|
|
34
|
+
},
|
|
35
|
+
serialize(format, document, context) {
|
|
36
|
+
const serializer = serializers.get(format);
|
|
37
|
+
if (!serializer) {
|
|
38
|
+
throw new AIComposerError(
|
|
39
|
+
'UNKNOWN_FORMAT',
|
|
40
|
+
`No serializer registered for "${format}". Registered: ${[...serializers.keys()].join(', ') || 'none'}`,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
return serializer.serialize(document, context);
|
|
44
|
+
},
|
|
45
|
+
clear() {
|
|
46
|
+
serializers.clear();
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Serializer contracts. Built-in implementations live in ./builtins.ts. */
|
|
2
|
+
|
|
3
|
+
import type { AIComposerDocument } from '../model/document';
|
|
4
|
+
import type { NodeRegistry } from '../nodes/registry';
|
|
5
|
+
|
|
6
|
+
export type SerializationFormat = 'json' | 'text' | 'markdown' | 'html' | 'ai';
|
|
7
|
+
|
|
8
|
+
export interface SerializerContext {
|
|
9
|
+
nodes: NodeRegistry;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface Serializer {
|
|
13
|
+
format: SerializationFormat;
|
|
14
|
+
serialize(document: AIComposerDocument, context: SerializerContext): unknown;
|
|
15
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Observable, framework-neutral editor state. Adapters expose it as
|
|
3
|
+
* React hooks state, Angular signals, Vue refs, Svelte stores — core stays UI-free.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { AIComposerDocument } from '../model/document';
|
|
7
|
+
import type { AttachmentNode } from '../model/nodes';
|
|
8
|
+
import type { SelectionState } from '../model/selection';
|
|
9
|
+
|
|
10
|
+
/** One entry of a trigger's suggestion list. */
|
|
11
|
+
export interface SuggestionItem {
|
|
12
|
+
id: string;
|
|
13
|
+
label: string;
|
|
14
|
+
description?: string;
|
|
15
|
+
/** Optional grouping heading in the suggestion UI. */
|
|
16
|
+
group?: string;
|
|
17
|
+
/** Icon name/URL — rendering is up to the UI layer. */
|
|
18
|
+
icon?: string;
|
|
19
|
+
/** Plugin-specific payload handed back on select. */
|
|
20
|
+
data?: Record<string, unknown>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Live trigger session, e.g. while the user types "@jo". */
|
|
24
|
+
export interface TriggerState {
|
|
25
|
+
triggerId: string;
|
|
26
|
+
/** Text typed after the trigger character. */
|
|
27
|
+
query: string;
|
|
28
|
+
/** Index of the text node containing the trigger run. */
|
|
29
|
+
nodeIndex: number;
|
|
30
|
+
/** Offset of the trigger character within that text node. */
|
|
31
|
+
triggerOffset: number;
|
|
32
|
+
/** Offset where the query ends (the caret at scan time). */
|
|
33
|
+
endOffset: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface AIComposerState {
|
|
37
|
+
/** Current document value. */
|
|
38
|
+
value: AIComposerDocument;
|
|
39
|
+
focused: boolean;
|
|
40
|
+
disabled: boolean;
|
|
41
|
+
readonly: boolean;
|
|
42
|
+
/** True while an async submit handler is running. */
|
|
43
|
+
submitting: boolean;
|
|
44
|
+
selection: SelectionState;
|
|
45
|
+
activeTrigger: TriggerState | null;
|
|
46
|
+
suggestions: SuggestionItem[];
|
|
47
|
+
/** Highlighted suggestion (-1 when none). */
|
|
48
|
+
activeSuggestionIndex: number;
|
|
49
|
+
/** Convenience view of attachment nodes inside the document. */
|
|
50
|
+
attachments: AttachmentNode[];
|
|
51
|
+
canUndo: boolean;
|
|
52
|
+
canRedo: boolean;
|
|
53
|
+
/** Active presentation mode / preset name (e.g. 'chat'). */
|
|
54
|
+
mode: string;
|
|
55
|
+
placeholder: string;
|
|
56
|
+
empty: boolean;
|
|
57
|
+
}
|