@hachej/boring-ui-kit 0.4.25 → 0.4.27
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 +40 -3
- package/dist/chat-artifacts.d.ts +157 -0
- package/dist/chat-artifacts.js +274 -0
- package/dist/chat-dictation.d.ts +27 -0
- package/dist/chat-dictation.js +71 -0
- package/dist/chat-react-controller.d.ts +25 -0
- package/dist/chat-react-controller.js +72 -0
- package/dist/chat-react-conversations.d.ts +40 -0
- package/dist/chat-react-conversations.js +68 -0
- package/dist/chat-react-draft.d.ts +11 -0
- package/dist/chat-react-draft.js +91 -0
- package/dist/chat-react-remote.d.ts +33 -0
- package/dist/chat-react-remote.js +72 -0
- package/dist/chat-react-session.d.ts +170 -0
- package/dist/chat-react-session.js +367 -0
- package/dist/chat-react-transcript.d.ts +39 -0
- package/dist/chat-react-transcript.js +123 -0
- package/dist/chat-react.d.ts +62 -0
- package/dist/chat-react.js +95 -0
- package/dist/chat-text.d.ts +131 -0
- package/dist/chat-text.js +220 -0
- package/dist/chat-view.d.ts +2 -17
- package/dist/chat-view.js +1 -0
- package/dist/chat-voice-message.d.ts +65 -0
- package/dist/chat-voice-message.js +235 -0
- package/dist/chat-voice.d.ts +7 -0
- package/dist/chat-voice.js +4 -0
- package/dist/experience-catalog.d.ts +2 -2
- package/dist/experience-region-tree.d.ts +1 -1
- package/dist/markdown-editor-labels.d.ts +83 -0
- package/dist/markdown-editor-labels.js +75 -0
- package/dist/markdown-editor.d.ts +7 -1
- package/dist/markdown-editor.js +28 -18
- package/dist/markdown-extensions.d.ts +4 -2
- package/dist/markdown-extensions.js +2 -2
- package/dist/markdown-preserved.d.ts +5 -1
- package/dist/markdown-preserved.js +15 -4
- package/dist/markdown-proposal-diff.d.ts +3 -1
- package/dist/markdown-proposal-diff.js +6 -5
- package/dist/run-notifications.d.ts +42 -0
- package/dist/run-notifications.js +67 -0
- package/dist/shared-chat/index.d.ts +1 -1
- package/dist/shared-chat/index.js +5 -4
- package/dist/text-draft-controls.d.ts +5 -1
- package/dist/text-draft-controls.js +6 -5
- package/dist/text-draft-labels.d.ts +26 -0
- package/dist/text-draft-labels.js +47 -0
- package/dist/voice-attempt.d.ts +33 -0
- package/dist/voice-attempt.js +122 -0
- package/dist/voice-capture.d.ts +12 -0
- package/dist/voice-capture.js +17 -2
- package/package.json +14 -2
- package/r/agent-target.json +5 -9
- package/r/app-shell.json +19 -28
- package/r/board-viewer.json +8 -11
- package/r/button.json +2 -9
- package/r/diagram-viewer.json +0 -5
- package/r/dock.json +2 -2
- package/r/documents.json +13 -18
- package/r/feedback-preview.json +1 -7
- package/r/feedback.json +12 -19
- package/r/file-tree.json +4 -14
- package/r/highlights.json +4 -9
- package/r/html-viewer.json +3 -3
- package/r/looks-viewer.json +3 -6
- package/r/maquette-viewer.json +2 -20
- package/r/markdown-editor.json +4 -7
- package/r/mockup-viewer.json +2 -5
- package/r/pi-ambient.json +11 -19
- package/r/pi-app-kinds.json +10 -10
- package/r/pi-app.json +17 -27
- package/r/pi-artifacts.json +3 -4
- package/r/pi-chat-approval.json +2 -5
- package/r/pi-chat-attachments.json +2 -4
- package/r/pi-chat-markdown.json +3 -5
- package/r/pi-chat-mentions.json +2 -3
- package/r/pi-chat-pickers.json +2 -4
- package/r/pi-chat-plugins.json +11 -11
- package/r/pi-chat-question.json +1 -7
- package/r/pi-chat-slash.json +3 -6
- package/r/pi-chat-subagent.json +3 -8
- package/r/pi-chat-voice.json +7 -10
- package/r/pi-chat.json +25 -53
- package/r/pi-workspace.json +4 -7
- package/r/placed-agent.json +4 -5
- package/r/provider-setup.json +2 -8
- package/r/record-viewer.json +0 -9
- package/r/registry.json +182 -403
- package/r/requests-board.json +2 -10
- package/r/shared-chat.json +10 -24
- package/r/task-list-viewer.json +3 -9
- package/r/utils.json +14 -2
- package/r/version-history.json +3 -5
- package/r/viewer-code.json +2 -3
- package/r/viewer-frame.json +9 -17
- package/r/viewer-html.json +4 -5
- package/r/viewer-image.json +3 -4
- package/r/viewer-markdown.json +6 -8
- package/r/viewer-pdf.json +5 -7
- package/r/viewers.json +6 -6
- package/r/windows.json +3 -8
package/README.md
CHANGED
|
@@ -38,7 +38,7 @@ import { MarkdownEditor } from '@hachej/boring-ui-kit/markdown-editor';
|
|
|
38
38
|
|
|
39
39
|
Install the pinned React, React DOM, Marked and Tiptap peers listed in this package's manifest when selecting this entry. They remain optional for headless consumers. The renderer borrows its controller; unmounting does not dispose it or close its resource client. The host owns selection, identity and teardown.
|
|
40
40
|
|
|
41
|
-
Formatting defaults to the native Tiptap selection bubble on desktop and phone. The header’s **Formatting** icon toggles the full bar; unpressed means selection-only formatting. It remembers the choice for the viewer session, stays visible on phones with a 48px target and hides in Markdown mode. The **…** menu keeps **Formatted** / **Markdown**. Pass `formattingToolbar="pinned"` for the full desktop row (on phones it docks above the keyboard while editing).
|
|
41
|
+
Formatting defaults to the native Tiptap selection bubble on desktop and phone. The header’s **Formatting** icon toggles the full bar; unpressed means selection-only formatting. It remembers the choice for the viewer session, stays visible on phones with a 48px target and hides in Markdown mode. The **…** menu keeps **Formatted** / **Markdown**. Pass `formattingToolbar="pinned"` for the full desktop row (on phones it docks above the keyboard while editing). `locale="fr"` translates everything the editor draws itself (status, Save, save and conflict alerts, toolbar and source-box accessible names, proposals, draft recovery); `labels={{ saved: 'Enregistré', sourceName: title => title }}` overrides single words of either set (`markdownEditorLabels.en` lists every key). Text the host supplies (`title`, `placeholder`, a save result's `reason`) is shown as given. Installed recipes supply real shadcn Base UI menu items through the optional `components.markdownMenu` binding. Keyboard shortcuts and Markdown input rules remain available. Rich formatting and Markdown source controls share controller state. Mounting, switching modes and projecting remote text never serialize the original buffer. Genuine rich edits serialize the changed block range through Tiptap; untouched source spans are retained verbatim.
|
|
42
42
|
|
|
43
43
|
Formatted opens documents with YAML front matter, raw HTML/comments, footnotes, reference links, CRLF and style variants (`*` bullets, four-space nesting, underscore emphasis and setext headings). Front matter is an editable literal code block. HTML and definitions are quiet raw blocks; inline HTML and references are inert source atoms, never executable HTML. A save without editing preserves the original bytes. Paragraph/heading/list-item source metadata also keeps unchanged spelling through edits and native undo. Edited content follows its existing heading style and document markers where valid; intraword emphasis uses stars when underscores would change its meaning. CRLF is edited as LF and restored on write. The existing legacy ` `-only paragraph cleanup remains.
|
|
44
44
|
|
|
@@ -80,9 +80,15 @@ The default schema includes headings, lists, task lists, quotes, code and text m
|
|
|
80
80
|
|
|
81
81
|
Where the page has Web Audio, the recording is a 16 kHz mono 16-bit WAV (`audio/wav`, 32 kB/s), not MediaRecorder's webm: a webm carries the browser's clock, and one whose timestamps jumped was refused by every OpenAI transcription model. `start()` resolves `recording` once the first sound arrives, so a host showing "recording" never loses the first words; no sound within `firstSoundMs` (5 s) refuses as `busy`. A context the browser keeps suspended (no user activation, e.g. a hold begun on `touchstart`) records that take with MediaRecorder as before; `audioContext: null` always does. `encodeWav` and `downsample` are exported.
|
|
82
82
|
|
|
83
|
-
`
|
|
83
|
+
`progress()` returns current elapsed milliseconds, PCM RMS level, paused state and pause capability (null outside a capture). `pause()`/`resume()` return false when unavailable/ended. PCM pause keeps the microphone open but stops appending frames: elapsed time and WAV duration exclude paused samples. MediaRecorder fallback exposes no level or pause, never fabricated telemetry.
|
|
84
84
|
|
|
85
|
-
|
|
85
|
+
`maxMs` caps captured audio at five minutes by default. Reaching the cap stops the microphone; callers still choose when to consume the recording through `stop()`. Transcription and persistence remain host capabilities. The existing `@hachej/boring-feedback/page` capture API re-exports this implementation unchanged. The headless root imports neither entry.
|
|
86
|
+
|
|
87
|
+
`@hachej/boring-ui-kit/chat-react/voice` adds the optional React voice orchestration without adding microphone capture to core `chat-react` imports: `useVoiceAttempt<Data, Phase>()` owns attempt identities, abort signals, microphone cleanup and sample-based elapsed time, input levels and pause/resume. `start(data, maxMs?)` begins from the gesture; `stop(attempt)` retains the recording; `remove(attempt)` cancels only that attempt. `useDictation({ transcribe, onText, labels, maxMs?, target? })` trims transcription and delivers it only while the attempt still belongs to the selected target. Cancel, finish during permission, target replacement and unmount fence late results. Supply the eight voice labels (`DictationLabels`); the registry adapter supplies its English/French table. `voiceRefusalLabel` in `chat-text` is the one refusal-to-label-key map.
|
|
88
|
+
|
|
89
|
+
`useVoiceMessage({ controller, config, attachments?, labels, admitted?, activeController? })` uses that same capture lifecycle, uploads and transcribes in parallel on release, and submits one native `followUp` with the audio mention and transcript. It preserves typed drafts, successful upload/transcription halves and exact native draft/request identity for uncertain admission; native reconciliation, not a new voice retry, resolves unknown sends. `useChatVoice({ controller, voice, attachments?, labels, onDone })` selects text-only draft/auto or retained message mode for a shell, including native send, draft restore and completion callbacks. Both lower-level hooks accept the capture's `maxMs` semantics: five minutes by default, nonpositive/nonfinite disables the cap, and the cap stops the microphone without automatically delivering text or sending. These are browser preparation hooks over the existing native controller, not Pi execution extensions. Evidence: `test/packages/ui-chat-react-voice.test.mjs`.
|
|
90
|
+
|
|
91
|
+
The [registry voice-message guide](../../registry/README.md#voice-messages-in-pi-chat) describes PiChat's optional tap-to-record composition. Public capture behavior runs in `test/packages/ui.test.mjs`, compatibility in `test/packages/feedback-voice.test.mjs`, and dependency-free installation in `test/scaffold/isolated-ui.test.mjs`.
|
|
86
92
|
|
|
87
93
|
## Document marks
|
|
88
94
|
|
|
@@ -146,6 +152,37 @@ A submit the host refused before admission (status 402 `submission-refused`, for
|
|
|
146
152
|
|
|
147
153
|
`@hachej/boring-ui-kit/chat-view` holds the chat's presentation rules once, so the registry items (`pi-chat`, `pi-ambient`) keep only their markup. It imports Pi types only: no React, no files or agent code. `derive(view, options)` turns a native `ConversationView` into display rows (cards, activity steps, questions, approvals, artifacts) and the run state; its card content type is generic, and the items fix it to React nodes. `waits(part)`, `waitingPart(rows)` and `waitingForAnswer(rows)` are the one rule for "waiting for the person": a committed question with no result, or an approval with no result and no decision (a decided approval is not waiting). `preparing(...)` is when the natural presentation's single status shows. `pendingNotice` and `finishNotice` give the words of the run toasts and system notifications from one table, `defaultNoticeText`, which a surface can replace to localize them. Evidence: `test/packages/ui-chat-view.test.mjs`.
|
|
148
154
|
|
|
155
|
+
`@hachej/boring-ui-kit/chat-text` holds the chat's text grammar once: pure string functions, no React, files or agent code. `mentionToken(value)` writes an `@` mention (quoted when the value has whitespace, a quote or a backslash) and `mentionedPaths(text)`, `MENTION_TOKEN` and `pieces(text, options)` read mentions back, with the same parser as `mentionedPaths` of `@hachej/boring-agent/mentions`; `hasMention` and `removeMention` edit a draft; `slashItems`, `slashGroup` and `slashQuery` drive the `/` menu, `mentionTrigger` the `@` picker. `appendToDraft(draft, text)` adds text at the end of a draft with one space, and `prependToDraft(draft, text)` puts a refused message back on its own line before it. `fileKind`, `isUploadPath`, `isVoicePath`, `displayName`, `visibleParts`, `isLongPaste` and `looksLikeMarkdown` classify attachments and pastes. The shared chat writes and finds member mentions with it (`memberMention`). `kindMentionDraft(text, { kinds })` projects editable composer text plus its hidden kind-token spellings from the full native draft, leaving file tokens visible. `writeKindMentionDraft(text, tokens, skills?)` keeps a leading registered skill first, then prefixes complete hidden tokens before the remaining editable text so unfinished quoted input cannot consume them; `skills` is the registered name list, parsed by the same `pieces` grammar. `mentionKind(value, { kinds })` resolves an app-registered `prefix:path` token without depending on its renderer; `withoutMentions(text)` consumes all tokens for voice sends, while `withoutKindMentions(text, { kinds })` drops only registered app tokens from refused-message restoration. See [App mention kinds](../../registry/README.md#app-mention-kinds) for the React config. Evidence: `test/packages/ui-chat-text.test.mjs`.
|
|
156
|
+
|
|
157
|
+
`@hachej/boring-ui-kit/chat-react` provides `useRunNotifications(controller, store, options)`, `watchConversation(controller, store, options)` and `createNotificationStore()`. Both adapters use one transition over the existing controller's `subscribe`/`getSnapshot`, with no execution lifecycle ownership. `options` supplies `conversationId`, `title`, `question(call)` and optional localized `text` (`NoticeText`); `enabled`, `suppress()` and `onOutcome()` are optional. Questions notify even without a run; approval decisions dismiss input notices; disconnected snapshots neither complete work nor consume transitions. The first connected observation establishes a baseline (and reports pending input), never invents a past completion. Suppression consumes a transition without pushing a toast. Detaching releases only the subscription. The store is transient presentation, not durable delivery. This is host UI policy over native views, not a Pi execution extension. Existing registry notification exports remain available. Evidence: `test/packages/ui-run-notifications.test.mjs`.
|
|
158
|
+
|
|
159
|
+
`chat-view` also owns artifact descriptors and parsing (`parseArtifact`, `detectArtifact`, `recogniseArtifact`), `pendingPresents` and `createPresentAcks`. `artifactVersions(view, detect?)` lists newest conversation results first, even when retried calls reuse an ordinal. `follows(descriptor, versions)` compares the newest revision for that artifact key. `collectArtifacts` remains oldest first for compatibility.
|
|
160
|
+
|
|
161
|
+
### Chat React hooks
|
|
162
|
+
|
|
163
|
+
`@hachej/boring-ui-kit/chat-react` is opt-in React presentation over the existing `ChatController.subscribe/getSnapshot` seam, never a Pi executor or a second store. `useArtifactFollow` takes the controller, conversation ID, active descriptor/follow selection and host opening/guard callbacks. It returns versions, `open`, `isOpen`, `select`, `recordClosed` and viewer `onShown`/`onFailed` callbacks. Initial artifacts do not auto-open; new ones may when `autoOpen` is enabled (default false), unless the host reports a narrow screen, unsaved work or a close in this turn. Call `recordClosed` only after the host's leave guard accepts. A present is acknowledged with only the revision the viewer reports after it loads and mounts; unmount reports use `hidden: true`, failures refuse, and absent reports stay unconfirmed. Controller/conversation changes fence old reports and retries. The host retains its controller and execution lifetime. Evidence: `test/packages/ui-chat-react.test.mjs`, `test/packages/ui-present-ack.test.mjs`.
|
|
164
|
+
|
|
165
|
+
`useChatSession<Content>({ controller, activeController, mode, labels, ...features })` is the headless session. `labels` is the small `ChatSessionLabels` contract (no built-in locale or UI); renderers use generic `Content`, with optional tool-name renderers before the catch-all. The hook returns `state`, `derived`, active/archived `rows`, `files`, `queued`, `queueActions`, connection/loading/working/waiting/send readiness, errors and actions. Optional `artifacts.recognise` enables artifact derivation; the registry enables it only when its card is installed. Files are observed conversation references opened by host callbacks, not a filesystem or a new file store.
|
|
166
|
+
|
|
167
|
+
- `send()` uses native `followUp`; `putBack(text, images)` preserves later typing. Optional `feedback` attaches notes with exact native restore content. The existing `mentions.kinds` rules are preserved: native sends keep app tokens, refused drafts restore words/images without app chips, and app cards are excluded from observed files. Unknown admission stays blocked for native reconcile/retry; the hook never creates a retry identity.
|
|
168
|
+
- `queueActions.edit/steer` withdraw first and refuse already-placed messages; steer preserves the current draft. `upload(files)` uses host `attachments.upload` (paths become mentions; images native attachments), legacy `onFiles` or host-owned `onPickFiles`. `pending`, `picked`, `pickMention`, `dismissUpload` drive presentation. `pasteText({ text, start, end })` uploads Markdown/text and supplies a pending cancel action that restores the captured paste without deleting intervening edits. Paste coordinates address editable text, retaining hidden app tokens and the existing leading `slash.skills` order on cancel; the composer decides paste thresholds through `chat-text`.
|
|
169
|
+
- `currentModel`, `currentEffort`, `changing`, `changeError`, `changeModel`, `changeEffort` and `dismissChangeError` drive optional host pickers; accepted choices stay optimistic until the native watch agrees. `activateMention` checks the current command catalog and connection; host authorization is still separate.
|
|
170
|
+
- **Ownership:** mount one session per controller (key/remount on replacement, as the registry does), keep `activeController.current` bound to the host's current controller, and supply stable host configs where possible. The hook connects on mount, detaches its subscription and aborts only its own uploads on unmount. It never disposes the borrowed controller, stops Pi work or owns a Harness. `afterSend` and `onFocus` are event callbacks; DOM refs, JSX, pickers and composer slots stay in the registry. Nothing is exported by the files/Pi/agent-free root.
|
|
171
|
+
|
|
172
|
+
Public-output/StrictMode evidence: `test/packages/ui-chat-session.test.mjs`. `pi-chat/session.useChatSession` retains its presentation adapter API and old contract type imports for one release. `ChatConnectionNotice` and `ChatStatusLine` in `pi-chat/notice` are reusable presentation shared by PiChat and Ambient; Ambient's host connection-error renderer now survives both full and compact notices. `test/ui/ambient-position.test.mjs` drives the public Ambient component and status matrix.
|
|
173
|
+
|
|
174
|
+
The same entry exports the shared chat hooks (not from the headless root):
|
|
175
|
+
|
|
176
|
+
- `useChatView(controller, select)` selects the native `view` (possibly undefined) through the existing `subscribe/getSnapshot`; selection is memoized by view and selector identity. It borrows the controller and introduces no store. Keep a selector stable to avoid repeated derivation.
|
|
177
|
+
- `useTranscriptWindow(rows, first = 60, more = 40, history?, automatic = true)` (alias `useTranscript`) returns `hidden`, `visible`, `reveal`, `revealTo`, `reset`, `loaded`, `scrollRef` and `sentinelRef`. Give `scrollRef` the host scroll element. The hook owns only the paging window and top sentinel, not scroll position or follow-bottom. Registry `session.useTranscript` combines it with the app's shadcn MessageScroller, the only actual scroll/anchor owner. `useRowChunks(rows, size = 20)` retains run boundaries and unchanged row arrays for that scroller.
|
|
178
|
+
- `useChatDraft(controller, key)` keeps the existing per-conversation localStorage policy under `pi-chat:draft:<key>`; undefined opts out, blocked storage never blocks typing. The host must scope the key by owner when needed and clear sensitive drafts on logout. `useChatPlacement` / `readChatPlacement` report the last surviving presenter's docked/floating registration; placement is advisory, never authorization.
|
|
179
|
+
- `useRemoteChat({ conversationId, endpoint, fetch, identity, requestContext?, transport?, webSocket? })` returns connecting/offline/ready with the concrete native controller, transport actions and configure. Selection keys include the conversation, resolved URL, identity IDs and transport; new identity objects alone do not reconnect. Current fetch/context callbacks are read after commit; each submit merges current placement alongside the original screen context. Failed acquisition retries every second; transport reconnect remains the existing remote client's responsibility.
|
|
180
|
+
- `useControllerPerConversation({ key, create, connect?, dispose?, retryMs? })` is shared selected-adapter ownership, not a Pi lifecycle. Use a stable key including source/owner/options; undefined acquires nothing. It returns connecting/offline/ready and preserves the factory's concrete controller type. It disposes only what the factory creates (including late results and StrictMode mounts), awaits async teardown on retry, and releases retry timers on selection change. A custom `dispose` releases any additional owned transport; teardown errors are reported to the console. It never aborts native work or closes a borrowed source/Harness. `useSharedChat` uses it without changing its existing connect-on-session-mount behavior.
|
|
181
|
+
- `useConversationsClient({ endpoint, fetch, activeId, onSelect, onChange?, pollMs? })` (alias `useConversations`) returns the headless list and reload/search/create/rename/archive/delete/fork callbacks once the first list arrives. Polling defaults to four seconds; fetch and selection callbacks remain host-owned. Registry `ConversationsConfig` adds presentation-only row actions.
|
|
182
|
+
|
|
183
|
+
Public hook/StrictMode evidence: `test/packages/ui-chat-react-hooks.test.mjs`. Existing `test/contracts/chat-placement*.test.mjs` and `test/ui/pi-chat-draft.test.mjs` also drive the compatibility entry points. Browser scroll and installed consumers are separate gates.
|
|
184
|
+
|
|
185
|
+
|
|
149
186
|
### Native conversation history
|
|
150
187
|
|
|
151
188
|
`loadEarlier()` reads one native page through `Conversation.entries`. `snapshot.history` distinguishes disabled, idle, loading, ready and error states. Each request contains at most 40 native entries. The controller accumulates pages in display order for the person's current browsing session and retains an opaque native continuation cursor. It clears that cache on history invalidation or `clearHistory()`; loading never restores records to model context. The first request captures the greatest observed active entry ID as an inclusive upper bound; later writes do not move that bound. Parent-owned fork entries remain valid native history.
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import type { ConversationView } from '@earendil-works/pi-durable';
|
|
2
|
+
import type { ToolCall, ToolResultMessage } from '@earendil-works/pi-ai';
|
|
3
|
+
/**
|
|
4
|
+
* An artifact a tool call made (or a running `present` call is making), as the chat places it. `descriptor` is the host's own
|
|
5
|
+
* value: the chat hands it back to the host's `open` and `isOpen` and never reads it.
|
|
6
|
+
*/
|
|
7
|
+
export interface ChatArtifact {
|
|
8
|
+
readonly state: 'presenting' | 'ready';
|
|
9
|
+
/** One card per id in a reply: the last version, at the place of its last step. */
|
|
10
|
+
readonly id: string | undefined;
|
|
11
|
+
readonly title: string | undefined;
|
|
12
|
+
/** The workspace path, for the conversation's file list. */
|
|
13
|
+
readonly path: string | undefined;
|
|
14
|
+
/** The tool's own result carries it, so its card replaces the step; a host-detected one is shown in addition to the step. */
|
|
15
|
+
readonly own: boolean;
|
|
16
|
+
readonly descriptor: unknown;
|
|
17
|
+
}
|
|
18
|
+
/** Finds an artifact descriptor a finished call's result does not carry itself (the host's `detect`). */
|
|
19
|
+
export type ArtifactDetect = (call: ToolCall, result: ToolResultMessage) => unknown;
|
|
20
|
+
export declare const ARTIFACT_TYPES: readonly ["markdown", "html", "svg", "code", "canvas"];
|
|
21
|
+
export type ArtifactType = typeof ARTIFACT_TYPES[number];
|
|
22
|
+
/** Structural twin of the `ResourceLocator` in `@hachej/boring-files`. */
|
|
23
|
+
export interface ArtifactTarget {
|
|
24
|
+
readonly resource: {
|
|
25
|
+
readonly providerId: string;
|
|
26
|
+
readonly path: string;
|
|
27
|
+
};
|
|
28
|
+
readonly view: {
|
|
29
|
+
readonly kind: 'published';
|
|
30
|
+
} | {
|
|
31
|
+
readonly kind: 'working';
|
|
32
|
+
readonly viewId: string;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
export interface ArtifactDescriptor {
|
|
36
|
+
readonly schema: 'boring.artifact';
|
|
37
|
+
readonly version: 1;
|
|
38
|
+
/** Set by the artifact tools; the descriptor of `present` has none (the file path is its identity). */
|
|
39
|
+
readonly id?: string;
|
|
40
|
+
readonly title: string;
|
|
41
|
+
readonly type: ArtifactType;
|
|
42
|
+
readonly mediaType: string;
|
|
43
|
+
readonly language?: string;
|
|
44
|
+
readonly target: ArtifactTarget;
|
|
45
|
+
/** The revision this message produced. */
|
|
46
|
+
readonly revision: string;
|
|
47
|
+
/** The 1-based version number of this artifact (artifact tools only). */
|
|
48
|
+
readonly ordinal?: number;
|
|
49
|
+
}
|
|
50
|
+
/** What identifies an artifact across its versions: its id, or for a presented file its path. */
|
|
51
|
+
export declare const artifactKey: (artifact: Pick<ArtifactDescriptor, "id" | "target">) => string;
|
|
52
|
+
export declare const PRESENT_TOOL = "present";
|
|
53
|
+
/** A `present` call with no result yet, while its conversation runs: its answer ID (`[assistantEntryId, callId]`) and the path it names. */
|
|
54
|
+
export interface PendingPresent {
|
|
55
|
+
readonly id: string;
|
|
56
|
+
readonly callId: string;
|
|
57
|
+
readonly path: string;
|
|
58
|
+
}
|
|
59
|
+
/** The path a `present` call or a page acknowledgement names, as the server compares it (a leading `./` is the same file). */
|
|
60
|
+
export declare const presentPath: (path: string) => string;
|
|
61
|
+
/**
|
|
62
|
+
* The `present` calls still waiting for the page (the server's view-open record, `@hachej/boring-agent/artifacts`). Mirrors
|
|
63
|
+
* the preview's pending-call scan (the feedback plug-in's `pendingBrowserTasks`): a call with no result, in a conversation that is running.
|
|
64
|
+
*/
|
|
65
|
+
export declare function pendingPresents(view: {
|
|
66
|
+
readonly entries: readonly {
|
|
67
|
+
readonly id: number | bigint | string;
|
|
68
|
+
readonly model?: readonly unknown[] | undefined;
|
|
69
|
+
}[];
|
|
70
|
+
readonly docs?: Readonly<Record<string, unknown>>;
|
|
71
|
+
} | undefined): readonly PendingPresent[];
|
|
72
|
+
/** Strict shape check of an untrusted value. Returns a fresh copy or undefined. */
|
|
73
|
+
export declare function parseArtifact(value: unknown): ArtifactDescriptor | undefined;
|
|
74
|
+
/**
|
|
75
|
+
* The descriptor a successful tool result carries: the host's `detect` first, then the `artifact` key of a JSON text result.
|
|
76
|
+
* Failed results never produce one, and a host callback that throws or returns something malformed is ignored. Without
|
|
77
|
+
* the host's `detect`, only a result of `present` counts: any other tool whose text happens
|
|
78
|
+
* to be that JSON (an MCP tool, `cat x.json`) cannot forge a card. A host with renamed tools says so in `detect`.
|
|
79
|
+
*/
|
|
80
|
+
export declare function detectArtifact(call: ToolCall, result: ToolResultMessage, detect?: ArtifactDetect | undefined): ArtifactDescriptor | undefined;
|
|
81
|
+
/** The title a still-running `present` call announces in its streamed arguments: the file name. */
|
|
82
|
+
export declare function pendingTitle(call: ToolCall): string | undefined;
|
|
83
|
+
/** What a still-running `present` call will show: the file path. */
|
|
84
|
+
export declare function pendingId(call: ToolCall): string | undefined;
|
|
85
|
+
/**
|
|
86
|
+
* Every artifact version a conversation's tool results produced, oldest first. Hosts use it to list the versions known
|
|
87
|
+
* from the conversation (a version switcher); the chat itself shows only the last version of an artifact per turn.
|
|
88
|
+
*/
|
|
89
|
+
export declare function collectArtifacts(view: ConversationView | undefined, detect?: ArtifactDetect | undefined): ArtifactDescriptor[];
|
|
90
|
+
/** What the host's chat answer returns: the kind is all the presents bookkeeping reads. */
|
|
91
|
+
export interface PresentAnswer {
|
|
92
|
+
readonly kind: string;
|
|
93
|
+
}
|
|
94
|
+
export interface PresentAcksHost {
|
|
95
|
+
/** The chat's `answer` (the transport's `?op=answer`); undefined when the host has none. */
|
|
96
|
+
readonly answer: (id: string, answer: string) => Promise<PresentAnswer> | undefined;
|
|
97
|
+
/** Open the file in the panel; false when the panel refuses (unsaved changes the person keeps). */
|
|
98
|
+
readonly open: (path: string) => boolean;
|
|
99
|
+
/** This page's identity in the acknowledgement. */
|
|
100
|
+
readonly instanceId: string;
|
|
101
|
+
/** Test seams: the wait between retries and the clock of the retry bound. Default: real timers. */
|
|
102
|
+
readonly wait?: (ms: number) => Promise<void>;
|
|
103
|
+
readonly now?: () => number;
|
|
104
|
+
}
|
|
105
|
+
/** The page's state of the presents it must answer, as `AgentWorkspace` reads it on each change. */
|
|
106
|
+
export interface PresentContext {
|
|
107
|
+
/** The person closed the panel in this turn. */
|
|
108
|
+
readonly closedThisTurn: boolean;
|
|
109
|
+
/** The open file has unsaved edits. */
|
|
110
|
+
readonly unsaved: boolean;
|
|
111
|
+
/** The screen is a phone (the panel is a sheet and is not opened for an agent). */
|
|
112
|
+
readonly narrow: boolean;
|
|
113
|
+
/** `autoOpen`: when false the panel is not opened for an agent; the call waits for a manual open. */
|
|
114
|
+
readonly autoOpen: boolean;
|
|
115
|
+
/** Whether the panel already shows this file. */
|
|
116
|
+
readonly showing: (path: string) => boolean;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* The page's side of the view-open acknowledgement. A `present` call is confirmed only by a viewer's mount report (`shown`) of the
|
|
120
|
+
* revision it names. A call is decided once; an answer the server denies (a revision the page showed before the agent wrote a new one)
|
|
121
|
+
* keeps the call waiting for the next mount report, and an answer is retried briefly while the server has not recorded the call yet.
|
|
122
|
+
*/
|
|
123
|
+
export declare function createPresentAcks(host: PresentAcksHost): {
|
|
124
|
+
/** A viewer mounted and shows `path` at `revision`: confirms the calls waiting for that file. */
|
|
125
|
+
shown(view: {
|
|
126
|
+
readonly path: string;
|
|
127
|
+
readonly revision: string;
|
|
128
|
+
}): void;
|
|
129
|
+
/** A viewer could not show `path` (a kind's `onFailed`, or its renderer threw): refuses the calls waiting for that file with `reason`. */
|
|
130
|
+
failed(view: {
|
|
131
|
+
readonly path: string;
|
|
132
|
+
readonly reason: string;
|
|
133
|
+
}): void;
|
|
134
|
+
/** A viewer stopped showing `path` at `revision` (it unmounted, or a custom viewer or the canvas took the file): it no longer confirms. */
|
|
135
|
+
hidden(view: {
|
|
136
|
+
readonly path: string;
|
|
137
|
+
readonly revision: string;
|
|
138
|
+
}): void;
|
|
139
|
+
/** The presents the server waits for: each is decided once, in order. Decided calls and waits that are no longer live are forgotten. */
|
|
140
|
+
consider(calls: readonly PendingPresent[], context: PresentContext): void;
|
|
141
|
+
/** Calls still waiting for a mount (test and debugging view). */
|
|
142
|
+
waiting: () => readonly string[];
|
|
143
|
+
};
|
|
144
|
+
/** What `createPresentAcks` returns. */
|
|
145
|
+
export type PresentAcks = ReturnType<typeof createPresentAcks>;
|
|
146
|
+
/**
|
|
147
|
+
* The artifact a tool call made, as the chat places it: a finished call that carries a descriptor (its own, or one the host's `detect`
|
|
148
|
+
* finds) is ready; a `present` call still running while its conversation runs (`running`) is presenting. Any other call has none.
|
|
149
|
+
*/
|
|
150
|
+
export declare function recogniseArtifact(call: ToolCall, result: ToolResultMessage | undefined, running: boolean, detect: ArtifactDetect | undefined): ChatArtifact | undefined;
|
|
151
|
+
/** Every version, newest conversation result first. Ordinals can tie across retries and never determine recency. */
|
|
152
|
+
export declare function artifactVersions(view: ConversationView | undefined, detect?: ArtifactDetect): ArtifactDescriptor[];
|
|
153
|
+
/** Whether this descriptor is the newest known revision of its artifact, never an ordinal comparison. */
|
|
154
|
+
export declare function follows(descriptor: ArtifactDescriptor, versions: readonly ArtifactDescriptor[]): boolean;
|
|
155
|
+
/** The number of user messages in the current native view, used to retain a manual close until the next turn. */
|
|
156
|
+
export declare function artifactTurn(view: ConversationView | undefined): number;
|
|
157
|
+
//# sourceMappingURL=chat-artifacts.d.ts.map
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Artifacts in the chat: substantial content (a document, an HTML page, an SVG, code, a canvas) that the transcript shows as a
|
|
3
|
+
* card and the host opens in its own panel. The descriptor and `parseArtifact` mirror `@hachej/boring-agent/artifacts` on purpose: the UI
|
|
4
|
+
* cannot import that package (it carries Pi runtime code). Keep the two validators in step;
|
|
5
|
+
* `test/contracts/pi-chat-source.test.mjs` checks that they accept and refuse the same values.
|
|
6
|
+
*/
|
|
7
|
+
export const ARTIFACT_TYPES = ['markdown', 'html', 'svg', 'code', 'canvas'];
|
|
8
|
+
/** What identifies an artifact across its versions: its id, or for a presented file its path. */
|
|
9
|
+
export const artifactKey = (artifact) => artifact.id ?? artifact.target.resource.path;
|
|
10
|
+
export const PRESENT_TOOL = 'present';
|
|
11
|
+
/** The path a `present` call or a page acknowledgement names, as the server compares it (a leading `./` is the same file). */
|
|
12
|
+
export const presentPath = (path) => path.replace(/^\.\//, '');
|
|
13
|
+
/**
|
|
14
|
+
* The `present` calls still waiting for the page (the server's view-open record, `@hachej/boring-agent/artifacts`). Mirrors
|
|
15
|
+
* the preview's pending-call scan (the feedback plug-in's `pendingBrowserTasks`): a call with no result, in a conversation that is running.
|
|
16
|
+
*/
|
|
17
|
+
export function pendingPresents(view) {
|
|
18
|
+
if (!view)
|
|
19
|
+
return [];
|
|
20
|
+
const live = view.docs?.['pi.live'];
|
|
21
|
+
if (!live?.run)
|
|
22
|
+
return [];
|
|
23
|
+
const calls = new Map();
|
|
24
|
+
for (const entry of view.entries)
|
|
25
|
+
for (const message of entry.model ?? []) {
|
|
26
|
+
const item = message;
|
|
27
|
+
if (item.role === 'assistant' && Array.isArray(item.content)) {
|
|
28
|
+
for (const part of item.content) {
|
|
29
|
+
const path = part.arguments?.['path'];
|
|
30
|
+
if (part.type === 'toolCall' && part.name === PRESENT_TOOL && typeof part.id === 'string' && typeof path === 'string') {
|
|
31
|
+
calls.set(part.id, { id: JSON.stringify([Number(entry.id), part.id]), callId: part.id, path: presentPath(path) });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (item.role === 'toolResult' && typeof item.toolCallId === 'string')
|
|
36
|
+
calls.delete(item.toolCallId);
|
|
37
|
+
}
|
|
38
|
+
return [...calls.values()];
|
|
39
|
+
}
|
|
40
|
+
const ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
|
|
41
|
+
const LANGUAGE = /^[A-Za-z0-9+#._-]{1,32}$/;
|
|
42
|
+
const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
43
|
+
const hasOnly = (value, keys) => Object.keys(value).every(key => keys.includes(key));
|
|
44
|
+
/** Strict shape check of an untrusted value. Returns a fresh copy or undefined. */
|
|
45
|
+
export function parseArtifact(value) {
|
|
46
|
+
if (!isObject(value) || !hasOnly(value, ['schema', 'version', 'id', 'title', 'type', 'mediaType', 'language', 'target', 'revision', 'ordinal']))
|
|
47
|
+
return undefined;
|
|
48
|
+
const { id, title, type, mediaType, language, target, revision, ordinal } = value;
|
|
49
|
+
if (value['schema'] !== 'boring.artifact' || value['version'] !== 1)
|
|
50
|
+
return undefined;
|
|
51
|
+
if (id !== undefined && (typeof id !== 'string' || !ID.test(id)))
|
|
52
|
+
return undefined;
|
|
53
|
+
if (typeof title !== 'string' || !title.trim() || title.length > 200)
|
|
54
|
+
return undefined;
|
|
55
|
+
if (typeof type !== 'string' || !ARTIFACT_TYPES.includes(type))
|
|
56
|
+
return undefined;
|
|
57
|
+
if (typeof mediaType !== 'string' || !/^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/i.test(mediaType))
|
|
58
|
+
return undefined;
|
|
59
|
+
if (language !== undefined && (typeof language !== 'string' || !LANGUAGE.test(language)))
|
|
60
|
+
return undefined;
|
|
61
|
+
if (typeof revision !== 'string' || !revision || revision.length > 256)
|
|
62
|
+
return undefined;
|
|
63
|
+
if (ordinal !== undefined && (typeof ordinal !== 'number' || !Number.isSafeInteger(ordinal) || ordinal < 1))
|
|
64
|
+
return undefined;
|
|
65
|
+
if (!isObject(target) || !hasOnly(target, ['resource', 'view']) || !isObject(target['resource']) || !hasOnly(target['resource'], ['providerId', 'path']))
|
|
66
|
+
return undefined;
|
|
67
|
+
const { providerId, path } = target['resource'];
|
|
68
|
+
if (typeof providerId !== 'string' || !providerId || typeof path !== 'string' || !path || path.length > 512)
|
|
69
|
+
return undefined;
|
|
70
|
+
const view = target['view'];
|
|
71
|
+
if (!isObject(view))
|
|
72
|
+
return undefined;
|
|
73
|
+
let parsedView;
|
|
74
|
+
if (view['kind'] === 'published' && hasOnly(view, ['kind']))
|
|
75
|
+
parsedView = { kind: 'published' };
|
|
76
|
+
else if (view['kind'] === 'working' && hasOnly(view, ['kind', 'viewId']) && typeof view['viewId'] === 'string' && view['viewId'])
|
|
77
|
+
parsedView = { kind: 'working', viewId: view['viewId'] };
|
|
78
|
+
else
|
|
79
|
+
return undefined;
|
|
80
|
+
return { schema: 'boring.artifact', version: 1, ...(id === undefined ? {} : { id }), title, type: type, mediaType, ...(language === undefined ? {} : { language: language }),
|
|
81
|
+
target: { resource: { providerId, path }, view: parsedView }, revision, ...(ordinal === undefined ? {} : { ordinal }) };
|
|
82
|
+
}
|
|
83
|
+
const textOf = (result) => result.content.map(part => part.type === 'text' ? part.text : '').join('');
|
|
84
|
+
/**
|
|
85
|
+
* The descriptor a successful tool result carries: the host's `detect` first, then the `artifact` key of a JSON text result.
|
|
86
|
+
* Failed results never produce one, and a host callback that throws or returns something malformed is ignored. Without
|
|
87
|
+
* the host's `detect`, only a result of `present` counts: any other tool whose text happens
|
|
88
|
+
* to be that JSON (an MCP tool, `cat x.json`) cannot forge a card. A host with renamed tools says so in `detect`.
|
|
89
|
+
*/
|
|
90
|
+
export function detectArtifact(call, result, detect) {
|
|
91
|
+
if (result.isError)
|
|
92
|
+
return undefined;
|
|
93
|
+
if (detect) {
|
|
94
|
+
try {
|
|
95
|
+
const found = parseArtifact(detect(call, result));
|
|
96
|
+
if (found)
|
|
97
|
+
return found;
|
|
98
|
+
}
|
|
99
|
+
catch { /* a faulty host callback shows no card */ }
|
|
100
|
+
}
|
|
101
|
+
if (call.name !== PRESENT_TOOL)
|
|
102
|
+
return undefined;
|
|
103
|
+
const text = textOf(result);
|
|
104
|
+
if (!text.includes('"boring.artifact"'))
|
|
105
|
+
return undefined;
|
|
106
|
+
try {
|
|
107
|
+
const value = JSON.parse(text);
|
|
108
|
+
return isObject(value) ? parseArtifact(value['artifact']) : undefined;
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/** The title a still-running `present` call announces in its streamed arguments: the file name. */
|
|
115
|
+
export function pendingTitle(call) {
|
|
116
|
+
const path = call.arguments['path'];
|
|
117
|
+
const title = typeof path === 'string' ? path.slice(path.lastIndexOf('/') + 1) : undefined;
|
|
118
|
+
return title?.trim() ? title.trim().slice(0, 200) : undefined;
|
|
119
|
+
}
|
|
120
|
+
/** What a still-running `present` call will show: the file path. */
|
|
121
|
+
export function pendingId(call) {
|
|
122
|
+
const path = call.arguments['path'];
|
|
123
|
+
return typeof path === 'string' && path ? path.replace(/^(\.\/)+/, '') : undefined;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Every artifact version a conversation's tool results produced, oldest first. Hosts use it to list the versions known
|
|
127
|
+
* from the conversation (a version switcher); the chat itself shows only the last version of an artifact per turn.
|
|
128
|
+
*/
|
|
129
|
+
export function collectArtifacts(view, detect) {
|
|
130
|
+
const calls = new Map(), found = [];
|
|
131
|
+
for (const entry of view?.entries ?? [])
|
|
132
|
+
for (const message of entry.model ?? []) {
|
|
133
|
+
if (message.role === 'assistant')
|
|
134
|
+
for (const part of message.content) {
|
|
135
|
+
if (part.type === 'toolCall')
|
|
136
|
+
calls.set(part.id, part);
|
|
137
|
+
}
|
|
138
|
+
else if (message.role === 'toolResult') {
|
|
139
|
+
const call = calls.get(message.toolCallId);
|
|
140
|
+
const descriptor = call && detectArtifact(call, message, detect);
|
|
141
|
+
if (descriptor)
|
|
142
|
+
found.push(descriptor);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return found;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* The page's side of the view-open acknowledgement. A `present` call is confirmed only by a viewer's mount report (`shown`) of the
|
|
149
|
+
* revision it names. A call is decided once; an answer the server denies (a revision the page showed before the agent wrote a new one)
|
|
150
|
+
* keeps the call waiting for the next mount report, and an answer is retried briefly while the server has not recorded the call yet.
|
|
151
|
+
*/
|
|
152
|
+
export function createPresentAcks(host) {
|
|
153
|
+
const decided = new Set();
|
|
154
|
+
/** Calls waiting for their file's mount report: answer ID to path. */
|
|
155
|
+
const mounting = new Map();
|
|
156
|
+
const wait = host.wait ?? (ms => new Promise(resolve => setTimeout(resolve, ms)));
|
|
157
|
+
const now = host.now ?? (() => Date.now());
|
|
158
|
+
/** The last mount report: the file on screen and its revision. */
|
|
159
|
+
let lastShown;
|
|
160
|
+
/** Sends one answer; returns the server's outcome kind, or undefined when it was not sent. Retries while the record is not written yet. */
|
|
161
|
+
async function send(id, body) {
|
|
162
|
+
const until = now() + 3000;
|
|
163
|
+
for (;;) {
|
|
164
|
+
try {
|
|
165
|
+
const sent = host.answer(id, JSON.stringify(body));
|
|
166
|
+
if (sent === undefined)
|
|
167
|
+
return undefined;
|
|
168
|
+
const outcome = await sent;
|
|
169
|
+
if (outcome.kind !== 'unknown-question' || now() > until)
|
|
170
|
+
return outcome.kind;
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return undefined; /* the call ends at its deadline as unconfirmed */
|
|
174
|
+
}
|
|
175
|
+
await wait(20);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
/** Confirms a shown revision. The call keeps its entry while the answer is in flight, so a mount during the flight still confirms it. */
|
|
179
|
+
async function confirm(id, path, revision) {
|
|
180
|
+
const kind = await send(id, { kind: 'opened', instanceId: host.instanceId, path, revision });
|
|
181
|
+
// Only an accepted or conflicting answer ends the call. A denial (another revision), a failed request or an unrecorded call keeps it waiting for a later mount or a live-call check.
|
|
182
|
+
if (kind === 'answered' || kind === 'conflict')
|
|
183
|
+
mounting.delete(id);
|
|
184
|
+
}
|
|
185
|
+
/** Refuses a waiting call; like `confirm`, only an accepted or conflicting answer ends it. */
|
|
186
|
+
async function refuse(id, reason) {
|
|
187
|
+
const kind = await send(id, { kind: 'refused', reason });
|
|
188
|
+
if (kind === 'answered' || kind === 'conflict')
|
|
189
|
+
mounting.delete(id);
|
|
190
|
+
}
|
|
191
|
+
return {
|
|
192
|
+
/** A viewer mounted and shows `path` at `revision`: confirms the calls waiting for that file. */
|
|
193
|
+
shown(view) {
|
|
194
|
+
for (const [id, path] of mounting)
|
|
195
|
+
if (path === view.path)
|
|
196
|
+
void confirm(id, path, view.revision);
|
|
197
|
+
lastShown = view;
|
|
198
|
+
},
|
|
199
|
+
/** A viewer could not show `path` (a kind's `onFailed`, or its renderer threw): refuses the calls waiting for that file with `reason`. */
|
|
200
|
+
failed(view) {
|
|
201
|
+
if (lastShown?.path === view.path)
|
|
202
|
+
lastShown = undefined;
|
|
203
|
+
const reason = view.reason.trim().slice(0, 200) || 'the view failed';
|
|
204
|
+
for (const [id, path] of mounting)
|
|
205
|
+
if (path === view.path)
|
|
206
|
+
void refuse(id, reason);
|
|
207
|
+
},
|
|
208
|
+
/** A viewer stopped showing `path` at `revision` (it unmounted, or a custom viewer or the canvas took the file): it no longer confirms. */
|
|
209
|
+
hidden(view) {
|
|
210
|
+
if (lastShown?.path === view.path && lastShown.revision === view.revision)
|
|
211
|
+
lastShown = undefined;
|
|
212
|
+
},
|
|
213
|
+
/** The presents the server waits for: each is decided once, in order. Decided calls and waits that are no longer live are forgotten. */
|
|
214
|
+
consider(calls, context) {
|
|
215
|
+
const live = new Set(calls.map(call => call.id));
|
|
216
|
+
for (const id of decided)
|
|
217
|
+
if (!live.has(id))
|
|
218
|
+
decided.delete(id);
|
|
219
|
+
for (const id of mounting.keys())
|
|
220
|
+
if (!live.has(id))
|
|
221
|
+
mounting.delete(id);
|
|
222
|
+
for (const call of calls) {
|
|
223
|
+
if (decided.has(call.id))
|
|
224
|
+
continue;
|
|
225
|
+
decided.add(call.id);
|
|
226
|
+
if (context.closedThisTurn)
|
|
227
|
+
void send(call.id, { kind: 'refused', reason: 'closed by user' });
|
|
228
|
+
else if (context.unsaved)
|
|
229
|
+
void send(call.id, { kind: 'refused', reason: 'unsaved changes' });
|
|
230
|
+
else if (context.narrow)
|
|
231
|
+
void send(call.id, { kind: 'refused', reason: 'narrow screen' });
|
|
232
|
+
else {
|
|
233
|
+
mounting.set(call.id, call.path);
|
|
234
|
+
if (lastShown?.path === call.path && context.showing(call.path))
|
|
235
|
+
void confirm(call.id, call.path, lastShown.revision);
|
|
236
|
+
else if (context.autoOpen && !host.open(call.path)) {
|
|
237
|
+
mounting.delete(call.id);
|
|
238
|
+
void send(call.id, { kind: 'refused', reason: 'unsaved changes' });
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
},
|
|
243
|
+
/** Calls still waiting for a mount (test and debugging view). */
|
|
244
|
+
waiting: () => [...mounting.keys()],
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* The artifact a tool call made, as the chat places it: a finished call that carries a descriptor (its own, or one the host's `detect`
|
|
249
|
+
* finds) is ready; a `present` call still running while its conversation runs (`running`) is presenting. Any other call has none.
|
|
250
|
+
*/
|
|
251
|
+
export function recogniseArtifact(call, result, running, detect) {
|
|
252
|
+
if (result) {
|
|
253
|
+
const artifact = detectArtifact(call, result, detect);
|
|
254
|
+
if (!artifact)
|
|
255
|
+
return undefined;
|
|
256
|
+
// A tool whose own result carries the descriptor is replaced by its card; a host-detected card is shown in addition to the step.
|
|
257
|
+
return { state: 'ready', id: artifactKey(artifact), title: artifact.title, path: artifact.target.resource.path, own: detectArtifact(call, result) !== undefined, descriptor: artifact };
|
|
258
|
+
}
|
|
259
|
+
if (running && call.name === PRESENT_TOOL)
|
|
260
|
+
return { state: 'presenting', id: pendingId(call), title: pendingTitle(call), path: undefined, own: true, descriptor: undefined };
|
|
261
|
+
return undefined;
|
|
262
|
+
}
|
|
263
|
+
/** Every version, newest conversation result first. Ordinals can tie across retries and never determine recency. */
|
|
264
|
+
export function artifactVersions(view, detect) {
|
|
265
|
+
return collectArtifacts(view, detect).reverse();
|
|
266
|
+
}
|
|
267
|
+
/** Whether this descriptor is the newest known revision of its artifact, never an ordinal comparison. */
|
|
268
|
+
export function follows(descriptor, versions) {
|
|
269
|
+
return descriptor.revision === versions.find(version => artifactKey(version) === artifactKey(descriptor))?.revision;
|
|
270
|
+
}
|
|
271
|
+
/** The number of user messages in the current native view, used to retain a manual close until the next turn. */
|
|
272
|
+
export function artifactTurn(view) {
|
|
273
|
+
return (view?.entries ?? []).reduce((count, entry) => count + (entry.model ?? []).filter(message => message.role === 'user').length, 0);
|
|
274
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { voiceRefusalLabel } from './chat-text.js';
|
|
2
|
+
export type DictationPhase = 'idle' | 'starting' | 'recording' | 'transcribing';
|
|
3
|
+
export interface Dictation {
|
|
4
|
+
readonly phase: DictationPhase;
|
|
5
|
+
readonly seconds: number;
|
|
6
|
+
readonly levels: readonly number[];
|
|
7
|
+
readonly paused: boolean;
|
|
8
|
+
readonly pause: (() => void) | undefined;
|
|
9
|
+
readonly resume: (() => void) | undefined;
|
|
10
|
+
readonly problem: string | undefined;
|
|
11
|
+
readonly start: () => void;
|
|
12
|
+
readonly finish: () => void;
|
|
13
|
+
readonly cancel: () => void;
|
|
14
|
+
}
|
|
15
|
+
export type DictationLabels = Readonly<Record<typeof voiceRefusalLabel[keyof typeof voiceRefusalLabel] | 'voiceNothingHeard' | 'voiceTranscribeFailed', string>>;
|
|
16
|
+
export interface DictationOptions {
|
|
17
|
+
readonly transcribe: ((audio: Blob, signal: AbortSignal) => Promise<string>) | undefined;
|
|
18
|
+
readonly onText: (text: string, signal: AbortSignal) => void | Promise<void>;
|
|
19
|
+
readonly labels: DictationLabels;
|
|
20
|
+
/** Capture stops at this limit; transcription still waits for finish (default five minutes). */
|
|
21
|
+
readonly maxMs?: number | undefined;
|
|
22
|
+
/** Changing the target drops pending permission/transcription instead of inserting into the new subject. */
|
|
23
|
+
readonly target?: unknown;
|
|
24
|
+
}
|
|
25
|
+
/** Gesture-started dictation over the shared attempt lifecycle; host callbacks and labels are captured per attempt. */
|
|
26
|
+
export declare function useDictation(options: DictationOptions): Dictation;
|
|
27
|
+
//# sourceMappingURL=chat-dictation.d.ts.map
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
|
|
3
|
+
import { voiceRefusalLabel } from './chat-text.js';
|
|
4
|
+
import { useVoiceAttempt } from './voice-attempt.js';
|
|
5
|
+
/** Gesture-started dictation over the shared attempt lifecycle; host callbacks and labels are captured per attempt. */
|
|
6
|
+
export function useDictation(options) {
|
|
7
|
+
const latest = useRef(options);
|
|
8
|
+
useLayoutEffect(() => { latest.current = options; });
|
|
9
|
+
const [problem, setProblem] = useState();
|
|
10
|
+
const core = useVoiceAttempt({
|
|
11
|
+
valid: item => latest.current.target === item.options.target,
|
|
12
|
+
refused: result => setProblem(latest.current.labels[voiceRefusalLabel[result.code ?? 'failed']]),
|
|
13
|
+
});
|
|
14
|
+
const actions = useRef(core);
|
|
15
|
+
useLayoutEffect(() => { actions.current = core; });
|
|
16
|
+
const cancel = useCallback(() => { actions.current.clear(); setProblem(undefined); }, []);
|
|
17
|
+
// A committed subject change releases pending microphone permission and aborts transcription.
|
|
18
|
+
useLayoutEffect(() => cancel(), [options.target, cancel]);
|
|
19
|
+
const start = useCallback(() => {
|
|
20
|
+
const selected = latest.current;
|
|
21
|
+
if (!selected.transcribe || actions.current.current().length)
|
|
22
|
+
return;
|
|
23
|
+
setProblem(undefined);
|
|
24
|
+
actions.current.start({ options: selected }, selected.maxMs);
|
|
25
|
+
}, []);
|
|
26
|
+
const finish = useCallback(() => {
|
|
27
|
+
const api = actions.current, item = api.current()[0];
|
|
28
|
+
if (!item || !api.valid(item))
|
|
29
|
+
return;
|
|
30
|
+
if (item.phase === 'starting') {
|
|
31
|
+
cancel();
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (item.phase !== 'recording')
|
|
35
|
+
return;
|
|
36
|
+
const selected = item.options;
|
|
37
|
+
void (async () => {
|
|
38
|
+
try {
|
|
39
|
+
const recording = await api.stop(item);
|
|
40
|
+
if (!api.valid(item))
|
|
41
|
+
return;
|
|
42
|
+
if (!recording) {
|
|
43
|
+
setProblem(selected.labels.voiceNothingHeard);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const text = (await selected.transcribe(recording.audio, item.abort.signal)).trim();
|
|
47
|
+
if (!api.valid(item))
|
|
48
|
+
return;
|
|
49
|
+
if (!text) {
|
|
50
|
+
setProblem(selected.labels.voiceNothingHeard);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
await selected.onText(text, item.abort.signal);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
if (api.valid(item))
|
|
57
|
+
setProblem(selected.labels.voiceTranscribeFailed);
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
if (api.valid(item))
|
|
61
|
+
api.remove(item);
|
|
62
|
+
}
|
|
63
|
+
})();
|
|
64
|
+
}, [cancel]);
|
|
65
|
+
const item = core.attempts[0];
|
|
66
|
+
return { phase: item?.phase === 'processing' ? 'transcribing' : item?.phase ?? 'idle', seconds: item?.seconds ?? 0,
|
|
67
|
+
levels: item?.levels ?? [], paused: item?.paused ?? false,
|
|
68
|
+
pause: item?.phase === 'recording' && item.canPause ? () => core.pause(item) : undefined,
|
|
69
|
+
resume: item?.phase === 'recording' && item.canPause ? () => core.resume(item) : undefined,
|
|
70
|
+
problem, start, finish, cancel };
|
|
71
|
+
}
|