@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 +35 -0
- package/LICENSE +21 -0
- package/README.md +41 -0
- package/package.json +56 -0
- package/src/actions.d.ts +237 -0
- package/src/actions.js +145 -0
- package/src/component.d.ts +55 -0
- package/src/component.js +93 -0
- package/src/controller.d.ts +36 -0
- package/src/controller.js +322 -0
- package/src/index.d.ts +4 -0
- package/src/index.js +4 -0
- package/src/settings.d.ts +6 -0
- package/src/settings.js +11 -0
- package/src/state.d.ts +32 -0
- package/src/state.js +40 -0
- package/src/viewmodel.d.ts +55 -0
- package/src/viewmodel.js +77 -0
- package/src/views.d.ts +332 -0
- package/src/views.js +208 -0
- package/styles/assistant.css +115 -0
package/src/views.d.ts
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The AI assistant panel — mode 'assistant', dispatched with
|
|
3
|
+
* `$.ui.assistant` as the current node. A global slide-out on every
|
|
4
|
+
* page: a bring-your-own-key chat that drives the playground through
|
|
5
|
+
* @tangleai/agents's schema-guarded tools. Assistant replies render through
|
|
6
|
+
* the @jarenjs/md component (dogfooding); the streaming reply is plain
|
|
7
|
+
* text because it changes per token.
|
|
8
|
+
*/
|
|
9
|
+
/** One instance owns its datalist identifier and host copy. */
|
|
10
|
+
export function createAssistantRules(modelListId: any): ({
|
|
11
|
+
match: string;
|
|
12
|
+
mode: string;
|
|
13
|
+
body: (string | {
|
|
14
|
+
class: {
|
|
15
|
+
$if: string[];
|
|
16
|
+
};
|
|
17
|
+
$if?: undefined;
|
|
18
|
+
} | (string | {
|
|
19
|
+
type: string;
|
|
20
|
+
class: string;
|
|
21
|
+
title: string;
|
|
22
|
+
'aria-label': string;
|
|
23
|
+
on: {
|
|
24
|
+
click: string;
|
|
25
|
+
};
|
|
26
|
+
} | (string | {
|
|
27
|
+
class: string;
|
|
28
|
+
})[])[] | {
|
|
29
|
+
$if: (string | (string | {
|
|
30
|
+
class: string;
|
|
31
|
+
$if?: undefined;
|
|
32
|
+
} | (string | {
|
|
33
|
+
class: string;
|
|
34
|
+
} | (string | {
|
|
35
|
+
class: string;
|
|
36
|
+
})[] | (string | {
|
|
37
|
+
type: string;
|
|
38
|
+
class: string;
|
|
39
|
+
title: string;
|
|
40
|
+
'aria-label': string;
|
|
41
|
+
on: {
|
|
42
|
+
click: string;
|
|
43
|
+
};
|
|
44
|
+
})[])[] | {
|
|
45
|
+
$if: (string | (string | {
|
|
46
|
+
class: string;
|
|
47
|
+
on: {
|
|
48
|
+
submit: {
|
|
49
|
+
action: string;
|
|
50
|
+
preventDefault: boolean;
|
|
51
|
+
};
|
|
52
|
+
};
|
|
53
|
+
$if?: undefined;
|
|
54
|
+
} | (string | {
|
|
55
|
+
class: string;
|
|
56
|
+
} | {}[] | (string | {
|
|
57
|
+
class: string;
|
|
58
|
+
on: {
|
|
59
|
+
change: {
|
|
60
|
+
action: string;
|
|
61
|
+
with: {
|
|
62
|
+
key: string;
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
} | {
|
|
67
|
+
$apply: string;
|
|
68
|
+
}[])[])[] | {
|
|
69
|
+
$if: (string | (string | {
|
|
70
|
+
class: string;
|
|
71
|
+
} | {}[] | (string | {
|
|
72
|
+
type: string;
|
|
73
|
+
class: string;
|
|
74
|
+
spellcheck: string;
|
|
75
|
+
autocomplete: string;
|
|
76
|
+
placeholder: string;
|
|
77
|
+
value: string;
|
|
78
|
+
on: {
|
|
79
|
+
input: {
|
|
80
|
+
action: string;
|
|
81
|
+
with: {
|
|
82
|
+
key: string;
|
|
83
|
+
};
|
|
84
|
+
};
|
|
85
|
+
};
|
|
86
|
+
})[])[])[];
|
|
87
|
+
class?: undefined;
|
|
88
|
+
on?: undefined;
|
|
89
|
+
} | (string | {
|
|
90
|
+
class: string;
|
|
91
|
+
} | {}[] | (string | {
|
|
92
|
+
type: string;
|
|
93
|
+
class: string;
|
|
94
|
+
spellcheck: string;
|
|
95
|
+
placeholder: string;
|
|
96
|
+
value: string;
|
|
97
|
+
on: {
|
|
98
|
+
input: {
|
|
99
|
+
action: string;
|
|
100
|
+
with: {
|
|
101
|
+
key: string;
|
|
102
|
+
};
|
|
103
|
+
};
|
|
104
|
+
};
|
|
105
|
+
})[])[] | (string | {
|
|
106
|
+
class: string;
|
|
107
|
+
} | {}[] | (string | {
|
|
108
|
+
type: string;
|
|
109
|
+
class: string;
|
|
110
|
+
spellcheck: string;
|
|
111
|
+
list: any;
|
|
112
|
+
placeholder: string;
|
|
113
|
+
value: string;
|
|
114
|
+
on: {
|
|
115
|
+
input: {
|
|
116
|
+
action: string;
|
|
117
|
+
with: {
|
|
118
|
+
key: string;
|
|
119
|
+
};
|
|
120
|
+
};
|
|
121
|
+
};
|
|
122
|
+
})[] | (string | {
|
|
123
|
+
id: any;
|
|
124
|
+
} | {
|
|
125
|
+
$apply: string;
|
|
126
|
+
}[])[])[] | (string | {
|
|
127
|
+
class: string;
|
|
128
|
+
} | (string | {
|
|
129
|
+
type: string;
|
|
130
|
+
class: string;
|
|
131
|
+
})[] | (string | {
|
|
132
|
+
type: string;
|
|
133
|
+
class: string;
|
|
134
|
+
disabled: {
|
|
135
|
+
$if: (string | boolean)[];
|
|
136
|
+
};
|
|
137
|
+
on: {
|
|
138
|
+
click: string;
|
|
139
|
+
};
|
|
140
|
+
$if?: undefined;
|
|
141
|
+
} | {
|
|
142
|
+
$if: string[];
|
|
143
|
+
type?: undefined;
|
|
144
|
+
class?: undefined;
|
|
145
|
+
disabled?: undefined;
|
|
146
|
+
on?: undefined;
|
|
147
|
+
})[])[])[])[];
|
|
148
|
+
class?: undefined;
|
|
149
|
+
} | {
|
|
150
|
+
$if: (string | {
|
|
151
|
+
$if: (string | (string | {
|
|
152
|
+
class: string;
|
|
153
|
+
$if?: undefined;
|
|
154
|
+
} | (string | {
|
|
155
|
+
class: string;
|
|
156
|
+
} | (string | {
|
|
157
|
+
class: string;
|
|
158
|
+
})[] | (string | {
|
|
159
|
+
type: string;
|
|
160
|
+
class: string;
|
|
161
|
+
title: string;
|
|
162
|
+
'aria-label': string;
|
|
163
|
+
on: {
|
|
164
|
+
click: string;
|
|
165
|
+
};
|
|
166
|
+
})[])[] | (string | {
|
|
167
|
+
class: string;
|
|
168
|
+
} | {
|
|
169
|
+
$apply: string;
|
|
170
|
+
}[])[] | {
|
|
171
|
+
$if: (string | (string | {
|
|
172
|
+
class: string;
|
|
173
|
+
})[])[];
|
|
174
|
+
class?: undefined;
|
|
175
|
+
} | (string | {
|
|
176
|
+
class: string;
|
|
177
|
+
$if?: undefined;
|
|
178
|
+
} | (string | {
|
|
179
|
+
type: string;
|
|
180
|
+
class: string;
|
|
181
|
+
disabled: {
|
|
182
|
+
$if: (string | boolean)[];
|
|
183
|
+
};
|
|
184
|
+
on: {
|
|
185
|
+
click: string;
|
|
186
|
+
};
|
|
187
|
+
$if?: undefined;
|
|
188
|
+
} | {
|
|
189
|
+
$if: string[];
|
|
190
|
+
type?: undefined;
|
|
191
|
+
class?: undefined;
|
|
192
|
+
disabled?: undefined;
|
|
193
|
+
on?: undefined;
|
|
194
|
+
})[] | {
|
|
195
|
+
$if: (string | (string | {
|
|
196
|
+
class: string;
|
|
197
|
+
})[])[];
|
|
198
|
+
class?: undefined;
|
|
199
|
+
})[])[] | (string | {
|
|
200
|
+
class: string;
|
|
201
|
+
on: {
|
|
202
|
+
submit: {
|
|
203
|
+
action: string;
|
|
204
|
+
preventDefault: boolean;
|
|
205
|
+
};
|
|
206
|
+
};
|
|
207
|
+
} | (string | {
|
|
208
|
+
type: string;
|
|
209
|
+
class: string;
|
|
210
|
+
spellcheck: string;
|
|
211
|
+
placeholder: string;
|
|
212
|
+
value: string;
|
|
213
|
+
on: {
|
|
214
|
+
input: string;
|
|
215
|
+
};
|
|
216
|
+
})[] | (string | {
|
|
217
|
+
type: string;
|
|
218
|
+
class: string;
|
|
219
|
+
})[])[])[];
|
|
220
|
+
})[];
|
|
221
|
+
class?: undefined;
|
|
222
|
+
} | (string | {
|
|
223
|
+
class: string;
|
|
224
|
+
$if?: undefined;
|
|
225
|
+
} | {
|
|
226
|
+
$if: (string | {
|
|
227
|
+
$if: (string | (string | {
|
|
228
|
+
class: string;
|
|
229
|
+
} | {}[])[])[];
|
|
230
|
+
})[];
|
|
231
|
+
class?: undefined;
|
|
232
|
+
} | {
|
|
233
|
+
$apply: string;
|
|
234
|
+
}[] | {
|
|
235
|
+
$if: ({
|
|
236
|
+
$and: string[];
|
|
237
|
+
} | (string | {
|
|
238
|
+
class: string;
|
|
239
|
+
} | {}[])[])[];
|
|
240
|
+
class?: undefined;
|
|
241
|
+
} | {
|
|
242
|
+
$if: (string | (string | {
|
|
243
|
+
class: string;
|
|
244
|
+
} | {}[])[])[];
|
|
245
|
+
class?: undefined;
|
|
246
|
+
} | {
|
|
247
|
+
$if: ({
|
|
248
|
+
$and: ({
|
|
249
|
+
$eq: string[];
|
|
250
|
+
$not?: undefined;
|
|
251
|
+
} | {
|
|
252
|
+
$not: string;
|
|
253
|
+
$eq?: undefined;
|
|
254
|
+
})[];
|
|
255
|
+
} | (string | {
|
|
256
|
+
class: string;
|
|
257
|
+
})[])[];
|
|
258
|
+
class?: undefined;
|
|
259
|
+
})[] | {
|
|
260
|
+
$if: (string | (string | {
|
|
261
|
+
class: string;
|
|
262
|
+
on: {
|
|
263
|
+
submit: {
|
|
264
|
+
action: string;
|
|
265
|
+
preventDefault: boolean;
|
|
266
|
+
};
|
|
267
|
+
};
|
|
268
|
+
$if?: undefined;
|
|
269
|
+
} | (string | {
|
|
270
|
+
class: string;
|
|
271
|
+
rows: number;
|
|
272
|
+
spellcheck: string;
|
|
273
|
+
placeholder: string;
|
|
274
|
+
value: string;
|
|
275
|
+
on: {
|
|
276
|
+
input: string;
|
|
277
|
+
};
|
|
278
|
+
})[] | (string | {
|
|
279
|
+
type: string;
|
|
280
|
+
class: string;
|
|
281
|
+
disabled: {
|
|
282
|
+
$if: (string | boolean | {
|
|
283
|
+
$eq: string[];
|
|
284
|
+
})[];
|
|
285
|
+
};
|
|
286
|
+
$if?: undefined;
|
|
287
|
+
} | {
|
|
288
|
+
$if: (string | {
|
|
289
|
+
$eq: string[];
|
|
290
|
+
})[];
|
|
291
|
+
type?: undefined;
|
|
292
|
+
class?: undefined;
|
|
293
|
+
disabled?: undefined;
|
|
294
|
+
})[] | {
|
|
295
|
+
$if: (string | (string | {
|
|
296
|
+
type: string;
|
|
297
|
+
class: string;
|
|
298
|
+
on: {
|
|
299
|
+
click: string;
|
|
300
|
+
};
|
|
301
|
+
})[])[];
|
|
302
|
+
class?: undefined;
|
|
303
|
+
on?: undefined;
|
|
304
|
+
})[])[];
|
|
305
|
+
class?: undefined;
|
|
306
|
+
})[])[];
|
|
307
|
+
class?: undefined;
|
|
308
|
+
})[];
|
|
309
|
+
} | {
|
|
310
|
+
match: string;
|
|
311
|
+
mode: string;
|
|
312
|
+
body: (string | {
|
|
313
|
+
value: string;
|
|
314
|
+
selected: string;
|
|
315
|
+
})[];
|
|
316
|
+
} | {
|
|
317
|
+
match: string;
|
|
318
|
+
mode: string;
|
|
319
|
+
body: (string | {
|
|
320
|
+
value: string;
|
|
321
|
+
})[];
|
|
322
|
+
} | {
|
|
323
|
+
match: string;
|
|
324
|
+
mode: string;
|
|
325
|
+
body: (string | {
|
|
326
|
+
class: string;
|
|
327
|
+
} | {}[])[];
|
|
328
|
+
} | {
|
|
329
|
+
match: string;
|
|
330
|
+
mode: string;
|
|
331
|
+
body: {}[];
|
|
332
|
+
})[];
|
package/src/views.js
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* The AI assistant panel — mode 'assistant', dispatched with
|
|
4
|
+
* `$.ui.assistant` as the current node. A global slide-out on every
|
|
5
|
+
* page: a bring-your-own-key chat that drives the playground through
|
|
6
|
+
* @tangleai/agents's schema-guarded tools. Assistant replies render through
|
|
7
|
+
* the @jarenjs/md component (dogfooding); the streaming reply is plain
|
|
8
|
+
* text because it changes per token.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** One instance owns its datalist identifier and host copy. */
|
|
12
|
+
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'],
|
|
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
|
+
['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
|
+
|
|
208
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/* ---- the AI assistant slide-out (@tangleai/assistant) ---- */
|
|
2
|
+
:where(.tangle-assistant) .ai-launch {
|
|
3
|
+
position: fixed; right: var(--space-5, 1.25rem); bottom: var(--space-5, 1.25rem); z-index: 150;
|
|
4
|
+
min-height: 2.75rem; /* primary tap target (DESIGN §5) */
|
|
5
|
+
border: 1px solid var(--accent, #3b82f6); border-radius: 999px;
|
|
6
|
+
padding: var(--space-2, .5rem) var(--space-4, 1rem); font-size: 0.9rem; font-weight: 600;
|
|
7
|
+
background: var(--accent, #3b82f6); color: var(--accent-fg, white); cursor: pointer;
|
|
8
|
+
box-shadow: 0 6px 24px rgb(0 0 0 / 0.18);
|
|
9
|
+
}
|
|
10
|
+
:where(.tangle-assistant) .ai-launch:hover { background: var(--accent-hover, #2563eb); border-color: var(--accent-hover, #2563eb); }
|
|
11
|
+
:where(.tangle-assistant) .ai.open .ai-launch { display: none; }
|
|
12
|
+
:where(.tangle-assistant) .ai-panel {
|
|
13
|
+
position: fixed; right: var(--space-5, 1.25rem); bottom: var(--space-5, 1.25rem); z-index: 150;
|
|
14
|
+
display: flex; flex-direction: column; width: min(28rem, calc(100vw - 2 * var(--space-5, 1.25rem)));
|
|
15
|
+
max-height: min(40rem, calc(100dvh - 2 * var(--space-5, 1.25rem)));
|
|
16
|
+
background: var(--surface, #fff); border: 1px solid var(--border, #d6dae1); border-radius: var(--radius, .625rem);
|
|
17
|
+
box-shadow: 0 12px 40px rgb(0 0 0 / 0.22); overflow: hidden;
|
|
18
|
+
}
|
|
19
|
+
:where(.tangle-assistant) .ai-head {
|
|
20
|
+
display: flex; align-items: center; gap: var(--space-2, .5rem);
|
|
21
|
+
padding: var(--space-3, .75rem) var(--space-4, 1rem); border-bottom: 1px solid var(--border, #d6dae1);
|
|
22
|
+
}
|
|
23
|
+
:where(.tangle-assistant) .ai-title { font-weight: 700; margin-right: auto; }
|
|
24
|
+
:where(.tangle-assistant) .ai-icon {
|
|
25
|
+
border: 0; background: transparent; color: var(--muted, #596273); cursor: pointer;
|
|
26
|
+
width: 2.75rem; height: 2.75rem; /* primary tap targets (DESIGN §5) */
|
|
27
|
+
border-radius: var(--radius-sm, .5rem); font-size: 1rem;
|
|
28
|
+
}
|
|
29
|
+
:where(.tangle-assistant) .ai-icon:hover { color: var(--accent, #3b82f6); background: var(--accent-soft, #eff6ff); }
|
|
30
|
+
:where(.tangle-assistant) .ai-settings {
|
|
31
|
+
display: flex; flex-direction: column; gap: var(--space-3, .75rem);
|
|
32
|
+
padding: var(--space-4, 1rem); border-bottom: 1px solid var(--border, #d6dae1);
|
|
33
|
+
}
|
|
34
|
+
:where(.tangle-assistant) .ai-hint { color: var(--muted, #596273); font-size: 0.82rem; margin: 0; }
|
|
35
|
+
:where(.tangle-assistant) .ai-field { display: flex; flex-direction: column; gap: var(--space-1, .25rem); font-size: 0.85rem; }
|
|
36
|
+
:where(.tangle-assistant) .ai-field > span { color: var(--muted, #596273); }
|
|
37
|
+
:where(.tangle-assistant) .ai-settings .btn { align-self: flex-start; }
|
|
38
|
+
:where(.tangle-assistant) .ai-settings-actions { display: flex; gap: var(--space-2, .5rem); }
|
|
39
|
+
/* the ledger surface: the objective that outlives the tab, its evidenced
|
|
40
|
+
progress, and what a compacted session can still recall */
|
|
41
|
+
:where(.tangle-assistant) .ai-goal, :where(.tangle-assistant) .ai-goal-set {
|
|
42
|
+
display: flex; gap: var(--space-2, .5rem);
|
|
43
|
+
padding: var(--space-3, .75rem) var(--space-4, 1rem); border-bottom: 1px solid var(--border, #d6dae1);
|
|
44
|
+
}
|
|
45
|
+
:where(.tangle-assistant) .ai-goal { flex-direction: column; }
|
|
46
|
+
:where(.tangle-assistant) .ai-goal-set > input { flex: 1; min-width: 0; }
|
|
47
|
+
:where(.tangle-assistant) .ai-goal-head { display: flex; align-items: center; gap: var(--space-2, .5rem); }
|
|
48
|
+
:where(.tangle-assistant) .ai-goal-label {
|
|
49
|
+
color: var(--muted, #596273); font-size: 0.75rem; font-weight: 600;
|
|
50
|
+
letter-spacing: 0.04em; text-transform: uppercase; margin-right: auto;
|
|
51
|
+
}
|
|
52
|
+
:where(.tangle-assistant) .ai-goal-text { margin: 0; font-size: 0.9rem; font-weight: 600; overflow-wrap: anywhere; }
|
|
53
|
+
:where(.tangle-assistant) .ai-goal-progress {
|
|
54
|
+
margin: 0; padding-left: var(--space-5, 1.25rem);
|
|
55
|
+
color: var(--muted, #596273); font-size: 0.82rem;
|
|
56
|
+
}
|
|
57
|
+
:where(.tangle-assistant) .ai-goal-progress li { margin: var(--space-1, .25rem) 0; overflow-wrap: anywhere; }
|
|
58
|
+
:where(.tangle-assistant) .ai-goal-progress:empty { display: none; }
|
|
59
|
+
:where(.tangle-assistant) .ai-evidence { display: block; color: var(--muted, #596273); font-size: 0.75rem; font-style: italic; }
|
|
60
|
+
:where(.tangle-assistant) .ai-goal-actions { display: flex; align-items: center; gap: var(--space-2, .5rem); flex-wrap: wrap; }
|
|
61
|
+
:where(.tangle-assistant) .ai-log {
|
|
62
|
+
flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain;
|
|
63
|
+
display: flex; flex-direction: column; gap: var(--space-3, .75rem); padding: var(--space-4, 1rem);
|
|
64
|
+
}
|
|
65
|
+
:where(.tangle-assistant) .ai-archived {
|
|
66
|
+
color: var(--muted, #596273); font-size: 0.78rem; margin: 0;
|
|
67
|
+
border-top: 1px dashed var(--border, #d6dae1); padding-top: var(--space-2, .5rem);
|
|
68
|
+
}
|
|
69
|
+
:where(.tangle-assistant) .ai-intro { color: var(--muted, #596273); font-size: 0.88rem; }
|
|
70
|
+
:where(.tangle-assistant) .ai-intro ul { margin: var(--space-2, .5rem) 0 0; padding-left: var(--space-5, 1.25rem); }
|
|
71
|
+
:where(.tangle-assistant) .ai-intro li { margin: var(--space-1, .25rem) 0; }
|
|
72
|
+
:where(.tangle-assistant) .ai-msg { border-radius: var(--radius-sm, .5rem); padding: var(--space-2, .5rem) var(--space-3, .75rem); font-size: 0.9rem; }
|
|
73
|
+
:where(.tangle-assistant) .ai-msg.user { background: var(--accent-soft, #eff6ff); color: var(--fg, #182230); align-self: flex-end; max-width: 85%; }
|
|
74
|
+
:where(.tangle-assistant) .ai-msg.user p { margin: 0; overflow-wrap: anywhere; }
|
|
75
|
+
:where(.tangle-assistant) .ai-msg.assistant { background: transparent; padding-inline: 0; }
|
|
76
|
+
:where(.tangle-assistant) .ai-msg.assistant .md { font-size: 0.9rem; }
|
|
77
|
+
:where(.tangle-assistant) .ai-activity { color: var(--muted, #596273); font-size: 0.82rem; margin: 0; }
|
|
78
|
+
:where(.tangle-assistant) .ai-error {
|
|
79
|
+
color: var(--fail, #bd2525); background: var(--fail-soft, #fff0f0); border-radius: var(--radius-sm, .5rem);
|
|
80
|
+
padding: var(--space-2, .5rem) var(--space-3, .75rem); font-size: 0.85rem; margin: 0;
|
|
81
|
+
}
|
|
82
|
+
:where(.tangle-assistant) .ai-composer {
|
|
83
|
+
display: flex; gap: var(--space-2, .5rem); align-items: flex-end;
|
|
84
|
+
padding: var(--space-3, .75rem) var(--space-4, 1rem); border-top: 1px solid var(--border, #d6dae1);
|
|
85
|
+
}
|
|
86
|
+
:where(.tangle-assistant) .ai-composer .editor { flex: 1; min-height: auto; }
|
|
87
|
+
:where(.tangle-assistant) .ai-send[disabled] { opacity: 0.6; cursor: default; }
|
|
88
|
+
@media (max-width: 760px) {
|
|
89
|
+
:where(.tangle-assistant) .ai-panel {
|
|
90
|
+
right: 0; bottom: 0; left: 0; width: 100vw;
|
|
91
|
+
max-height: 85dvh; border-radius: var(--radius, .625rem) var(--radius, .625rem) 0 0;
|
|
92
|
+
padding-bottom: env(safe-area-inset-bottom);
|
|
93
|
+
}
|
|
94
|
+
:where(.tangle-assistant) .ai-launch { right: var(--space-4, 1rem); bottom: var(--space-4, 1rem); }
|
|
95
|
+
}
|
|
96
|
+
/* small phones: the labeled pill is wide enough to sit on the hero CTA
|
|
97
|
+
or the calculator plot at some fold positions — collapse it to a
|
|
98
|
+
round icon-only launcher (the aria-label carries the name; the
|
|
99
|
+
2.75rem primary tap target holds, DESIGN §5) */
|
|
100
|
+
@media (max-width: 480px) {
|
|
101
|
+
:where(.tangle-assistant) .ai-launch {
|
|
102
|
+
width: 2.75rem; height: 2.75rem; padding: 0;
|
|
103
|
+
display: grid; place-items: center; font-size: 1.05rem;
|
|
104
|
+
}
|
|
105
|
+
:where(.tangle-assistant) .ai-launch-label { display: none; }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
:where(.tangle-assistant) { font-family: var(--font, system-ui, sans-serif); color: var(--fg, #182230); line-height: 1.5; }
|
|
110
|
+
:where(.tangle-assistant) *, :where(.tangle-assistant) *::before, :where(.tangle-assistant) *::after { box-sizing: border-box; }
|
|
111
|
+
:where(.tangle-assistant) .btn, :where(.tangle-assistant) .select { min-height: 2.75rem; border: 1px solid var(--border, #d6dae1); border-radius: .5rem; padding: .4rem .75rem; color: var(--fg, #182230); background: var(--surface, white); font: inherit; cursor: pointer; }
|
|
112
|
+
:where(.tangle-assistant) .editor { display: block; width: 100%; min-width: 0; border: 1px solid var(--border, #d6dae1); border-radius: .5rem; background: var(--bg, white); color: var(--fg, #182230); font: inherit; padding: .5rem .75rem; }
|
|
113
|
+
:where(.tangle-assistant) .editor.line { min-height: 2.75rem; }
|
|
114
|
+
:where(.tangle-assistant) :focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
|
|
115
|
+
:where(.tangle-assistant) button:disabled { opacity: .6; cursor: default; }
|