@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 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, or by restore), 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.
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` reopens the last document at launch, the first entry of Open Recent, and opens a document the Finder hands over (double-click, `open some.x`, the Dock), which wins over the restored one. A last document that has gone, or won't load, becomes a note, not a dialog: register `app_kit_document_note` and show what `useDocumentNotes` hands you. For an app with documents, app-kit owns Finder opens, so don't add a second handler.
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`, `useDocumentNotes(onNote)`**: the live `DocumentState`, and notes about the document (a last document that wouldn't reopen), each delivered once. Show a note as a snackbar.
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, so a plain browser tab (`just dev-web` in the playground) has no commands and an empty toolbar.
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.2",
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/app-kit.git"
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.0",
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": "~5.9.3"
34
+ "typescript": "~7.0.2",
35
+ "@preset.nz/ux-kit": "^0.1.2"
34
36
  },
35
37
  "peerDependenciesMeta": {
36
38
  "@preset.nz/ux-kit": {
@@ -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, useRef, useState } from "react"
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
- * Notes about the document for the app to show, such as a last document that wouldn't reopen
35
- * at launch. Each arrives once: the ones held from before the webview listened, then any later
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(onNote: (message: string) => void): void {
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
+ }