@preset.nz/app-kit 0.1.2 → 0.1.4
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/README.md +16 -4
- package/package.json +8 -6
- package/src-ts/document.ts +4 -27
- package/src-ts/models/ModelPromptHost.tsx +68 -0
- package/src-ts/models/ModelsSection.tsx +122 -0
- package/src-ts/models/api.ts +61 -0
- package/src-ts/models/context.ts +32 -0
- package/src-ts/models/gate.ts +108 -0
- package/src-ts/models/index.ts +13 -0
- package/src-ts/models/useModelGate.ts +20 -0
- package/src-ts/models/useModels.ts +103 -0
package/README.md
CHANGED
|
@@ -49,9 +49,21 @@ tauri::Builder::default()
|
|
|
49
49
|
])
|
|
50
50
|
```
|
|
51
51
|
|
|
52
|
+
- **Models (cargo feature `models`)**: `preset_app_kit::models::install(app.handle(), "nz.preset.myapp", "My App", REGISTRY)` in `setup` (separate from `AppKit::install`). Call it from the synchronous `setup`, never from async code: the downloader is reqwest's blocking client, which panics on a tokio worker thread. It manages the store, writes `.users/<bundle-id>.json`, sweeps stale `.part` files and emits `app-kit://models` (`{ id, status }`, at most four a second per download, plus every status change). It returns the `ModelStore`; `store.path("mobile_sam")` gives a sidecar its `model_dir`. Add the commands beside the others:
|
|
53
|
+
|
|
54
|
+
```rust
|
|
55
|
+
preset_app_kit::app_kit_models_list,
|
|
56
|
+
preset_app_kit::app_kit_models_download,
|
|
57
|
+
preset_app_kit::app_kit_models_cancel,
|
|
58
|
+
preset_app_kit::app_kit_models_remove,
|
|
59
|
+
preset_app_kit::app_kit_models_path,
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`status` is a tagged object (`{ "kind": "downloading", "done": 1, "total": 2 }`). The list includes models this app never declared (from their manifests), and any of them can be removed. The webview never receives a URL.
|
|
63
|
+
In the Settings window of `@preset.nz/preferences` (app-kit depends on neither it nor facets, so these lines live in the app): `registerFieldRenderer("models", () => <ModelsSection />)`, then `<SettingsWindow extraGroups={[{ page: "general", group: { id: "models", title: "Models", rows: [{ kind: "models", id: "models" }] } }]} />` with the array at module scope. The recipe is in preferences' README.
|
|
52
64
|
- **`Command`**: id, menu label, optional toolbar label, Tauri accelerator, `MenuName` (App, File, Edit, View, Window, Help), `section` (items in a section sit together), item or toggle. Undo and Redo are built in; Settings is opt-in.
|
|
53
|
-
- **File menu**: New, Open…, Open Recent ▸, Close, Save, Save As… and Revert to Saved… are built in, run against the app's `Document` behind the close guard. Revert is enabled only for an unsaved document with a file, and asks first, because reloading starts an empty history. Open Recent keeps ten documents, newest first, in `recent-documents.json` in the app's data directory; a file that has gone says so and leaves the list. `note_recent(app, path)` adds a document opened some other way (from the Finder
|
|
54
|
-
- **Relaunch**: `.plugin(preset_app_kit::window_state())` on the builder brings each window back at its size and position (`tauri-plugin-window-state`, not visibility). `on_run_event`
|
|
65
|
+
- **File menu**: New, Open…, Open Recent ▸, Close, Save, Save As… and Revert to Saved… are built in, run against the app's `Document` behind the close guard. Revert is enabled only for an unsaved document with a file, and asks first, because reloading starts an empty history. Open Recent keeps ten documents, newest first, in `recent-documents.json` in the app's data directory; a file that has gone says so and leaves the list. `note_recent(app, path)` adds a document opened some other way (from the Finder), and `recent_documents(app)[0]` is the last document. `.documents_folder(|app| …)` names where the Open and Save panels start for an Untitled document (`~/preset-nz/<App>/…`); the app supplies the lookup, so app-kit doesn't depend on app-folders.
|
|
66
|
+
- **Relaunch**: the app starts on a fresh Untitled document; it does not reopen the last one (that is File > Open Recent's first entry). `.plugin(preset_app_kit::window_state())` on the builder brings each window back at its size and position (`tauri-plugin-window-state`, not visibility). `on_run_event` opens a document the Finder hands over (double-click, `open some.x`, the Dock). For an app with documents, app-kit owns Finder opens, so don't add a second handler.
|
|
55
67
|
- **Menu config**: `.menu_config(include_str!("../menu.toml"))` switches the built-in items on and off: `[file] new, open, open_recent, close, save, save_as, revert` and `[app] settings`. A missing key takes the default (on); an unknown key is an error, so add a test that calls `MenuConfig::parse` on your file. Labels, accelerators and positions stay the standard's. Design: guidance `projects/app-kit/design/menu-config.md`.
|
|
56
68
|
- **Apps without documents**: `install_history::<H: History>` instead of `install::<H: Document>`, for a library such as Strata. The config must turn New, Open, Open Recent, Save, Save As and Revert off; with Close off too, Window gains Close Window on Cmd+W. No close guard, no window-title handling, and it doesn't register `tauri-plugin-dialog`, so the app keeps its own.
|
|
57
69
|
- **`History`**: `undo_label`, `redo_label`, `undo_labels` (oldest first), `redo_labels` (next first), `undo`, `redo`. Implement it on the type you manage as Tauri state. The reads may lock, so call `refresh_history(app)` after a change and never while holding that lock.
|
|
@@ -71,13 +83,13 @@ const { commands, run, shortcut } = useCommands(bindings) // bindings: Record<id
|
|
|
71
83
|
- **`useCommands(bindings)`**: loads the table from Rust, binds `run`, icon and gating by id, follows the history, routes text-field undo and keeps the menu's enabled and checked state in step. `edit.undo` and `edit.redo` need only an icon. `bindCommands`, `useCommandTable`, `useMenuSync`, `toItems` are the parts.
|
|
72
84
|
- **`CommandToolbar`**: ux-kit's `ToolbarItems` fed from the commands.
|
|
73
85
|
- **`@preset.nz/app-kit/core`**: everything except `CommandToolbar` and `toItems`, with no ux-kit import, for apps that use only the menu half. ux-kit is an optional peer.
|
|
74
|
-
- **`useDocument
|
|
86
|
+
- **`useDocument`**: the live `DocumentState`.
|
|
75
87
|
- **`useHistory`, `undo`, `redo`**: the live `HistoryState`, and the same undo the menu takes. The Inspector's History list reads `undoLabels` and `redoLabels`.
|
|
76
88
|
- **`useTextUndo`, `useTextFocus`, `isTextField`, `blurField`**: Cmd+Z undoes typing while a text field has focus, the document otherwise.
|
|
77
89
|
- **`createSelection<T>(none)`**: single-select store with `get`, `use`, `select`, `clear`. **`createMultiSelection<T>(key)`**: the opt-in multi-select. **`createStore`, `useStore`**: the small store under both. Wrap them in your own narrow setters; there is no `setSelection`, and selection is not an undo step.
|
|
78
90
|
- **`nativeContextMenu()`**: no WebKit context menu. Right-click in a text field pops up a short native menu (Undo, Redo, Cut, Copy, Paste, Select All; needs `app_kit_text_menu` registered); elsewhere nothing, and app-drawn menus still open.
|
|
79
91
|
|
|
80
|
-
The table arrives from Rust
|
|
92
|
+
The table arrives from Rust. Apps run only in their Tauri window; a plain browser tab is not a supported host.
|
|
81
93
|
|
|
82
94
|
## Consuming it
|
|
83
95
|
|
package/package.json
CHANGED
|
@@ -1,36 +1,38 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@preset.nz/app-kit",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "The behaviour of a preset.nz desktop app: command table, menu sync, Undo and Redo titles, text-field undo routing and a selection store, over the preset-app-kit Rust crate.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
8
|
-
"url": "git+https://github.com/preset-nz/
|
|
8
|
+
"url": "git+https://github.com/preset-nz/packages.git",
|
|
9
|
+
"directory": "app-kit"
|
|
9
10
|
},
|
|
10
11
|
"type": "module",
|
|
11
12
|
"sideEffects": false,
|
|
12
13
|
"exports": {
|
|
13
14
|
".": "./src-ts/index.ts",
|
|
14
|
-
"./core": "./src-ts/core.ts"
|
|
15
|
+
"./core": "./src-ts/core.ts",
|
|
16
|
+
"./models": "./src-ts/models/index.ts"
|
|
15
17
|
},
|
|
16
18
|
"files": [
|
|
17
19
|
"src-ts"
|
|
18
20
|
],
|
|
19
21
|
"peerDependencies": {
|
|
20
|
-
"@preset.nz/ux-kit": "^0.1.
|
|
22
|
+
"@preset.nz/ux-kit": "^0.1.2",
|
|
21
23
|
"@tauri-apps/api": "^2",
|
|
22
24
|
"react": "^19"
|
|
23
25
|
},
|
|
24
26
|
"devDependencies": {
|
|
25
27
|
"@biomejs/biome": "^2.5.15",
|
|
26
|
-
"@preset.nz/ux-kit": "^0.1.0",
|
|
27
28
|
"@tauri-apps/api": "^2.11.1",
|
|
28
29
|
"@types/react": "^19.2.17",
|
|
29
30
|
"@types/react-dom": "^19.2.3",
|
|
30
31
|
"lefthook": "^2.1.17",
|
|
31
32
|
"react": "^19.2.7",
|
|
32
33
|
"react-dom": "^19.2.7",
|
|
33
|
-
"typescript": "~
|
|
34
|
+
"typescript": "~7.0.2",
|
|
35
|
+
"@preset.nz/ux-kit": "^0.1.2"
|
|
34
36
|
},
|
|
35
37
|
"peerDependenciesMeta": {
|
|
36
38
|
"@preset.nz/ux-kit": {
|
package/src-ts/document.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { invoke } from "@tauri-apps/api/core"
|
|
2
2
|
import { listen } from "@tauri-apps/api/event"
|
|
3
|
-
import { useEffect,
|
|
3
|
+
import { useEffect, useState } from "react"
|
|
4
4
|
|
|
5
5
|
/** The document as Rust reports it (`DocumentState`): from `app_kit_document`, pushed as `app-kit://document`. */
|
|
6
6
|
export interface DocumentState {
|
|
@@ -31,30 +31,7 @@ export function useDocument(): DocumentState | null {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* `app-kit://note`. Show them as the app shows a passing message (a snackbar), never a dialog.
|
|
34
|
+
* Kept so apps built against 0.1 still compile: launch no longer reopens the last document, so
|
|
35
|
+
* there are no notes to show. Remove the call when convenient.
|
|
37
36
|
*/
|
|
38
|
-
export function useDocumentNotes(
|
|
39
|
-
const handler = useRef(onNote)
|
|
40
|
-
useEffect(() => {
|
|
41
|
-
handler.current = onNote
|
|
42
|
-
}, [onNote])
|
|
43
|
-
useEffect(() => {
|
|
44
|
-
let live = true
|
|
45
|
-
const un = listen<string>("app-kit://note", (e) => handler.current(e.payload))
|
|
46
|
-
// Ask only once listening, so nothing falls between the held notes and the event. And only
|
|
47
|
-
// while still mounted: taking them is once-only, so an unmounted effect (StrictMode mounts
|
|
48
|
-
// twice in dev) must leave them for the next.
|
|
49
|
-
un.then(() => (live ? invoke<string[]>("app_kit_document_note") : [])).then(
|
|
50
|
-
(held) => {
|
|
51
|
-
if (live) for (const m of held) handler.current(m)
|
|
52
|
-
},
|
|
53
|
-
() => {},
|
|
54
|
-
)
|
|
55
|
-
return () => {
|
|
56
|
-
live = false
|
|
57
|
-
un.then((f) => f())
|
|
58
|
-
}
|
|
59
|
-
}, [])
|
|
60
|
-
}
|
|
37
|
+
export function useDocumentNotes(_onNote: (message: string) => void): void {}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { DownloadPrompt, formatBytes } from "@preset.nz/ux-kit"
|
|
2
|
+
import { useEffect, useSyncExternalStore } from "react"
|
|
3
|
+
|
|
4
|
+
import type { ModelsApi } from "./api"
|
|
5
|
+
import { useModelsContext } from "./context"
|
|
6
|
+
import { useModels } from "./useModels"
|
|
7
|
+
|
|
8
|
+
type PromptState = "ask" | "downloading" | "failed"
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Renders the prompt `ensure` opens. Mount it once near the app root. The state comes from the
|
|
12
|
+
* model's status: not Ready and not asked is "ask"; asked and downloading is the bar; asked and
|
|
13
|
+
* failed is the reason with Retry; Ready resolves the `ensure`.
|
|
14
|
+
*/
|
|
15
|
+
export function ModelPromptHost({ api }: { api?: ModelsApi }) {
|
|
16
|
+
const { gate, api: contextApi } = useModelsContext()
|
|
17
|
+
const request = useSyncExternalStore(gate.subscribe, gate.snapshot)
|
|
18
|
+
const { models, download, cancel } = useModels(api ?? contextApi)
|
|
19
|
+
const info = request ? models.find((m) => m.id === request.id) : undefined
|
|
20
|
+
const ready = info?.status.kind === "ready"
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
if (request && ready) gate.succeed()
|
|
24
|
+
}, [request, ready, gate])
|
|
25
|
+
|
|
26
|
+
let state: PromptState | null = null
|
|
27
|
+
if (request && info && !ready) {
|
|
28
|
+
const kind = info.status.kind
|
|
29
|
+
if (kind === "downloading") state = "downloading"
|
|
30
|
+
else if (!request.asked) state = "ask"
|
|
31
|
+
else state = kind === "failed" ? "failed" : "downloading"
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const start = () => {
|
|
35
|
+
if (!request) return
|
|
36
|
+
gate.markAsked()
|
|
37
|
+
void download(request.id)
|
|
38
|
+
}
|
|
39
|
+
const decline = () => gate.decline()
|
|
40
|
+
const stop = () => {
|
|
41
|
+
if (request) void cancel(request.id)
|
|
42
|
+
gate.decline()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const status = info?.status
|
|
46
|
+
return (
|
|
47
|
+
<DownloadPrompt
|
|
48
|
+
state={state}
|
|
49
|
+
title={`${request?.tool ?? "This tool"} needs the ${info?.label ?? ""} model.`}
|
|
50
|
+
name={info?.label ?? ""}
|
|
51
|
+
facts={{
|
|
52
|
+
size: info && info.totalBytes > 0 ? formatBytes(info.totalBytes) : undefined,
|
|
53
|
+
licence: info?.licence || undefined,
|
|
54
|
+
host: info?.host || undefined,
|
|
55
|
+
}}
|
|
56
|
+
progress={
|
|
57
|
+
status?.kind === "downloading" && status.total > 0
|
|
58
|
+
? { done: status.done, total: status.total }
|
|
59
|
+
: undefined
|
|
60
|
+
}
|
|
61
|
+
reason={status?.kind === "failed" ? status.reason : undefined}
|
|
62
|
+
onDownload={start}
|
|
63
|
+
onDismiss={decline}
|
|
64
|
+
onCancel={stop}
|
|
65
|
+
onRetry={start}
|
|
66
|
+
/>
|
|
67
|
+
)
|
|
68
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { Button, ConfirmDialog, EmptyState, formatBytes, List, ListRow } from "@preset.nz/ux-kit"
|
|
2
|
+
import { useState } from "react"
|
|
3
|
+
|
|
4
|
+
import type { ModelInfo, ModelsApi } from "./api"
|
|
5
|
+
import { useModels } from "./useModels"
|
|
6
|
+
|
|
7
|
+
/** "Strata", "Strata and Shard", "Strata, Shard and Fault". */
|
|
8
|
+
function joinNames(names: string[]): string {
|
|
9
|
+
if (names.length <= 1) return names.join("")
|
|
10
|
+
return `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function sizeText(m: ModelInfo): string {
|
|
14
|
+
switch (m.status.kind) {
|
|
15
|
+
case "ready":
|
|
16
|
+
return `${formatBytes(m.bytesOnDisk)} on disk`
|
|
17
|
+
case "downloading":
|
|
18
|
+
return `${formatBytes(m.status.done)} of ${formatBytes(m.status.total)}`
|
|
19
|
+
case "damaged":
|
|
20
|
+
return "Damaged"
|
|
21
|
+
case "failed":
|
|
22
|
+
return m.status.reason
|
|
23
|
+
default:
|
|
24
|
+
return formatBytes(m.totalBytes)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const detailOf = (m: ModelInfo): string =>
|
|
29
|
+
[m.usedBy, m.licence, sizeText(m)].filter(Boolean).join(" · ")
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The Settings section: one row per model with its status, and the action that fits it. Remove
|
|
33
|
+
* asks first and names the other apps that use the model.
|
|
34
|
+
*/
|
|
35
|
+
export function ModelsSection({ api }: { api?: ModelsApi }) {
|
|
36
|
+
const { models, download, cancel, remove } = useModels(api)
|
|
37
|
+
const [removing, setRemoving] = useState<ModelInfo | null>(null)
|
|
38
|
+
const [open, setOpen] = useState(false)
|
|
39
|
+
const [error, setError] = useState<string | null>(null)
|
|
40
|
+
|
|
41
|
+
const ask = (m: ModelInfo) => {
|
|
42
|
+
setError(null)
|
|
43
|
+
setRemoving(m)
|
|
44
|
+
setOpen(true)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const action = (m: ModelInfo) => {
|
|
48
|
+
switch (m.status.kind) {
|
|
49
|
+
case "ready":
|
|
50
|
+
return (
|
|
51
|
+
<Button size="sm" variant="outline" onClick={() => ask(m)}>
|
|
52
|
+
Remove
|
|
53
|
+
</Button>
|
|
54
|
+
)
|
|
55
|
+
case "downloading":
|
|
56
|
+
return (
|
|
57
|
+
<Button size="sm" variant="outline" onClick={() => void cancel(m.id)}>
|
|
58
|
+
Cancel
|
|
59
|
+
</Button>
|
|
60
|
+
)
|
|
61
|
+
case "damaged":
|
|
62
|
+
case "failed":
|
|
63
|
+
return (
|
|
64
|
+
<Button size="sm" variant="outline" onClick={() => void download(m.id)}>
|
|
65
|
+
Retry
|
|
66
|
+
</Button>
|
|
67
|
+
)
|
|
68
|
+
default:
|
|
69
|
+
return (
|
|
70
|
+
<Button size="sm" variant="outline" onClick={() => void download(m.id)}>
|
|
71
|
+
Download
|
|
72
|
+
</Button>
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const onDisk = models.reduce((sum, m) => sum + m.bytesOnDisk, 0)
|
|
78
|
+
const others = removing?.otherApps ?? []
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<>
|
|
82
|
+
<List
|
|
83
|
+
aria-label="Models"
|
|
84
|
+
empty={
|
|
85
|
+
<EmptyState
|
|
86
|
+
title="No models"
|
|
87
|
+
description="A tool that needs a model asks before downloading it."
|
|
88
|
+
/>
|
|
89
|
+
}
|
|
90
|
+
footer={`${formatBytes(onDisk)} on disk`}
|
|
91
|
+
>
|
|
92
|
+
{models.map((m) => (
|
|
93
|
+
<ListRow key={m.id} id={m.id} title={m.label} detail={detailOf(m)} trailing={action(m)} />
|
|
94
|
+
))}
|
|
95
|
+
</List>
|
|
96
|
+
<ConfirmDialog
|
|
97
|
+
open={open}
|
|
98
|
+
onOpenChange={setOpen}
|
|
99
|
+
title={`Remove ${removing?.label ?? ""}?`}
|
|
100
|
+
confirmLabel="Remove"
|
|
101
|
+
destructive
|
|
102
|
+
onConfirm={async () => {
|
|
103
|
+
if (!removing) return
|
|
104
|
+
try {
|
|
105
|
+
await remove(removing.id)
|
|
106
|
+
} catch (e) {
|
|
107
|
+
setError(typeof e === "string" ? e : (e as Error).message)
|
|
108
|
+
throw e
|
|
109
|
+
}
|
|
110
|
+
}}
|
|
111
|
+
>
|
|
112
|
+
<p>{removing ? `${formatBytes(removing.bytesOnDisk)}. ` : ""}You can download it again.</p>
|
|
113
|
+
{others.length > 0 ? (
|
|
114
|
+
<p>
|
|
115
|
+
{joinNames(others)} {others.length === 1 ? "also uses" : "also use"} this model.
|
|
116
|
+
</p>
|
|
117
|
+
) : null}
|
|
118
|
+
{error ? <p>{error}</p> : null}
|
|
119
|
+
</ConfirmDialog>
|
|
120
|
+
</>
|
|
121
|
+
)
|
|
122
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { invoke } from "@tauri-apps/api/core"
|
|
2
|
+
import { listen } from "@tauri-apps/api/event"
|
|
3
|
+
|
|
4
|
+
// The one file in `models/` that touches Tauri. The hooks and components take a `ModelsApi`, so
|
|
5
|
+
// the ux-kit playground passes a fake and shows them without a window's backend.
|
|
6
|
+
|
|
7
|
+
/** Where a model stands, mirroring Rust's `ModelStatus` (`#[serde(tag = "kind")]`). */
|
|
8
|
+
export type ModelStatus =
|
|
9
|
+
| { kind: "notInstalled" }
|
|
10
|
+
| { kind: "downloading"; done: number; total: number }
|
|
11
|
+
| { kind: "ready" }
|
|
12
|
+
/** The manifest is there but a file is missing or the wrong size. */
|
|
13
|
+
| { kind: "damaged" }
|
|
14
|
+
/** The last download ended in this one plain line. */
|
|
15
|
+
| { kind: "failed"; reason: string }
|
|
16
|
+
|
|
17
|
+
/** One model as `app_kit_models_list` reports it (Rust's `ModelInfo`, camelCase). */
|
|
18
|
+
export interface ModelInfo {
|
|
19
|
+
id: string
|
|
20
|
+
label: string
|
|
21
|
+
/** The apps using it, as names; empty for a model this app never declared. */
|
|
22
|
+
usedBy: string
|
|
23
|
+
licence: string
|
|
24
|
+
host: string
|
|
25
|
+
status: ModelStatus
|
|
26
|
+
bytesOnDisk: number
|
|
27
|
+
totalBytes: number
|
|
28
|
+
/** Other apps that use the model, by name. */
|
|
29
|
+
otherApps: string[]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The payload of the `app-kit://models` event (Rust's `ModelChange`). */
|
|
33
|
+
export interface ModelChange {
|
|
34
|
+
id: string
|
|
35
|
+
status: ModelStatus
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** What the models hooks and components need from the backend. */
|
|
39
|
+
export interface ModelsApi {
|
|
40
|
+
list(): Promise<ModelInfo[]>
|
|
41
|
+
/** Starts the download and returns at once; progress arrives through `subscribe`. */
|
|
42
|
+
download(id: string): Promise<void>
|
|
43
|
+
cancel(id: string): Promise<void>
|
|
44
|
+
remove(id: string): Promise<void>
|
|
45
|
+
/** Calls `onChange` for every change. Returns the unsubscribe function. */
|
|
46
|
+
subscribe(onChange: (change: ModelChange) => void): () => void
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The real thing: the five `app_kit_models_*` commands and the `app-kit://models` event. */
|
|
50
|
+
export const tauriModelsApi: ModelsApi = {
|
|
51
|
+
list: () => invoke<ModelInfo[]>("app_kit_models_list"),
|
|
52
|
+
download: (id) => invoke<void>("app_kit_models_download", { id }),
|
|
53
|
+
cancel: (id) => invoke<void>("app_kit_models_cancel", { id }),
|
|
54
|
+
remove: (id) => invoke<void>("app_kit_models_remove", { id }),
|
|
55
|
+
subscribe(onChange) {
|
|
56
|
+
const un = listen<ModelChange>("app-kit://models", (e) => onChange(e.payload))
|
|
57
|
+
return () => {
|
|
58
|
+
un.then((f) => f())
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { createContext, createElement, type ReactNode, useContext, useMemo } from "react"
|
|
2
|
+
|
|
3
|
+
import { type ModelsApi, tauriModelsApi } from "./api"
|
|
4
|
+
import { ModelGate } from "./gate"
|
|
5
|
+
|
|
6
|
+
interface ModelsContextValue {
|
|
7
|
+
api: ModelsApi
|
|
8
|
+
gate: ModelGate
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
let defaultValue: ModelsContextValue | undefined
|
|
12
|
+
|
|
13
|
+
// Built on first use, so importing the module touches nothing.
|
|
14
|
+
function fallback(): ModelsContextValue {
|
|
15
|
+
defaultValue ??= { api: tauriModelsApi, gate: new ModelGate(tauriModelsApi) }
|
|
16
|
+
return defaultValue
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const ModelsContext = createContext<ModelsContextValue | null>(null)
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Gives the models hooks and components another `api` (the playground's fake) and the gate that
|
|
23
|
+
* goes with it. An app on the real backend doesn't need it.
|
|
24
|
+
*/
|
|
25
|
+
export function ModelsProvider({ api, children }: { api: ModelsApi; children: ReactNode }) {
|
|
26
|
+
const value = useMemo(() => ({ api, gate: new ModelGate(api) }), [api])
|
|
27
|
+
return createElement(ModelsContext.Provider, { value }, children)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function useModelsContext(): ModelsContextValue {
|
|
31
|
+
return useContext(ModelsContext) ?? fallback()
|
|
32
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { ModelsApi } from "./api"
|
|
2
|
+
|
|
3
|
+
/** `ensure` rejects with this when the person says no: Not now, Cancel or Close. */
|
|
4
|
+
export class ModelDeclined extends Error {
|
|
5
|
+
modelId: string
|
|
6
|
+
|
|
7
|
+
constructor(modelId: string) {
|
|
8
|
+
super(`The ${modelId} model was declined`)
|
|
9
|
+
this.name = "ModelDeclined"
|
|
10
|
+
this.modelId = modelId
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface EnsureOptions {
|
|
15
|
+
/** The tool that needs the model, for the prompt: "Smart Select". */
|
|
16
|
+
for?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The request the prompt is showing. Replaced, never mutated, so React can compare it. */
|
|
20
|
+
export interface GateRequest {
|
|
21
|
+
id: string
|
|
22
|
+
tool: string | undefined
|
|
23
|
+
/** The person pressed Download in this session. */
|
|
24
|
+
asked: boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface Waiting {
|
|
28
|
+
request: GateRequest
|
|
29
|
+
promise: Promise<void>
|
|
30
|
+
resolve: () => void
|
|
31
|
+
reject: (error: Error) => void
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The queue behind `useModelGate` and `ModelPromptHost`. It keeps only what has been asked for
|
|
36
|
+
* and whether the person said yes; the prompt's state comes from the model's status.
|
|
37
|
+
*/
|
|
38
|
+
export class ModelGate {
|
|
39
|
+
private api: ModelsApi
|
|
40
|
+
private queue: Waiting[] = []
|
|
41
|
+
private listeners = new Set<() => void>()
|
|
42
|
+
private current: GateRequest | null = null
|
|
43
|
+
|
|
44
|
+
constructor(api: ModelsApi) {
|
|
45
|
+
this.api = api
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
subscribe = (listener: () => void): (() => void) => {
|
|
49
|
+
this.listeners.add(listener)
|
|
50
|
+
return () => {
|
|
51
|
+
this.listeners.delete(listener)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The request being shown, or null. */
|
|
56
|
+
snapshot = (): GateRequest | null => this.current
|
|
57
|
+
|
|
58
|
+
/** Resolves at once for a Ready model; otherwise queues the prompt. One prompt at a time. */
|
|
59
|
+
ensure = async (id: string, options: EnsureOptions = {}): Promise<void> => {
|
|
60
|
+
const models = await this.api.list()
|
|
61
|
+
const info = models.find((m) => m.id === id)
|
|
62
|
+
if (!info) throw new Error(`Unknown model ${id}`)
|
|
63
|
+
if (info.status.kind === "ready") return
|
|
64
|
+
const waiting = this.queue.find((w) => w.request.id === id)
|
|
65
|
+
if (waiting) return waiting.promise
|
|
66
|
+
let resolve!: () => void
|
|
67
|
+
let reject!: (error: Error) => void
|
|
68
|
+
const promise = new Promise<void>((res, rej) => {
|
|
69
|
+
resolve = res
|
|
70
|
+
reject = rej
|
|
71
|
+
})
|
|
72
|
+
this.queue.push({
|
|
73
|
+
request: { id, tool: options.for, asked: false },
|
|
74
|
+
promise,
|
|
75
|
+
resolve,
|
|
76
|
+
reject,
|
|
77
|
+
})
|
|
78
|
+
this.publish()
|
|
79
|
+
return promise
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The person pressed Download (or Retry). */
|
|
83
|
+
markAsked(): void {
|
|
84
|
+
const head = this.queue[0]
|
|
85
|
+
if (!head || head.request.asked) return
|
|
86
|
+
head.request = { ...head.request, asked: true }
|
|
87
|
+
this.publish()
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The model is Ready: resolve the head and move to the next. */
|
|
91
|
+
succeed(): void {
|
|
92
|
+
const head = this.queue.shift()
|
|
93
|
+
head?.resolve()
|
|
94
|
+
this.publish()
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The person said no: reject the head with `ModelDeclined` and move to the next. */
|
|
98
|
+
decline(): void {
|
|
99
|
+
const head = this.queue.shift()
|
|
100
|
+
head?.reject(new ModelDeclined(head.request.id))
|
|
101
|
+
this.publish()
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private publish(): void {
|
|
105
|
+
this.current = this.queue[0]?.request ?? null
|
|
106
|
+
for (const l of this.listeners) l()
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// `@preset.nz/app-kit/models`: the webview half of app-kit's models feature. It imports ux-kit
|
|
2
|
+
// (the prompt, the list and the confirm dialog), so it is not part of `core`.
|
|
3
|
+
|
|
4
|
+
export type { ModelChange, ModelInfo, ModelStatus, ModelsApi } from "./api"
|
|
5
|
+
export { tauriModelsApi } from "./api"
|
|
6
|
+
export { ModelsProvider } from "./context"
|
|
7
|
+
export type { EnsureOptions } from "./gate"
|
|
8
|
+
export { ModelPromptHost } from "./ModelPromptHost"
|
|
9
|
+
export { ModelsSection } from "./ModelsSection"
|
|
10
|
+
export type { ModelGate } from "./useModelGate"
|
|
11
|
+
export { ModelDeclined, useModelGate } from "./useModelGate"
|
|
12
|
+
export type { UseModels } from "./useModels"
|
|
13
|
+
export { useModels } from "./useModels"
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { useMemo } from "react"
|
|
2
|
+
|
|
3
|
+
import { useModelsContext } from "./context"
|
|
4
|
+
import type { EnsureOptions } from "./gate"
|
|
5
|
+
|
|
6
|
+
export { ModelDeclined } from "./gate"
|
|
7
|
+
|
|
8
|
+
export interface ModelGate {
|
|
9
|
+
/**
|
|
10
|
+
* Resolves when the model is Ready: at once if it is, otherwise after the person agrees and the
|
|
11
|
+
* download ends. Rejects with `ModelDeclined` on Not now, Cancel or Close, which a tool treats
|
|
12
|
+
* as "don't arm". A `<ModelPromptHost />` must be mounted for the prompt to show.
|
|
13
|
+
*/
|
|
14
|
+
ensure(id: string, options?: EnsureOptions): Promise<void>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function useModelGate(): ModelGate {
|
|
18
|
+
const { gate } = useModelsContext()
|
|
19
|
+
return useMemo(() => ({ ensure: gate.ensure }), [gate])
|
|
20
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react"
|
|
2
|
+
|
|
3
|
+
import type { ModelChange, ModelInfo, ModelStatus, ModelsApi } from "./api"
|
|
4
|
+
import { useModelsContext } from "./context"
|
|
5
|
+
|
|
6
|
+
export interface UseModels {
|
|
7
|
+
/** Declared models first, in registry order, then any other app's. Empty until the first read. */
|
|
8
|
+
models: ModelInfo[]
|
|
9
|
+
/** Starts the download. A refusal shows as the model's `failed` status, not a throw. */
|
|
10
|
+
download(id: string): Promise<void>
|
|
11
|
+
cancel(id: string): Promise<void>
|
|
12
|
+
/** Rejects when Rust refuses (the model is downloading). */
|
|
13
|
+
remove(id: string): Promise<void>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const reasonOf = (e: unknown): string => (typeof e === "string" ? e : (e as Error).message)
|
|
17
|
+
|
|
18
|
+
function withStatus(models: ModelInfo[], id: string, status: ModelStatus): ModelInfo[] {
|
|
19
|
+
return models.map((m) => (m.id === id ? { ...m, status } : m))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The model list, kept current by `app-kit://models`. The list is the only state: it is read on
|
|
24
|
+
* mount, and every event replaces one row's status. A model that stops downloading, or one the
|
|
25
|
+
* list doesn't know, is read again, so sizes and the apps using it stay true.
|
|
26
|
+
*/
|
|
27
|
+
export function useModels(api?: ModelsApi): UseModels {
|
|
28
|
+
const context = useModelsContext()
|
|
29
|
+
const backend = api ?? context.api
|
|
30
|
+
const [models, setModels] = useState<ModelInfo[]>([])
|
|
31
|
+
const loaded = useRef(false)
|
|
32
|
+
const early = useRef(new Map<string, ModelStatus>())
|
|
33
|
+
const current = useRef(models)
|
|
34
|
+
current.current = models
|
|
35
|
+
|
|
36
|
+
const refresh = useCallback(async () => {
|
|
37
|
+
try {
|
|
38
|
+
setModels(await backend.list())
|
|
39
|
+
} catch {
|
|
40
|
+
// Keep what is shown; the next event reads again.
|
|
41
|
+
}
|
|
42
|
+
}, [backend])
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
loaded.current = false
|
|
46
|
+
early.current.clear()
|
|
47
|
+
let live = true
|
|
48
|
+
const apply = (change: ModelChange) => {
|
|
49
|
+
if (!loaded.current) {
|
|
50
|
+
early.current.set(change.id, change.status)
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
const known = current.current.some((m) => m.id === change.id)
|
|
54
|
+
if (known) setModels((cur) => withStatus(cur, change.id, change.status))
|
|
55
|
+
if (!known || change.status.kind !== "downloading") void refresh()
|
|
56
|
+
}
|
|
57
|
+
// Subscribe first, so a change during the first read isn't lost.
|
|
58
|
+
const off = backend.subscribe(apply)
|
|
59
|
+
backend.list().then(
|
|
60
|
+
(list) => {
|
|
61
|
+
if (!live) return
|
|
62
|
+
loaded.current = true
|
|
63
|
+
setModels(list.map((m) => ({ ...m, status: early.current.get(m.id) ?? m.status })))
|
|
64
|
+
early.current.clear()
|
|
65
|
+
},
|
|
66
|
+
() => {},
|
|
67
|
+
)
|
|
68
|
+
return () => {
|
|
69
|
+
live = false
|
|
70
|
+
off()
|
|
71
|
+
}
|
|
72
|
+
}, [backend, refresh])
|
|
73
|
+
|
|
74
|
+
const download = useCallback(
|
|
75
|
+
async (id: string) => {
|
|
76
|
+
const before = current.current.find((m) => m.id === id)
|
|
77
|
+
if (before?.status.kind === "downloading") return
|
|
78
|
+
// Show it downloading before the command returns: its events can only come after this, and a
|
|
79
|
+
// quick failure then can't be overwritten by a stale row.
|
|
80
|
+
setModels((cur) =>
|
|
81
|
+
withStatus(cur, id, { kind: "downloading", done: 0, total: before?.totalBytes ?? 0 }),
|
|
82
|
+
)
|
|
83
|
+
try {
|
|
84
|
+
await backend.download(id)
|
|
85
|
+
} catch (e) {
|
|
86
|
+
setModels((cur) => withStatus(cur, id, { kind: "failed", reason: reasonOf(e) }))
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
[backend],
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
const cancel = useCallback((id: string) => backend.cancel(id).catch(() => {}), [backend])
|
|
93
|
+
|
|
94
|
+
const remove = useCallback(
|
|
95
|
+
async (id: string) => {
|
|
96
|
+
await backend.remove(id)
|
|
97
|
+
await refresh()
|
|
98
|
+
},
|
|
99
|
+
[backend, refresh],
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
return { models, download, cancel, remove }
|
|
103
|
+
}
|