@tangleai/assistant 0.21.1 → 0.25.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/views.js CHANGED
@@ -1,4 +1,3 @@
1
- //@ts-check
2
1
  /**
3
2
  * The AI assistant panel — mode 'assistant', dispatched with
4
3
  * `$.ui.assistant` as the current node. A global slide-out on every
@@ -7,202 +6,201 @@
7
6
  * the @jarenjs/md component (dogfooding); the streaming reply is plain
8
7
  * text because it changes per token.
9
8
  */
10
-
11
9
  /** One instance owns its datalist identifier and host copy. */
12
10
  export function createAssistantRules(modelListId) {
13
- const settingsForm =
14
- ['form', { class: 'ai-settings',
15
- on: { submit: { action: 'ai/save-settings', preventDefault: true } } },
16
- ['p', { class: 'ai-hint' },
17
- '$.settingsHint'],
18
- ['label', { class: 'ai-field' },
19
- ['span', {}, 'Provider'],
20
- ['select', {
21
- class: 'select',
22
- on: { change: { action: 'ai/setting', with: { key: 'provider' } } },
23
- }, [{ $apply: '$.providers[*]' }]],
24
- ],
25
- { $if: ['$.settings.needsKey',
26
- ['label', { class: 'ai-field' },
27
- ['span', {}, 'API key'],
11
+ const settingsForm = ['form', {
12
+ class: 'ai-settings',
13
+ on: { submit: { action: 'ai/save-settings', preventDefault: true } }
14
+ },
15
+ ['p', { class: 'ai-hint' },
16
+ '$.settingsHint'],
17
+ ['label', { class: 'ai-field' },
18
+ ['span', {}, 'Provider'],
19
+ ['select', {
20
+ class: 'select',
21
+ on: { change: { action: 'ai/setting', with: { key: 'provider' } } },
22
+ }, [{ $apply: '$.providers[*]' }]],
23
+ ],
24
+ {
25
+ $if: ['$.settings.needsKey',
26
+ ['label', { class: 'ai-field' },
27
+ ['span', {}, 'API key'],
28
+ ['input', {
29
+ type: 'password', class: 'editor line', spellcheck: 'false',
30
+ autocomplete: 'off', placeholder: 'sk-…', value: '$.settings.apiKey',
31
+ on: { input: { action: 'ai/setting', with: { key: 'apiKey' } } },
32
+ }],
33
+ ]]
34
+ },
35
+ ['label', { class: 'ai-field' },
36
+ ['span', {}, 'Base URL'],
37
+ ['input', {
38
+ type: 'text', class: 'editor line', spellcheck: 'false',
39
+ placeholder: 'http://localhost:11434 · https://…/v1',
40
+ value: '$.settings.baseUrl',
41
+ on: { input: { action: 'ai/setting', with: { key: 'baseUrl' } } },
42
+ }],
43
+ ],
44
+ ['label', { class: 'ai-field' },
45
+ ['span', {}, 'Model'],
46
+ ['input', {
47
+ type: 'text', class: 'editor line', spellcheck: 'false', list: modelListId,
48
+ placeholder: 'qwen/qwen3-4b · llama3.2 · …', value: '$.settings.model',
49
+ on: { input: { action: 'ai/setting', with: { key: 'model' } } },
50
+ }],
51
+ // a successful probe fills this in: the input becomes a picker
52
+ ['datalist', { id: modelListId }, [{ $apply: '$.settings.probe.models[*]' }]],
53
+ ],
54
+ ['div', { class: 'ai-settings-actions' },
55
+ ['button', { type: 'submit', class: 'btn small' }, 'Save'],
56
+ ['button', {
57
+ type: 'button', class: 'btn small',
58
+ disabled: { $if: ['$.settings.probe.busy', 'disabled', false] },
59
+ on: { click: 'ai/probe' },
60
+ }, { $if: ['$.settings.probe.busy', 'Testing…', 'Test connection'] }],
61
+ ],
62
+ { $if: ['$.settings.probe.ok', ['p', { class: 'ai-hint' }, '$.settings.probe.detail']] },
63
+ { $if: ['$.settings.probe.fail', ['p', { class: 'ai-error' }, '$.settings.probe.detail']] },
64
+ ];
65
+ const intro = ['div', { class: 'ai-intro' }, ['p', {}, '$.intro'], ['ul', {}, [{ $apply: '$.capabilities[*]' }]]];
66
+ // unconfigured: no composer to type into yet — point at the settings
67
+ // form above instead of presenting a chat that cannot send
68
+ const setupIntro = ['div', { class: 'ai-intro' },
69
+ ['p', {}, 'Pick a provider above, add a model (and a key for OpenRouter), then save — the chat opens right here. Nothing leaves your browser except the calls to the provider you choose.'],
70
+ ];
71
+ const composer = ['form', {
72
+ class: 'ai-composer',
73
+ on: { submit: { action: 'ai/send', preventDefault: true } }
74
+ },
75
+ ['textarea', {
76
+ class: 'editor', rows: 2, spellcheck: 'false',
77
+ placeholder: 'Ask the assistant…', value: '$.draft',
78
+ on: { input: 'ai/draft' },
79
+ }],
80
+ ['button', {
81
+ type: 'submit', class: 'btn small ai-send',
82
+ disabled: { $if: [{ $eq: ['$.status', 'streaming'] }, 'disabled', false] },
83
+ }, { $if: [{ $eq: ['$.status', 'streaming'] }, '…', 'Send'] }],
84
+ { $if: ['$.streaming', ['button', { type: 'button', class: 'btn small', on: { click: 'ai/cancel' } }, 'Cancel']] },
85
+ ];
86
+ // The ledger surface: one objective that outlives the tab, what has
87
+ // been recorded against it, and the archived rounds a compacted session
88
+ // can still reach. Without a goal it is a single input — the smallest
89
+ // thing that can be ignored.
90
+ const goalForm = ['form', {
91
+ class: 'ai-goal-set',
92
+ on: { submit: { action: 'ai/goal-set', preventDefault: true } }
93
+ },
28
94
  ['input', {
29
- type: 'password', class: 'editor line', spellcheck: 'false',
30
- autocomplete: 'off', placeholder: 'sk-…', value: '$.settings.apiKey',
31
- on: { input: { action: 'ai/setting', with: { key: 'apiKey' } } },
32
- }],
33
- ]] },
34
- ['label', { class: 'ai-field' },
35
- ['span', {}, 'Base URL'],
36
- ['input', {
37
- type: 'text', class: 'editor line', spellcheck: 'false',
38
- placeholder: 'http://localhost:11434 · https://…/v1',
39
- value: '$.settings.baseUrl',
40
- on: { input: { action: 'ai/setting', with: { key: 'baseUrl' } } },
41
- }],
42
- ],
43
- ['label', { class: 'ai-field' },
44
- ['span', {}, 'Model'],
45
- ['input', {
46
- type: 'text', class: 'editor line', spellcheck: 'false', list: modelListId,
47
- placeholder: 'qwen/qwen3-4b · llama3.2 · ', value: '$.settings.model',
48
- on: { input: { action: 'ai/setting', with: { key: 'model' } } },
49
- }],
50
- // a successful probe fills this in: the input becomes a picker
51
- ['datalist', { id: modelListId }, [{ $apply: '$.settings.probe.models[*]' }]],
52
- ],
53
- ['div', { class: 'ai-settings-actions' },
54
- ['button', { type: 'submit', class: 'btn small' }, 'Save'],
55
- ['button', {
56
- type: 'button', class: 'btn small',
57
- disabled: { $if: ['$.settings.probe.busy', 'disabled', false] },
58
- on: { click: 'ai/probe' },
59
- }, { $if: ['$.settings.probe.busy', 'Testing…', 'Test connection'] }],
60
- ],
61
- { $if: ['$.settings.probe.ok', ['p', { class: 'ai-hint' }, '$.settings.probe.detail']] },
62
- { $if: ['$.settings.probe.fail', ['p', { class: 'ai-error' }, '$.settings.probe.detail']] },
63
- ];
64
-
65
- const intro = ['div', { class: 'ai-intro' }, ['p', {}, '$.intro'], ['ul', {}, [{ $apply: '$.capabilities[*]' }]]];
66
-
67
- // unconfigured: no composer to type into yet — point at the settings
68
- // form above instead of presenting a chat that cannot send
69
- const setupIntro =
70
- ['div', { class: 'ai-intro' },
71
- ['p', {}, 'Pick a provider above, add a model (and a key for OpenRouter), then save — the chat opens right here. Nothing leaves your browser except the calls to the provider you choose.'],
72
- ];
73
-
74
- const composer =
75
- ['form', { class: 'ai-composer',
76
- on: { submit: { action: 'ai/send', preventDefault: true } } },
77
- ['textarea', {
78
- class: 'editor', rows: 2, spellcheck: 'false',
79
- placeholder: 'Ask the assistant…', value: '$.draft',
80
- on: { input: 'ai/draft' },
81
- }],
82
- ['button', {
83
- type: 'submit', class: 'btn small ai-send',
84
- disabled: { $if: [{ $eq: ['$.status', 'streaming'] }, 'disabled', false] },
85
- }, { $if: [{ $eq: ['$.status', 'streaming'] }, '', 'Send'] }],
86
- { $if: ['$.streaming', ['button', { type: 'button', class: 'btn small', on: { click: 'ai/cancel' } }, 'Cancel']] },
87
- ];
88
-
89
- // The ledger surface: one objective that outlives the tab, what has
90
- // been recorded against it, and the archived rounds a compacted session
91
- // can still reach. Without a goal it is a single input — the smallest
92
- // thing that can be ignored.
93
- const goalForm =
94
- ['form', { class: 'ai-goal-set',
95
- on: { submit: { action: 'ai/goal-set', preventDefault: true } } },
96
- ['input', {
97
- type: 'text', class: 'editor line', spellcheck: 'false',
98
- placeholder: 'Set an objective the assistant keeps across sessions…',
99
- value: '$.goalDraft',
100
- on: { input: 'ai/goal-draft' },
101
- }],
102
- ['button', { type: 'submit', class: 'btn small' }, 'Set'],
103
- ];
104
-
105
- const goalPanel =
106
- ['div', { class: 'ai-goal' },
107
- ['div', { class: 'ai-goal-head' },
108
- ['span', { class: 'ai-goal-label' }, 'Objective'],
109
- ['button', {
110
- type: 'button', class: 'ai-icon', title: 'Clear the objective',
111
- 'aria-label': 'Clear the objective', on: { click: 'ai/goal-clear' },
112
- }, ''],
113
- ],
114
- ['p', { class: 'ai-goal-text' }, '$.goal.objective'],
115
- ['ul', { class: 'ai-goal-progress' }, [{ $apply: '$.goal.progress[*]' }]],
116
- { $if: ['$.goal.checkpointLabel', ['p', { class: 'ai-hint' }, '$.goal.checkpointLabel']] },
117
- { $if: ['$.goal.more', ['p', { class: 'ai-hint' }, '…and ', '$.goal.more', ' earlier entries']] },
118
- ['div', { class: 'ai-goal-actions' },
119
- ['button', {
120
- type: 'button', class: 'btn small',
121
- disabled: { $if: ['$.remembering', 'disabled', false] },
122
- on: { click: 'ai/remember' },
123
- }, { $if: ['$.remembering', 'Remembering…', 'Remember this session'] }],
124
- { $if: ['$.memories', ['span', { class: 'ai-hint' }, '$.memoryLabel']] },
125
- ],
126
- { $if: ['$.remembered', ['p', { class: 'ai-hint' }, '$.remembered']] },
127
- ];
128
-
129
- const panel =
130
- ['div', { class: 'ai-panel' },
131
- ['div', { class: 'ai-head' },
132
- ['span', { class: 'ai-title' }, '$.title'],
133
- ['button', {
134
- type: 'button', class: 'ai-icon', title: 'Settings',
135
- 'aria-label': 'Assistant settings', on: { click: 'ai/settings-toggle' },
136
- }, ''],
137
- ['button', {
138
- type: 'button', class: 'ai-icon', title: 'Clear the conversation',
139
- 'aria-label': 'Clear conversation', on: { click: 'ai/clear' },
140
- }, '⌫'],
141
- ['button', {
142
- type: 'button', class: 'ai-icon', title: 'Close',
143
- 'aria-label': 'Close assistant', on: { click: 'ai/toggle' },
144
- }, '✕'],
145
- ],
146
- { $if: ['$.showSettings', settingsForm] },
147
- { $if: ['$.configured', { $if: ['$.goal', goalPanel, goalForm] }] },
148
- ['div', { class: 'ai-log' },
149
- { $if: ['$.empty', { $if: ['$.configured', intro, setupIntro] }] },
150
- [{ $apply: '$.messages[*]' }],
151
- { $if: [{ $and: ['$.streaming', '$.pending'] },
152
- ['div', { class: 'ai-msg assistant' }, ['p', {}, '$.pending']]] },
153
- { $if: ['$.activity',
154
- ['p', { class: 'ai-activity' }, 'Running ', ['code', {}, '$.activity'], '…']] },
155
- { $if: [{ $and: [{ $eq: ['$.status', 'streaming'] }, { $not: '$.pending' }, { $not: '$.activity' }] },
156
- ['p', { class: 'ai-activity' }, '$.thinkingLabel']] },
157
- { $if: ['$.error', ['p', { class: 'ai-error' }, '$.error']] },
158
- // a compacted session says so: the rounds that left the request are
159
- // in slots with addresses, not gone, and the panel is where that
160
- // stops being an implementation detail
161
- { $if: ['$.persistenceLabel', ['p', { class: 'ai-archived' }, '$.persistenceLabel']] },
162
- { $if: ['$.retentionLabel', ['p', { class: 'ai-archived' }, '$.retentionLabel']] },
163
- { $if: ['$.archived', ['p', { class: 'ai-archived' }, '$.archivedLabel']] },
164
- ],
165
- { $if: ['$.configured', composer] },
166
- ];
167
-
168
- return [
169
- {
170
- match: '$.ui.assistant', mode: 'assistant',
171
- body: ['div', { class: { $if: ['$.open', 'ai open', 'ai'] } },
172
- ['button', {
173
- type: 'button', class: 'ai-launch', title: '$.launchTitle',
174
- 'aria-label': '$.launchTitle', on: { click: 'ai/toggle' },
175
- }, '✦', ['span', { class: 'ai-launch-label' }, '$.launchLabel']],
176
- { $if: ['$.open', panel] },
177
- ],
178
- },
179
- { match: '$.ui.assistant.capabilities[*]', mode: 'assistant', body: ['li', {}, '$.text'] },
180
- {
181
- match: '$.ui.assistant.providers[*]', mode: 'assistant',
182
- body: ['option', { value: '$.value', selected: '$.selected' }, '$.label'],
183
- },
184
- {
185
- match: '$.ui.assistant.settings.probe.models[*]', mode: 'assistant',
186
- body: ['option', { value: '$.id' }],
187
- },
188
- {
189
- match: "$.ui.assistant.messages[?@.role == 'user']", mode: 'assistant',
190
- body: ['div', { class: 'ai-msg user' }, ['p', {}, '$.text']],
191
- },
192
- {
193
- // the assistant reply is a ready-made @jarenjs/md vnode, spliced
194
- // in verbatim (no dispatch into it)
195
- match: "$.ui.assistant.messages[?@.role == 'assistant']", mode: 'assistant',
196
- body: ['div', { class: 'ai-msg assistant' }, '$.article'],
197
- },
198
- {
199
- // one recorded step towards the objective. The evidence is shown
200
- // beside the note, always: a progress log of unevidenced claims is
201
- // exactly what the ledger's schema refuses to store, and the panel
202
- // should not present one either.
203
- match: '$.ui.assistant.goal.progress[*]', mode: 'assistant',
204
- body: ['li', {}, '$.note', ['span', { class: 'ai-evidence' }, '$.evidence']],
205
- },
206
- ];
207
-
95
+ type: 'text', class: 'editor line', spellcheck: 'false',
96
+ placeholder: 'Set an objective the assistant keeps across sessions…',
97
+ value: '$.goalDraft',
98
+ on: { input: 'ai/goal-draft' },
99
+ }],
100
+ ['button', { type: 'submit', class: 'btn small' }, 'Set'],
101
+ ];
102
+ const goalPanel = ['div', { class: 'ai-goal' },
103
+ ['div', { class: 'ai-goal-head' },
104
+ ['span', { class: 'ai-goal-label' }, 'Objective'],
105
+ ['button', {
106
+ type: 'button', class: 'ai-icon', title: 'Clear the objective',
107
+ 'aria-label': 'Clear the objective', on: { click: 'ai/goal-clear' },
108
+ }, '✕'],
109
+ ],
110
+ ['p', { class: 'ai-goal-text' }, '$.goal.objective'],
111
+ ['ul', { class: 'ai-goal-progress' }, [{ $apply: '$.goal.progress[*]' }]],
112
+ { $if: ['$.goal.checkpointLabel', ['p', { class: 'ai-hint' }, '$.goal.checkpointLabel']] },
113
+ { $if: ['$.goal.more', ['p', { class: 'ai-hint' }, '…and ', '$.goal.more', ' earlier entries']] },
114
+ ['div', { class: 'ai-goal-actions' },
115
+ ['button', {
116
+ type: 'button', class: 'btn small',
117
+ disabled: { $if: ['$.remembering', 'disabled', false] },
118
+ on: { click: 'ai/remember' },
119
+ }, { $if: ['$.remembering', 'Remembering…', 'Remember this session'] }],
120
+ { $if: ['$.memories', ['span', { class: 'ai-hint' }, '$.memoryLabel']] },
121
+ ],
122
+ { $if: ['$.remembered', ['p', { class: 'ai-hint' }, '$.remembered']] },
123
+ ];
124
+ const panel = ['div', { class: 'ai-panel' },
125
+ ['div', { class: 'ai-head' },
126
+ ['span', { class: 'ai-title' }, '$.title'],
127
+ ['button', {
128
+ type: 'button', class: 'ai-icon', title: 'Settings',
129
+ 'aria-label': 'Assistant settings', on: { click: 'ai/settings-toggle' },
130
+ }, '⚙'],
131
+ ['button', {
132
+ type: 'button', class: 'ai-icon', title: 'Clear the conversation',
133
+ 'aria-label': 'Clear conversation', on: { click: 'ai/clear' },
134
+ }, '⌫'],
135
+ ['button', {
136
+ type: 'button', class: 'ai-icon', title: 'Close',
137
+ 'aria-label': 'Close assistant', on: { click: 'ai/toggle' },
138
+ }, '✕'],
139
+ ],
140
+ { $if: ['$.showSettings', settingsForm] },
141
+ { $if: ['$.configured', { $if: ['$.goal', goalPanel, goalForm] }] },
142
+ ['div', { class: 'ai-log' },
143
+ { $if: ['$.empty', { $if: ['$.configured', intro, setupIntro] }] },
144
+ [{ $apply: '$.messages[*]' }],
145
+ {
146
+ $if: [{ $and: ['$.streaming', '$.pending'] },
147
+ ['div', { class: 'ai-msg assistant' }, ['p', {}, '$.pending']]]
148
+ },
149
+ {
150
+ $if: ['$.activity',
151
+ ['p', { class: 'ai-activity' }, 'Running ', ['code', {}, '$.activity'], '']]
152
+ },
153
+ {
154
+ $if: [{ $and: [{ $eq: ['$.status', 'streaming'] }, { $not: '$.pending' }, { $not: '$.activity' }] },
155
+ ['p', { class: 'ai-activity' }, '$.thinkingLabel']]
156
+ },
157
+ { $if: ['$.error', ['p', { class: 'ai-error' }, '$.error']] },
158
+ // a compacted session says so: the rounds that left the request are
159
+ // in slots with addresses, not gone, and the panel is where that
160
+ // stops being an implementation detail
161
+ { $if: ['$.persistenceLabel', ['p', { class: 'ai-archived' }, '$.persistenceLabel']] },
162
+ { $if: ['$.retentionLabel', ['p', { class: 'ai-archived' }, '$.retentionLabel']] },
163
+ { $if: ['$.archived', ['p', { class: 'ai-archived' }, '$.archivedLabel']] },
164
+ ],
165
+ { $if: ['$.configured', composer] },
166
+ ];
167
+ return [
168
+ {
169
+ match: '$.ui.assistant', mode: 'assistant',
170
+ body: ['div', { class: { $if: ['$.open', 'ai open', 'ai'] } },
171
+ ['button', {
172
+ type: 'button', class: 'ai-launch', title: '$.launchTitle',
173
+ 'aria-label': '$.launchTitle', on: { click: 'ai/toggle' },
174
+ }, '✦', ['span', { class: 'ai-launch-label' }, '$.launchLabel']],
175
+ { $if: ['$.open', panel] },
176
+ ],
177
+ },
178
+ { match: '$.ui.assistant.capabilities[*]', mode: 'assistant', body: ['li', {}, '$.text'] },
179
+ {
180
+ match: '$.ui.assistant.providers[*]', mode: 'assistant',
181
+ body: ['option', { value: '$.value', selected: '$.selected' }, '$.label'],
182
+ },
183
+ {
184
+ match: '$.ui.assistant.settings.probe.models[*]', mode: 'assistant',
185
+ body: ['option', { value: '$.id' }],
186
+ },
187
+ {
188
+ match: "$.ui.assistant.messages[?@.role == 'user']", mode: 'assistant',
189
+ body: ['div', { class: 'ai-msg user' }, ['p', {}, '$.text']],
190
+ },
191
+ {
192
+ // the assistant reply is a ready-made @jarenjs/md vnode, spliced
193
+ // in verbatim (no dispatch into it)
194
+ match: "$.ui.assistant.messages[?@.role == 'assistant']", mode: 'assistant',
195
+ body: ['div', { class: 'ai-msg assistant' }, '$.article'],
196
+ },
197
+ {
198
+ // one recorded step towards the objective. The evidence is shown
199
+ // beside the note, always: a progress log of unevidenced claims is
200
+ // exactly what the ledger's schema refuses to store, and the panel
201
+ // should not present one either.
202
+ match: '$.ui.assistant.goal.progress[*]', mode: 'assistant',
203
+ body: ['li', {}, '$.note', ['span', { class: 'ai-evidence' }, '$.evidence']],
204
+ },
205
+ ];
208
206
  }