@tangleai/assistant 0.21.1

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/CHANGELOG.md ADDED
@@ -0,0 +1,35 @@
1
+ # @tangleai/assistant
2
+
3
+ ## 0.21.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Update the exact Jaren foundation dependencies and source pin to the published
8
+ 0.84.3 release after verifying its AI-free archives against source-built bytes.
9
+ Retain Tangle's model, context and agent ownership and align the development
10
+ Node pin with 24.20.0. Tangle publication remains a manual author action.
11
+ Allow release preparation after an already committed local release while
12
+ preserving its record and rejecting unprepared version edits.
13
+ - Updated dependencies
14
+ - @tangleai/models@0.21.1
15
+ - @tangleai/context@0.21.1
16
+ - @tangleai/agents@0.21.1
17
+
18
+ ## 0.21.0
19
+
20
+ ### Minor Changes
21
+
22
+ - Receive the reusable assistant and injected slot ledger storage, preserving
23
+ streaming, transcript and evidenced-memory behavior with owned cleanup.
24
+
25
+ ### Patch Changes
26
+
27
+ - Updated dependencies
28
+ - Updated dependencies
29
+ - @tangleai/models@0.21.0
30
+ - @tangleai/context@0.21.0
31
+ - @tangleai/agents@0.21.0
32
+
33
+ ## Unreleased
34
+
35
+ Received the existing assistant with injected host services and owned lifecycle.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Joham (jklarenbeek@gmail.com)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,41 @@
1
+ # @tangleai/assistant
2
+
3
+ Reusable assistant state, actions and request lifecycle. The headless root imports
4
+ no renderer or DOM. Hosts inject their toolbox, system instructions, state access,
5
+ settings/transcript stores and ledger. Dispose aborts requests and releases the
6
+ optional owned storage; cancel prevents late stream results from reappearing.
7
+
8
+ ```js
9
+ import { mountAssistant } from '@tangleai/assistant/component';
10
+ import '@tangleai/assistant/styles/assistant.css';
11
+ const assistant = mountAssistant(document.querySelector('#assistant'), {
12
+ title: 'My workspace', capabilities: ['Inspect a document'], toolbox,
13
+ system: 'Help with the tools this workspace provides.',
14
+ aiStorage: settingsSlot, aiChat: transcriptSlot, ledger,
15
+ onDispose: () => ownedLedgerStorage.close(),
16
+ });
17
+ assistant.update({ title: 'Review workspace' }); // retains the draft and request
18
+ await assistant.dispose();
19
+ ```
20
+
21
+ `mountAssistant(node, options)` returns `read`, `dispatch`, `subscribe`,
22
+ `update` and asynchronous, idempotent `dispose`, plus its headless controller.
23
+ `createAssistantWidget(options)` supplies app widget mount/update/unmount hooks.
24
+ Presentation options include title, launcher text, intro, capabilities and
25
+ settings hint. The host supplies its toolbox, system prompt, provider fetch and
26
+ settings/transcript slots; omitted slots and ledger are in memory. Slot writes
27
+ must throw or return false on failure. An injected ledger remains host-owned
28
+ unless `onDispose` explicitly closes it. Never put provider settings in a data
29
+ export. See [browser data transfer](../../docs/migrations/jaren-ai/BROWSER_DATA.md).
30
+
31
+ The root exports state/actions/controller without rendering dependencies.
32
+ `/component` imports the Jaren app, Markdown and Mermaid renderers. CSS selectors
33
+ are scoped to `.tangle-assistant`; host tokens have fallbacks. Each mount owns
34
+ its datalist IDs, draft, stream and fragment links. Cancelling or disposing
35
+ aborts current requests and ignores late results from non-cooperating providers.
36
+ Disposed controls cannot dispatch into the host. Presentation updates retain
37
+ input nodes and do not reset selection or the active request.
38
+
39
+ The controller exposes its current request `signal`. Hosts can combine it with
40
+ their own lifetime signal for child authoring requests. Cancellation fences
41
+ later tool calls and Pages refuses late authoring results before publication.
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@tangleai/assistant",
3
+ "version": "0.21.1",
4
+ "description": "Reusable assistant state, streaming lifecycle and optional Jaren visual component.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./src/index.js",
8
+ "types": "./src/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./src/index.d.ts",
12
+ "import": "./src/index.js",
13
+ "default": "./src/index.js"
14
+ },
15
+ "./package.json": "./package.json",
16
+ "./component": {
17
+ "types": "./src/component.d.ts",
18
+ "import": "./src/component.js",
19
+ "default": "./src/component.js"
20
+ },
21
+ "./styles/assistant.css": "./styles/assistant.css"
22
+ },
23
+ "engines": {
24
+ "node": ">=24"
25
+ },
26
+ "sideEffects": [
27
+ "**/*.css"
28
+ ],
29
+ "dependencies": {
30
+ "@tangleai/models": "^0.21.1",
31
+ "@tangleai/context": "^0.21.1",
32
+ "@tangleai/agents": "^0.21.1",
33
+ "@jarenjs/json": "0.84.3",
34
+ "@jarenjs/app": "0.84.3",
35
+ "@jarenjs/md": "0.84.3",
36
+ "@jarenjs/mermaid": "0.84.3"
37
+ },
38
+ "private": false,
39
+ "files": [
40
+ "src/**/*.js",
41
+ "src/**/*.d.ts",
42
+ "README.md",
43
+ "LICENSE",
44
+ "CHANGELOG.md",
45
+ "styles/**/*.css"
46
+ ],
47
+ "publishConfig": {
48
+ "access": "public",
49
+ "registry": "https://registry.npmjs.org/"
50
+ },
51
+ "repository": {
52
+ "type": "git",
53
+ "url": "git+https://github.com/jklarenbeek/tangleai.git",
54
+ "directory": "components/assistant"
55
+ }
56
+ }
@@ -0,0 +1,237 @@
1
+ /** Assistant transitions received from the existing panel. */
2
+ export const ASSISTANT_ACTIONS: {
3
+ 'ai/cancel': {
4
+ patch: {
5
+ op: string;
6
+ path: string;
7
+ value: string;
8
+ }[];
9
+ effects: {
10
+ run: string;
11
+ }[];
12
+ };
13
+ 'ai/toggle': {
14
+ patch: {
15
+ op: string;
16
+ path: string;
17
+ value: {
18
+ $not: string;
19
+ };
20
+ }[];
21
+ effects: {
22
+ run: string;
23
+ }[];
24
+ };
25
+ 'ai/settings-toggle': {
26
+ patch: {
27
+ op: string;
28
+ path: string;
29
+ value: {
30
+ $not: string;
31
+ };
32
+ }[];
33
+ };
34
+ 'ai/settings-open': {
35
+ patch: {
36
+ op: string;
37
+ path: string;
38
+ value: string;
39
+ }[];
40
+ };
41
+ 'ai/setting': {
42
+ patch: ({
43
+ op: string;
44
+ path: {
45
+ $concat: string[];
46
+ };
47
+ value: string;
48
+ } | {
49
+ op: string;
50
+ path: string;
51
+ value: {
52
+ status: string;
53
+ detail: any;
54
+ models: any[];
55
+ };
56
+ })[];
57
+ };
58
+ 'ai/probe': {
59
+ patch: {
60
+ op: string;
61
+ path: string;
62
+ value: {
63
+ status: string;
64
+ detail: any;
65
+ models: any[];
66
+ };
67
+ }[];
68
+ effects: {
69
+ run: string;
70
+ }[];
71
+ };
72
+ 'ai/probe-result': {
73
+ patch: {
74
+ op: string;
75
+ path: string;
76
+ value: string;
77
+ }[];
78
+ };
79
+ 'ai/save-settings': {
80
+ patch: {
81
+ op: string;
82
+ path: string;
83
+ value: boolean;
84
+ }[];
85
+ effects: {
86
+ run: string;
87
+ }[];
88
+ };
89
+ 'ai/draft': {
90
+ patch: {
91
+ op: string;
92
+ path: string;
93
+ value: string;
94
+ }[];
95
+ };
96
+ 'ai/send': {
97
+ effects: {
98
+ run: string;
99
+ }[];
100
+ };
101
+ 'ai/user': {
102
+ patch: ({
103
+ op: string;
104
+ path: string;
105
+ value: {
106
+ role: string;
107
+ content: string;
108
+ };
109
+ } | {
110
+ op: string;
111
+ path: string;
112
+ value: string;
113
+ } | {
114
+ op: string;
115
+ path: string;
116
+ value: number;
117
+ })[];
118
+ effects: {
119
+ run: string;
120
+ }[];
121
+ };
122
+ 'ai/delta': {
123
+ patch: {
124
+ op: string;
125
+ path: string;
126
+ value: {
127
+ $concat: string[];
128
+ };
129
+ }[];
130
+ };
131
+ 'ai/reasoning': {
132
+ patch: {
133
+ op: string;
134
+ path: string;
135
+ value: {
136
+ $add: string[];
137
+ };
138
+ }[];
139
+ };
140
+ 'ai/activity': {
141
+ patch: {
142
+ op: string;
143
+ path: string;
144
+ value: string;
145
+ }[];
146
+ };
147
+ 'ai/reply': {
148
+ patch: ({
149
+ op: string;
150
+ path: string;
151
+ value: {
152
+ role: string;
153
+ content: string;
154
+ };
155
+ } | {
156
+ op: string;
157
+ path: string;
158
+ value: string;
159
+ })[];
160
+ effects: {
161
+ run: string;
162
+ }[];
163
+ };
164
+ 'ai/failed': {
165
+ patch: {
166
+ op: string;
167
+ path: string;
168
+ value: string;
169
+ }[];
170
+ };
171
+ 'ai/clear': {
172
+ patch: ({
173
+ op: string;
174
+ path: string;
175
+ value: any[];
176
+ } | {
177
+ op: string;
178
+ path: string;
179
+ value: string;
180
+ })[];
181
+ effects: {
182
+ run: string;
183
+ }[];
184
+ };
185
+ 'ai/ledger': {
186
+ patch: {
187
+ op: string;
188
+ path: string;
189
+ value: string;
190
+ }[];
191
+ };
192
+ 'ai/goal-draft': {
193
+ patch: {
194
+ op: string;
195
+ path: string;
196
+ value: string;
197
+ }[];
198
+ };
199
+ 'ai/goal-set': {
200
+ effects: {
201
+ run: string;
202
+ }[];
203
+ };
204
+ 'ai/goal-committed': {
205
+ patch: {
206
+ op: string;
207
+ path: string;
208
+ value: string;
209
+ }[];
210
+ };
211
+ 'ai/goal-clear': {
212
+ effects: {
213
+ run: string;
214
+ }[];
215
+ };
216
+ 'ai/remember': {
217
+ patch: {
218
+ op: string;
219
+ path: string;
220
+ value: boolean;
221
+ }[];
222
+ effects: {
223
+ run: string;
224
+ }[];
225
+ };
226
+ 'ai/remembered': {
227
+ patch: ({
228
+ op: string;
229
+ path: string;
230
+ value: boolean;
231
+ } | {
232
+ op: string;
233
+ path: string;
234
+ value: string;
235
+ })[];
236
+ };
237
+ };
package/src/actions.js ADDED
@@ -0,0 +1,145 @@
1
+ //@ts-check
2
+ /** Assistant transitions received from the existing panel. */
3
+ export const ASSISTANT_ACTIONS = {
4
+ 'ai/cancel': {
5
+ patch: [
6
+ { op: 'replace', path: '/ai/status', value: 'idle' },
7
+ { op: 'replace', path: '/ai/pending', value: '' },
8
+ { op: 'replace', path: '/ai/activity', value: null },
9
+ ],
10
+ effects: [{ run: 'ai-cancel' }],
11
+ },
12
+ 'ai/toggle': {
13
+ patch: [{ op: 'replace', path: '/ai/open', value: { $not: '$.ai.open' } }],
14
+ // opening while unconfigured pins the settings form open (see
15
+ // ai-ensure-settings), so it cannot vanish mid-edit as typing
16
+ // makes the configuration valid. The ledger read is what makes a
17
+ // reloaded page show the objective it was left with — the goal
18
+ // lives in storage, not in this tab.
19
+ effects: [{ run: 'ai-ensure-settings' }, { run: 'ai-ledger-read' }],
20
+ },
21
+ 'ai/settings-toggle': {
22
+ patch: [{ op: 'replace', path: '/ai/settingsOpen', value: { $not: '$.ai.settingsOpen' } }],
23
+ },
24
+ 'ai/settings-open': {
25
+ patch: [{ op: 'replace', path: '/ai/settingsOpen', value: '$payload' }],
26
+ },
27
+ 'ai/setting': {
28
+ patch: [
29
+ {
30
+ op: 'add',
31
+ path: { $concat: ['/ai/settings/', '$payload.key'] },
32
+ value: '$event.value',
33
+ },
34
+ // an edited connection voids the last probe verdict
35
+ { op: 'replace', path: '/ai/probe', value: { status: 'idle', detail: null, models: [] } },
36
+ ],
37
+ },
38
+ 'ai/probe': {
39
+ patch: [{
40
+ op: 'replace',
41
+ path: '/ai/probe',
42
+ value: { status: 'busy', detail: null, models: [] },
43
+ }],
44
+ effects: [{ run: 'ai-probe' }],
45
+ },
46
+ 'ai/probe-result': {
47
+ patch: [{ op: 'replace', path: '/ai/probe', value: '$payload' }],
48
+ },
49
+ 'ai/save-settings': {
50
+ patch: [{ op: 'replace', path: '/ai/settingsOpen', value: false }],
51
+ effects: [{ run: 'ai-save-settings' }],
52
+ },
53
+ 'ai/draft': {
54
+ patch: [{ op: 'replace', path: '/ai/draft', value: '$event.value' }],
55
+ },
56
+ 'ai/send': { effects: [{ run: 'ai-send' }] },
57
+ 'ai/user': {
58
+ patch: [
59
+ { op: 'add', path: '/ai/messages/-', value: { role: 'user', content: '$payload' } },
60
+ { op: 'replace', path: '/ai/draft', value: '' },
61
+ { op: 'replace', path: '/ai/pending', value: '' },
62
+ { op: 'replace', path: '/ai/status', value: 'streaming' },
63
+ { op: 'replace', path: '/ai/activity', value: null },
64
+ { op: 'replace', path: '/ai/error', value: null },
65
+ { op: 'replace', path: '/ai/reasoningChars', value: 0 },
66
+ ],
67
+ effects: [{ run: 'ai-persist' }],
68
+ },
69
+ 'ai/delta': {
70
+ patch: [{ op: 'replace', path: '/ai/pending', value: { $concat: ['$.ai.pending', '$payload'] } }],
71
+ },
72
+ 'ai/reasoning': {
73
+ patch: [{ op: 'replace', path: '/ai/reasoningChars', value: { $add: ['$.ai.reasoningChars', '$payload'] } }],
74
+ },
75
+ 'ai/activity': {
76
+ patch: [{ op: 'replace', path: '/ai/activity', value: '$payload' }],
77
+ },
78
+ 'ai/reply': {
79
+ patch: [
80
+ { op: 'add', path: '/ai/messages/-', value: { role: 'assistant', content: '$payload' } },
81
+ { op: 'replace', path: '/ai/pending', value: '' },
82
+ { op: 'replace', path: '/ai/status', value: 'idle' },
83
+ { op: 'replace', path: '/ai/activity', value: null },
84
+ ],
85
+ effects: [{ run: 'ai-persist' }],
86
+ },
87
+ 'ai/failed': {
88
+ patch: [
89
+ { op: 'replace', path: '/ai/status', value: 'error' },
90
+ { op: 'replace', path: '/ai/error', value: '$payload' },
91
+ { op: 'replace', path: '/ai/pending', value: '' },
92
+ { op: 'replace', path: '/ai/activity', value: null },
93
+ ],
94
+ },
95
+ 'ai/clear': {
96
+ patch: [
97
+ { op: 'replace', path: '/ai/messages', value: [] },
98
+ { op: 'replace', path: '/ai/pending', value: '' },
99
+ { op: 'replace', path: '/ai/status', value: 'idle' },
100
+ { op: 'replace', path: '/ai/activity', value: null },
101
+ { op: 'replace', path: '/ai/error', value: null },
102
+ { op: 'replace', path: '/ai/remembered', value: null },
103
+ ],
104
+ // the archived rounds go with the transcript they were cut from:
105
+ // their addresses only ever existed in its synopsis
106
+ effects: [{ run: 'ai-clear-run' }, { run: 'ai-persist' }, { run: 'ai-clear-archive' }],
107
+ },
108
+ 'ai/ledger': {
109
+ patch: [
110
+ { op: 'replace', path: '/ai/goal', value: '$payload.goal' },
111
+ { op: 'replace', path: '/ai/memories', value: '$payload.memories' },
112
+ { op: 'replace', path: '/ai/archived', value: '$payload.archived' },
113
+ { op: 'replace', path: '/ai/persistence', value: '$payload.persistence' },
114
+ { op: 'replace', path: '/ai/retention', value: '$payload.retention' },
115
+ ],
116
+ },
117
+ 'ai/goal-draft': {
118
+ patch: [{ op: 'replace', path: '/ai/goalDraft', value: '$event.value' }],
119
+ },
120
+ 'ai/goal-set': { effects: [{ run: 'ai-goal-set' }] },
121
+ 'ai/goal-committed': {
122
+ patch: [
123
+ { op: 'replace', path: '/ai/goal', value: '$payload.goal' },
124
+ { op: 'replace', path: '/ai/memories', value: '$payload.memories' },
125
+ { op: 'replace', path: '/ai/archived', value: '$payload.archived' },
126
+ { op: 'replace', path: '/ai/persistence', value: '$payload.persistence' },
127
+ { op: 'replace', path: '/ai/retention', value: '$payload.retention' },
128
+ { op: 'replace', path: '/ai/goalDraft', value: '' },
129
+ ],
130
+ },
131
+ 'ai/goal-clear': { effects: [{ run: 'ai-goal-clear' }] },
132
+ 'ai/remember': {
133
+ patch: [
134
+ { op: 'replace', path: '/ai/remembering', value: true },
135
+ { op: 'replace', path: '/ai/remembered', value: null },
136
+ ],
137
+ effects: [{ run: 'ai-remember' }],
138
+ },
139
+ 'ai/remembered': {
140
+ patch: [
141
+ { op: 'replace', path: '/ai/remembering', value: false },
142
+ { op: 'replace', path: '/ai/remembered', value: '$payload.note' },
143
+ ],
144
+ },
145
+ };
@@ -0,0 +1,55 @@
1
+ /** @param {HTMLElement | null} node @param {AssistantOptions} [options] */
2
+ export function mountAssistant(node: HTMLElement | null, options?: AssistantOptions): {
3
+ controller: {
4
+ effects: {
5
+ [k: string]: (props: any, dispatch: any) => void | Promise<any>;
6
+ };
7
+ cancel: () => void;
8
+ dispose: () => any;
9
+ readonly signal: AbortSignal;
10
+ };
11
+ read: () => any;
12
+ dispatch(action: any, payload?: any, event?: any): void;
13
+ subscribe(listener: any): () => void;
14
+ /** Presentation updates retain the transcript, request and input node. */
15
+ update(next: AssistantPresentation): void;
16
+ dispose(): any;
17
+ };
18
+ /** @param {AssistantOptions} [options] */
19
+ export function createAssistantWidget(options?: AssistantOptions): {
20
+ mount: (host: any, props: any) => {
21
+ controller: {
22
+ effects: {
23
+ [k: string]: (props: any, dispatch: any) => void | Promise<any>;
24
+ };
25
+ cancel: () => void;
26
+ dispose: () => any;
27
+ readonly signal: AbortSignal;
28
+ };
29
+ read: () => any;
30
+ dispatch(action: any, payload?: any, event?: any): void;
31
+ subscribe(listener: any): () => void;
32
+ /** Presentation updates retain the transcript, request and input node. */
33
+ update(next: AssistantPresentation): void;
34
+ dispose(): any;
35
+ };
36
+ update: (handle: any, props: any) => any;
37
+ unmount: (handle: any) => any;
38
+ };
39
+ export type AssistantPresentation = {
40
+ title?: string;
41
+ launchLabel?: string;
42
+ launchTitle?: string;
43
+ intro?: string;
44
+ capabilities?: string[];
45
+ settingsHint?: string;
46
+ };
47
+ export type AssistantOptions = AssistantPresentation & Partial<Omit<Parameters<typeof createAssistantController>[0], "getApp">> & {
48
+ settings?: any;
49
+ transcript?: any;
50
+ open?: boolean;
51
+ schedule?: (flush: () => void) => void;
52
+ onError?: (error: Error) => void;
53
+ renderMarkdown?: (source: string) => any;
54
+ };
55
+ import { createAssistantController } from './index.js';
@@ -0,0 +1,93 @@
1
+ //@ts-check
2
+ /** The visual assistant owns one app, its request lifecycle and its DOM listeners. */
3
+ import { createApp, formEventFields } from '@jarenjs/app';
4
+ import { createMdComponent } from '@jarenjs/md/component';
5
+ import { highlightPlugin } from '@jarenjs/md/plugins';
6
+ import { mermaidPlugin } from '@jarenjs/mermaid/plugin';
7
+ import { createLedger } from '@tangleai/context';
8
+ import { createToolbox } from '@tangleai/agents';
9
+ import { createAssistantState, createAssistantController, ASSISTANT_ACTIONS } from './index.js';
10
+ import { createAssistantRules } from './views.js';
11
+ import { assistantView } from './viewmodel.js';
12
+
13
+ /**
14
+ * @typedef {{ title?: string, launchLabel?: string, launchTitle?: string,
15
+ * intro?: string, capabilities?: string[], settingsHint?: string }} AssistantPresentation
16
+ * @typedef {AssistantPresentation & Partial<Omit<Parameters<typeof createAssistantController>[0], 'getApp'>> & {
17
+ * settings?: any, transcript?: any, open?: boolean,
18
+ * schedule?: (flush: () => void) => void, onError?: (error: Error) => void,
19
+ * renderMarkdown?: (source: string) => any }} AssistantOptions
20
+ */
21
+ const DEFAULT_PRESENTATION = {
22
+ title: 'Assistant', launchLabel: ' Assistant', launchTitle: 'Open assistant',
23
+ intro: 'Describe what you want to get done. The assistant can use the tools this host provides.',
24
+ capabilities: [],
25
+ settingsHint: 'Provider requests use your settings. This host controls local persistence.',
26
+ };
27
+ let nextInstance = 0;
28
+ /** @param {AssistantPresentation} value */
29
+ function presentation(value) {
30
+ return Object.fromEntries(Object.entries(value).filter(([key]) => Object.hasOwn(DEFAULT_PRESENTATION, key)));
31
+ }
32
+ /** @param {HTMLElement | null} node @param {AssistantOptions} [options] */
33
+ export function mountAssistant(node, options = {}) {
34
+ let app, disposed = false, closing;
35
+ const id = `tangle-assistant-${++nextInstance}`;
36
+ let settings = options.settings ?? null, transcript = options.transcript ?? null;
37
+ const aiStorage = options.aiStorage ?? { read: () => settings, write: value => { settings = structuredClone(value); } };
38
+ const aiChat = options.aiChat ?? { read: () => transcript, write: value => { transcript = structuredClone(value); } };
39
+ const md = createMdComponent({ plugins: [highlightPlugin(), mermaidPlugin({ theme: 'host' })], headingIds: true, headingAnchors: true });
40
+ const renderMarkdown = options.renderMarkdown ?? (source => md.view(source, { slugPrefix: `user-content-${id}-` }));
41
+ const controller = createAssistantController({ ...options, aiStorage, aiChat,
42
+ ledger: options.ledger ?? createLedger(), toolbox: options.toolbox ?? createToolbox(), getApp: () => app });
43
+ app = createApp({
44
+ state: { ai: { ...createAssistantState(aiStorage.read(), aiChat.read()), open: options.open ?? false }, presentation: { ...DEFAULT_PRESENTATION, ...presentation(options) } },
45
+ actions: { ...ASSISTANT_ACTIONS, 'assistant/presentation': { patch: [{ op: 'replace', path: '/presentation', value: '$payload' }] } },
46
+ view: { $jslt: '0.1', modes: { assistant: { unmatched: 'error' } }, rules: [
47
+ { match: '$', body: ['section', { class: 'tangle-assistant', 'data-assistant-instance': id }, { $apply: ['$.ui.assistant', 'assistant'] }] },
48
+ ...createAssistantRules(`${id}-models`),
49
+ ] },
50
+ }, {
51
+ node, document: node?.ownerDocument, schedule: options.schedule, onError: options.onError,
52
+ eventFields: formEventFields(), effects: controller.effects,
53
+ afterRender: () => { if (node?.querySelectorAll) md.hydrate(node); },
54
+ viewModel: state => ({ ui: { assistant: { ...assistantView(state.ai, renderMarkdown), ...state.presentation,
55
+ capabilities: state.presentation.capabilities.map(text => ({ text })) } } }),
56
+ });
57
+ // A Markdown fragment belongs to this component, not to its host's hash router.
58
+ function click(event) {
59
+ const anchor = event.target.closest?.('a[href^="#"]');
60
+ const href = anchor?.getAttribute('href');
61
+ if (!href || href.startsWith('#/')) return;
62
+ event.preventDefault();
63
+ let fragment; try { fragment = decodeURIComponent(href.slice(1)); } catch { return; }
64
+ for (const target of node?.querySelectorAll('[id]') ?? []) {
65
+ if (target.id === fragment) { target.scrollIntoView?.({ block: 'nearest' }); break; }
66
+ }
67
+ }
68
+ node?.addEventListener('click', click);
69
+ if (options.open) {
70
+ controller.effects['ai-ensure-settings']({}, app.dispatch);
71
+ controller.effects['ai-ledger-read']({}, app.dispatch);
72
+ }
73
+ return {
74
+ controller,
75
+ read: () => structuredClone(app.getState().ai),
76
+ dispatch(action, payload = null, event = null) { if (!disposed) app.dispatch(action, payload, event); },
77
+ subscribe(listener) { return app.subscribe(state => listener(structuredClone(state.ai))); },
78
+ /** Presentation updates retain the transcript, request and input node. */
79
+ update(/** @type {AssistantPresentation} */ next) {
80
+ if (!disposed) app.dispatch('assistant/presentation', { ...app.getState().presentation, ...presentation(next) });
81
+ },
82
+ dispose() {
83
+ if (disposed) return closing;
84
+ disposed = true; node?.removeEventListener('click', click);
85
+ closing = controller.dispose(); app.destroy(); return closing;
86
+ },
87
+ };
88
+ }
89
+ /** @param {AssistantOptions} [options] */
90
+ export function createAssistantWidget(options = {}) {
91
+ return { mount: (host, props) => mountAssistant(host, { ...options, ...props }),
92
+ update: (handle, props) => handle.update(props), unmount: handle => handle.dispose() };
93
+ }