@mosetta/editor 0.0.32 → 0.0.34
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 +13 -0
- package/package.json +7 -7
- package/tiles/content/content.server.ts +38 -10
- package/tiles/content/contentCommon.ts +6 -2
- package/tiles/content/pagePreset.ts +81 -0
- package/tiles/pagesPanel/pagesPanel.component.tsx +2 -1
- package/tiles/workbench/assistant.css +15 -0
- package/tiles/workbench/assistant.tsx +54 -2
- package/tiles/workbench/assistantChat.ts +6 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# @mosetta/editor
|
|
2
2
|
|
|
3
|
+
## 0.0.34
|
|
4
|
+
|
|
5
|
+
- A new page starts from the site's page preset: the prefab `pagePreset` — the blocks every page of
|
|
6
|
+
the site begins with, and an `outlet` block where the page's own go. The preset is copied into the
|
|
7
|
+
page; a site without one starts its pages in the frame of its home page. "Create a page" and the
|
|
8
|
+
content API's `createPage` (with `preset: true`) both do it. The page preset may hold prefabs.
|
|
9
|
+
|
|
10
|
+
## 0.0.33
|
|
11
|
+
|
|
12
|
+
- The assistant's chat lets the person choose the model it works with, among the ones the platform
|
|
13
|
+
offers (a larger one writes blocks that look better, and costs more); the choice is kept in this
|
|
14
|
+
browser.
|
|
15
|
+
|
|
3
16
|
## 0.0.32
|
|
4
17
|
|
|
5
18
|
- Built with the framework 0.0.12, whose `@mosetta/react` ships the guide to writing tiles — a new
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mosetta/editor",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.34",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "mosetta visual editor tile palette: block picker, page tree, inspector, isolated draft preview and the content API behind them.",
|
|
6
6
|
"repository": {
|
|
@@ -14,15 +14,15 @@
|
|
|
14
14
|
"dependencies": {
|
|
15
15
|
"react": "*",
|
|
16
16
|
"react-dom": "*",
|
|
17
|
-
"@mosetta/core": "0.0.12",
|
|
18
|
-
"@mosetta/di": "0.0.12",
|
|
19
|
-
"@mosetta/stores": "0.0.12",
|
|
20
17
|
"@mosetta/content": "0.0.12",
|
|
18
|
+
"@mosetta/di": "0.0.12",
|
|
19
|
+
"@mosetta/core": "0.0.12",
|
|
21
20
|
"@mosetta/react": "0.0.12",
|
|
22
|
-
"@mosetta/settings": "0.0.12",
|
|
23
|
-
"@mosetta/server-action": "0.0.12",
|
|
24
21
|
"@mosetta/runtime": "0.0.12",
|
|
25
|
-
"@mosetta/
|
|
22
|
+
"@mosetta/stores": "0.0.12",
|
|
23
|
+
"@mosetta/settings": "0.0.12",
|
|
24
|
+
"@mosetta/ui": "0.0.12",
|
|
25
|
+
"@mosetta/server-action": "0.0.12"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
28
|
"@types/react": "*",
|
|
@@ -42,6 +42,8 @@ import type { ZodType } from 'zod';
|
|
|
42
42
|
import { projectNamespaceOf, unitOf } from '@mosetta/content';
|
|
43
43
|
import type { IContentTransport } from '@mosetta/content';
|
|
44
44
|
import { CONFIG_PATH } from '@mosetta/content/paths';
|
|
45
|
+
import { homeFrameOf, PAGE_PRESET, startedPage } from './pagePreset';
|
|
46
|
+
import type { TPageStart } from './pagePreset';
|
|
45
47
|
import { blockSchemas, settingsIssueLine, treeSettingsIssues } from '@mosetta/settings/check';
|
|
46
48
|
import type { ICatalogSettingsEntry } from '@mosetta/settings/check';
|
|
47
49
|
import {
|
|
@@ -194,11 +196,14 @@ function pageReferences(page: IPageDoc): string[] {
|
|
|
194
196
|
/**
|
|
195
197
|
* A tree's references checked the way each kind exists: a module against the catalog; a live
|
|
196
198
|
* PREFAB is content (the project's) or the distribution's and never in the catalog — a missing one
|
|
197
|
-
* renders the node's children without it. Inside a prefab a prefab is refused: one level only
|
|
199
|
+
* renders the node's children without it. Inside a prefab a prefab is refused: one level only —
|
|
200
|
+
* except in the site's page preset, which is never placed but copied into a new page, its prefabs
|
|
201
|
+
* becoming the page's own (pagePreset.ts).
|
|
198
202
|
*/
|
|
199
|
-
function referenceIssues(refs: readonly string[], known: ReadonlySet<string>, kind: ContentKind): string[] {
|
|
203
|
+
function referenceIssues(refs: readonly string[], known: ReadonlySet<string>, kind: ContentKind, name: string): string[] {
|
|
204
|
+
const holdsPrefabs = kind === 'pages' || name === PAGE_PRESET;
|
|
200
205
|
return refs.flatMap((ref) => {
|
|
201
|
-
if (unitOf(ref) !== null) return
|
|
206
|
+
if (unitOf(ref) !== null) return holdsPrefabs ? [] : [`a prefab cannot hold a prefab: ${ref}`];
|
|
202
207
|
return known.has(ref) ? [] : [`unknown tile reference: ${ref}`];
|
|
203
208
|
});
|
|
204
209
|
}
|
|
@@ -325,20 +330,43 @@ class LoadPageDraftAction extends ContentAction<TDraftInput, { draft: ILoadedDra
|
|
|
325
330
|
}
|
|
326
331
|
}
|
|
327
332
|
|
|
328
|
-
type TPageInput = { name: string; page: IPageDoc } & IKindInput;
|
|
333
|
+
type TPageInput = { name: string; page: IPageDoc; preset?: boolean } & IKindInput;
|
|
329
334
|
|
|
330
335
|
@DiServerAction(createPageToken)
|
|
331
|
-
class CreatePageAction extends ContentAction<TPageInput, { draftId: string }> {
|
|
336
|
+
class CreatePageAction extends ContentAction<TPageInput, { draftId: string; started?: TPageStart }> {
|
|
332
337
|
constructor(@DiInject(CONTENT_REPOSITORY_TOKEN) private readonly content: ContentRepository) {
|
|
333
338
|
super();
|
|
334
339
|
}
|
|
335
340
|
|
|
336
341
|
protected inputSchema(builder: ICreateSchema['builder'], s: ContentSchemas) {
|
|
337
|
-
return builder.object({ name: s.entityName, page: s.pageDoc, kind: s.contentKind.optional() }) as unknown as ZodType<TPageInput>;
|
|
342
|
+
return builder.object({ name: s.entityName, page: s.pageDoc, preset: builder.boolean().optional(), kind: s.contentKind.optional() }) as unknown as ZodType<TPageInput>;
|
|
338
343
|
}
|
|
339
344
|
|
|
340
|
-
|
|
341
|
-
|
|
345
|
+
// with `preset`, a page starts as the site's pages do (pagePreset.ts): its blocks go into the
|
|
346
|
+
// site's page preset, or into its home page's frame
|
|
347
|
+
protected async run({ name, page, preset = false, kind = 'pages' }: TPageInput) {
|
|
348
|
+
const start = preset && kind === 'pages' ? await this.startOf() : null;
|
|
349
|
+
const placed = start ? startedPage(start.blocks, page.children ?? []) : null;
|
|
350
|
+
const made = placed?.started ? { ...page, children: placed.children } : page;
|
|
351
|
+
const draftId = await this.content.create(kind, name, storedFormOf(kind, made), kind === 'prefabs' ? 'unit' : undefined);
|
|
352
|
+
return { draftId, ...(placed?.started ? { started: start!.from } : {}) };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** what a new page of this site starts with: its preset, else its home page's frame; null — neither */
|
|
356
|
+
private async startOf(): Promise<{ from: TPageStart; blocks: IBlueprintNodeDoc[] } | null> {
|
|
357
|
+
const published = async (kind: ContentKind, name: string): Promise<IPageDoc | null> => {
|
|
358
|
+
try {
|
|
359
|
+
return editedFormOf(kind, (await this.content.load<unknown>(kind, name)).doc);
|
|
360
|
+
} catch (e) {
|
|
361
|
+
if (e instanceof ContentError) return null;
|
|
362
|
+
throw e;
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
const preset = await published('prefabs', PAGE_PRESET);
|
|
366
|
+
if (preset) return { from: 'pagePreset', blocks: preset.children ?? [] };
|
|
367
|
+
const home = await published('pages', 'home');
|
|
368
|
+
const frame = home ? homeFrameOf(home.children ?? []) : [];
|
|
369
|
+
return frame.length ? { from: 'home', blocks: frame } : null;
|
|
342
370
|
}
|
|
343
371
|
}
|
|
344
372
|
|
|
@@ -367,7 +395,7 @@ class SavePageDraftAction extends ContentAction<TSaveInput, { warnings: string[]
|
|
|
367
395
|
// NOT soft: a page whose seq does not follow the stored draft's comes back as CONFLICT.
|
|
368
396
|
protected async run({ name, draftId, page, kind = 'pages' }: TSaveInput) {
|
|
369
397
|
const known = await this.catalog.knownReferences();
|
|
370
|
-
const warnings = referenceIssues(pageReferences(page), known, kind);
|
|
398
|
+
const warnings = referenceIssues(pageReferences(page), known, kind, name);
|
|
371
399
|
await this.content.saveDraft(kind, name, draftId, storedFormOf(kind, page));
|
|
372
400
|
return { warnings };
|
|
373
401
|
}
|
|
@@ -450,7 +478,7 @@ class PublishPageAction extends ContentAction<TPublishInput, { live: number }> {
|
|
|
450
478
|
const known = new Set(descriptors.map((d) => d.id));
|
|
451
479
|
const edited = editedFormOf(kind, doc);
|
|
452
480
|
const refs = pageReferences(edited);
|
|
453
|
-
issues.push(...referenceIssues(refs, known, kind));
|
|
481
|
+
issues.push(...referenceIssues(refs, known, kind, name));
|
|
454
482
|
issues.push(...treeSettingsIssues(edited, blockSchemas(descriptors as readonly ICatalogSettingsEntry[])).map(settingsIssueLine));
|
|
455
483
|
|
|
456
484
|
if (kind === 'pages') {
|
|
@@ -63,9 +63,13 @@ export const loadPageDraftToken = new ServerActionToken<
|
|
|
63
63
|
ContentActionResult<{ draft: ILoadedDraft<IPageDoc> }>
|
|
64
64
|
>({ actionName: 'loadPageDraft', serverModuleName: 'content', tags: ['pages'] });
|
|
65
65
|
|
|
66
|
+
/**
|
|
67
|
+
* A new page or prefab. With `preset`, a page starts as the site's pages do: its blocks inside the
|
|
68
|
+
* site's page preset (or its home page's frame) — `started` says which, when either was used.
|
|
69
|
+
*/
|
|
66
70
|
export const createPageToken = new ServerActionToken<
|
|
67
|
-
{ name: string; page: IPageDoc } & IKindInput,
|
|
68
|
-
ContentActionResult<{ draftId: string }>
|
|
71
|
+
{ name: string; page: IPageDoc; preset?: boolean } & IKindInput,
|
|
72
|
+
ContentActionResult<{ draftId: string; started?: 'pagePreset' | 'home' }>
|
|
69
73
|
>({ actionName: 'createPage', serverModuleName: 'content', invalidates: ['pages'] });
|
|
70
74
|
|
|
71
75
|
export const savePageDraftToken = new ServerActionToken<
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// A NEW PAGE STARTS FROM THE SITE'S PAGE PRESET: the prefab `pagePreset` — the blocks every new page
|
|
2
|
+
// of the site begins with (its theme, its translations, a sign-in wrapper, whatever the site wants
|
|
3
|
+
// on all of its pages), and an OUTLET block where the page's own blocks go. A site without one starts
|
|
4
|
+
// its pages in the frame of its home page: the prefabs that page is built in, one inside the other.
|
|
5
|
+
// The preset is COPIED into the page, not referenced: changing it changes the pages made after,
|
|
6
|
+
// never the ones made before. One rule for every way a page is made — the editor's Create, the
|
|
7
|
+
// assistant's create_page — so no author has to remember what a page of this site needs.
|
|
8
|
+
import type { IBlueprintNodeDoc } from '@mosetta/content';
|
|
9
|
+
import { isOutletReference, unitOf } from '@mosetta/content/units';
|
|
10
|
+
|
|
11
|
+
/** the prefab a site names its page preset by */
|
|
12
|
+
export const PAGE_PRESET = 'pagePreset';
|
|
13
|
+
|
|
14
|
+
/** where a new page's start came from: the site's preset, or its home page's frame */
|
|
15
|
+
export type TPageStart = 'pagePreset' | 'home';
|
|
16
|
+
|
|
17
|
+
type TNode = IBlueprintNodeDoc;
|
|
18
|
+
|
|
19
|
+
const idsIn = (nodes: readonly TNode[]): string[] => nodes.flatMap((node) => [node.id, ...idsIn(node.children ?? [])]);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The frame of a home page: its blocks while they are one prefab inside another, outermost first,
|
|
23
|
+
* each without the page's own blocks — none when the page's blocks are not one prefab.
|
|
24
|
+
*/
|
|
25
|
+
export function homeFrameOf(home: readonly TNode[]): TNode[] {
|
|
26
|
+
const chain: TNode[] = [];
|
|
27
|
+
for (let level = home; level.length === 1 && unitOf(level[0].reference); level = level[0].children ?? []) chain.push(level[0]);
|
|
28
|
+
return chain.reduceRight<TNode[]>((inner, { children: _children, ...node }) => [{ ...node, children: inner }], []);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** the first outlet of a tree in document order, as the path of child indexes leading to it */
|
|
32
|
+
function outletPath(nodes: readonly TNode[]): number[] | null {
|
|
33
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
34
|
+
if (isOutletReference(nodes[i].reference)) return [i];
|
|
35
|
+
const below = outletPath(nodes[i].children ?? []);
|
|
36
|
+
if (below) return [i, ...below];
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** the tree with the node at `path` replaced by `content` */
|
|
42
|
+
function replacedAt(nodes: readonly TNode[], path: readonly number[], content: readonly TNode[]): TNode[] {
|
|
43
|
+
const [at, ...rest] = path;
|
|
44
|
+
if (!rest.length) return [...nodes.slice(0, at), ...content, ...nodes.slice(at + 1)];
|
|
45
|
+
return nodes.map((node, i) => (i === at ? { ...node, children: replacedAt(node.children ?? [], rest, content) } : node));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** the tree with `content` added to its innermost block while the blocks are one inside another */
|
|
49
|
+
function appendedInside(nodes: readonly TNode[], content: readonly TNode[]): TNode[] {
|
|
50
|
+
if (nodes.length !== 1) return [...nodes, ...content];
|
|
51
|
+
const [only] = nodes;
|
|
52
|
+
const children = only.children ?? [];
|
|
53
|
+
return [{ ...only, children: children.length === 1 ? appendedInside(children, content) : [...children, ...content] }];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** the start's blocks renamed where the page's own blocks already have an id: the page's ids stay */
|
|
57
|
+
function withFreeIds(nodes: readonly TNode[], taken: Set<string>): TNode[] {
|
|
58
|
+
return nodes.map((node) => {
|
|
59
|
+
let id = node.id;
|
|
60
|
+
if (taken.has(id)) {
|
|
61
|
+
let n = 1;
|
|
62
|
+
while (taken.has(`${node.id}-preset${n > 1 ? n : ''}`)) n++;
|
|
63
|
+
id = `${node.id}-preset${n > 1 ? n : ''}`;
|
|
64
|
+
}
|
|
65
|
+
taken.add(id);
|
|
66
|
+
return { ...node, id, children: withFreeIds(node.children ?? [], taken) };
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A new page's blocks: the start's (a copy) with the page's own `content` in its outlet — or, with
|
|
72
|
+
* no outlet, inside its innermost block. Content that already begins with a prefab of the start's
|
|
73
|
+
* (its author built the frame) is taken as it is; so is everything when the start is empty.
|
|
74
|
+
*/
|
|
75
|
+
export function startedPage(start: readonly TNode[], content: readonly TNode[]): { children: TNode[]; started: boolean } {
|
|
76
|
+
const startPrefabs = new Set(start.map((node) => node.reference).filter((reference) => unitOf(reference)));
|
|
77
|
+
if (!start.length || content.some((node) => startPrefabs.has(node.reference))) return { children: [...content], started: false };
|
|
78
|
+
const copy = withFreeIds(structuredClone(start), new Set(idsIn(content)));
|
|
79
|
+
const path = outletPath(copy);
|
|
80
|
+
return { children: path ? replacedAt(copy, path, content) : appendedInside(copy, content), started: true };
|
|
81
|
+
}
|
|
@@ -1216,7 +1216,8 @@ function CreateModal({ routes, own, onClose, onJump, onCreated }: ICreateModalPr
|
|
|
1216
1216
|
const submit = (): void => {
|
|
1217
1217
|
if (!name || create.isLoading()) return;
|
|
1218
1218
|
createdRef.current = name;
|
|
1219
|
-
|
|
1219
|
+
// a page starts as the site's pages do — in its page preset (or its home page's frame)
|
|
1220
|
+
create.start!({ name, page: { id: name, reference: rootId, pattern: byName ? PREFAB_CANVAS_PATTERN : trimmed, children: [] }, kind, ...(byName ? {} : { preset: true }) });
|
|
1220
1221
|
};
|
|
1221
1222
|
|
|
1222
1223
|
return (
|
|
@@ -68,6 +68,21 @@
|
|
|
68
68
|
font-variant-numeric: tabular-nums;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
.assistant__model {
|
|
72
|
+
max-width: 11em;
|
|
73
|
+
padding: 2px 4px;
|
|
74
|
+
border: 1px solid var(--wb-border, #e4e4e7);
|
|
75
|
+
border-radius: 6px;
|
|
76
|
+
background: #ffffff;
|
|
77
|
+
color: var(--wb-ink, #18181b);
|
|
78
|
+
font: inherit;
|
|
79
|
+
font-size: 0.85em;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.assistant__model:disabled {
|
|
83
|
+
opacity: 0.5;
|
|
84
|
+
}
|
|
85
|
+
|
|
71
86
|
.assistant__head-button {
|
|
72
87
|
padding: 3px 8px;
|
|
73
88
|
border: 1px solid transparent;
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
EMPTY_CHAT,
|
|
29
29
|
IChatState,
|
|
30
30
|
IDraftTouched,
|
|
31
|
+
modelLabel,
|
|
31
32
|
nodeOfCall,
|
|
32
33
|
TChatItem,
|
|
33
34
|
touchedNodes,
|
|
@@ -48,6 +49,23 @@ function readChat(): IChatState {
|
|
|
48
49
|
return EMPTY_CHAT;
|
|
49
50
|
}
|
|
50
51
|
|
|
52
|
+
/** the model the person chose, kept for this browser — one the platform no longer offers is forgotten */
|
|
53
|
+
const MODEL_KEY = 'mosetta-assistant-model';
|
|
54
|
+
|
|
55
|
+
function readModel(): string | null {
|
|
56
|
+
try {
|
|
57
|
+
return window.localStorage.getItem(MODEL_KEY);
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function writeModel(model: string): void {
|
|
64
|
+
try {
|
|
65
|
+
window.localStorage.setItem(MODEL_KEY, model);
|
|
66
|
+
} catch { /* storage denied: the choice lives for the page only */ }
|
|
67
|
+
}
|
|
68
|
+
|
|
51
69
|
function writeChat(state: IChatState): void {
|
|
52
70
|
try {
|
|
53
71
|
window.sessionStorage.setItem(storageKey(), JSON.stringify(state));
|
|
@@ -232,6 +250,10 @@ export function AssistantPanel({ placement, transcription, onClose, onWorking }:
|
|
|
232
250
|
const draftRef = useRef(draft);
|
|
233
251
|
draftRef.current = draft;
|
|
234
252
|
const [working, setWorking] = useState(false);
|
|
253
|
+
// the models the platform offers, its own first; the one chosen goes with every message
|
|
254
|
+
const [models, setModels] = useState<readonly string[]>([]);
|
|
255
|
+
const [chosen, setChosen] = useState<string | null>(readModel);
|
|
256
|
+
const model = chosen && models.includes(chosen) ? chosen : models[0] ?? null;
|
|
235
257
|
// over Home and the lists the island folds to its input when the person wants the page back
|
|
236
258
|
const [folded, setFolded] = useState(false);
|
|
237
259
|
const abort = useRef<AbortController | null>(null);
|
|
@@ -239,6 +261,19 @@ export function AssistantPanel({ placement, transcription, onClose, onWorking }:
|
|
|
239
261
|
const input = useRef<HTMLTextAreaElement>(null);
|
|
240
262
|
const dictation = useDictation(setDraft, () => draftRef.current, transcription);
|
|
241
263
|
useEffect(() => writeChat(chat), [chat]);
|
|
264
|
+
useEffect(() => {
|
|
265
|
+
let live = true;
|
|
266
|
+
fetch(ASSISTANT_PATH, { headers: { accept: 'application/json' } })
|
|
267
|
+
.then((res) => (res.ok ? res.json() as Promise<{ models?: unknown }> : null))
|
|
268
|
+
.then((answer) => {
|
|
269
|
+
const listed = Array.isArray(answer?.models) ? answer.models.filter((m): m is string => typeof m === 'string') : [];
|
|
270
|
+
if (live) setModels(listed);
|
|
271
|
+
})
|
|
272
|
+
.catch(() => undefined);
|
|
273
|
+
return () => {
|
|
274
|
+
live = false;
|
|
275
|
+
};
|
|
276
|
+
}, []);
|
|
242
277
|
useEffect(() => {
|
|
243
278
|
list.current?.scrollTo({ top: list.current.scrollHeight });
|
|
244
279
|
}, [chat.items, folded]);
|
|
@@ -353,7 +388,7 @@ export function AssistantPanel({ placement, transcription, onClose, onWorking }:
|
|
|
353
388
|
const res = await fetch(ASSISTANT_PATH, {
|
|
354
389
|
method: 'POST',
|
|
355
390
|
headers: { 'content-type': 'application/json', accept: 'text/event-stream' },
|
|
356
|
-
body: JSON.stringify({ history: chat.history, input: { text: message, context: contextNow() } }),
|
|
391
|
+
body: JSON.stringify({ history: chat.history, input: { text: message, context: contextNow(), ...(model && model !== models[0] ? { model } : {}) } }),
|
|
357
392
|
signal: controller.signal,
|
|
358
393
|
});
|
|
359
394
|
if (!res.ok || !res.body) {
|
|
@@ -389,7 +424,7 @@ export function AssistantPanel({ placement, transcription, onClose, onWorking }:
|
|
|
389
424
|
di.getByToken(SERVER_DATA_MANAGER).invalidate(['pages']);
|
|
390
425
|
}
|
|
391
426
|
}
|
|
392
|
-
}, [chat, di, dictation, openDraft, reload, sessionsOn, working]);
|
|
427
|
+
}, [chat, di, dictation, model, models, openDraft, reload, sessionsOn, working]);
|
|
393
428
|
|
|
394
429
|
const startOver = (): void => {
|
|
395
430
|
if (working) return;
|
|
@@ -404,6 +439,23 @@ export function AssistantPanel({ placement, transcription, onClose, onWorking }:
|
|
|
404
439
|
data-working={working || undefined}>
|
|
405
440
|
<div className="assistant__head">
|
|
406
441
|
<span className="assistant__title">Assistant</span>
|
|
442
|
+
{models.length > 1 && model ? (
|
|
443
|
+
<select className="assistant__model"
|
|
444
|
+
data-test-id="assistant-model"
|
|
445
|
+
aria-label="The model the assistant works with"
|
|
446
|
+
data-tooltip="The model the assistant works with — a larger one does better and costs more"
|
|
447
|
+
value={model}
|
|
448
|
+
disabled={working}
|
|
449
|
+
onChange={(e) => {
|
|
450
|
+
setChosen(e.target.value);
|
|
451
|
+
writeModel(e.target.value);
|
|
452
|
+
}}>
|
|
453
|
+
{models.map((m) => (
|
|
454
|
+
<option key={m}
|
|
455
|
+
value={m}>{modelLabel(m)}</option>
|
|
456
|
+
))}
|
|
457
|
+
</select>
|
|
458
|
+
) : null}
|
|
407
459
|
{chat.cost > 0 ? <span className="assistant__cost"
|
|
408
460
|
data-tooltip="What this conversation has cost">${chat.cost.toFixed(3)}</span> : null}
|
|
409
461
|
<button type="button"
|
|
@@ -163,3 +163,9 @@ export function nodeOfCall(name: string, args: unknown): string | null {
|
|
|
163
163
|
}
|
|
164
164
|
return null;
|
|
165
165
|
}
|
|
166
|
+
|
|
167
|
+
/** a model as the person reads it: `anthropic/claude-opus-5.5` → `Claude Opus 5.5` */
|
|
168
|
+
export function modelLabel(id: string): string {
|
|
169
|
+
return id.slice(id.indexOf('/') + 1).split('-').filter(Boolean)
|
|
170
|
+
.map((word) => (word === 'gpt' ? 'GPT' : `${word[0].toUpperCase()}${word.slice(1)}`)).join(' ');
|
|
171
|
+
}
|