@protoyard/ai-ui 0.0.0-stage → 1.1.0
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/LICENSE +21 -0
- package/README.md +152 -2
- package/dist/MessageList.svelte +113 -0
- package/dist/MessageList.svelte.d.ts +13 -0
- package/dist/approvals/AuditTrail.svelte +72 -0
- package/dist/approvals/AuditTrail.svelte.d.ts +12 -0
- package/dist/approvals/AutonomyLevelPicker.svelte +100 -0
- package/dist/approvals/AutonomyLevelPicker.svelte.d.ts +13 -0
- package/dist/approvals/ChatApprovalCard.svelte +118 -0
- package/dist/approvals/ChatApprovalCard.svelte.d.ts +12 -0
- package/dist/approvals/SuggestionCard.svelte +248 -0
- package/dist/approvals/SuggestionCard.svelte.d.ts +18 -0
- package/dist/approvals/index.d.ts +9 -0
- package/dist/approvals/index.js +9 -0
- package/dist/approvals/labels.d.ts +12 -0
- package/dist/approvals/labels.js +53 -0
- package/dist/components/AssistantComposer.svelte +172 -0
- package/dist/components/AssistantComposer.svelte.d.ts +21 -0
- package/dist/components/AssistantWorkspace.svelte +335 -0
- package/dist/components/AssistantWorkspace.svelte.d.ts +30 -0
- package/dist/components/CanvasPanel.svelte +62 -0
- package/dist/components/CanvasPanel.svelte.d.ts +9 -0
- package/dist/components/ChartAnswer.svelte +121 -0
- package/dist/components/ChartAnswer.svelte.d.ts +6 -0
- package/dist/components/RichAnswer.svelte +129 -0
- package/dist/components/RichAnswer.svelte.d.ts +7 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +5 -0
- package/dist/messages/ChatMessageBlocks.svelte +73 -0
- package/dist/messages/ChatMessageBlocks.svelte.d.ts +13 -0
- package/dist/messages/MessageAttachments.svelte +61 -0
- package/dist/messages/MessageAttachments.svelte.d.ts +8 -0
- package/dist/messages/MessageButtons.svelte +162 -0
- package/dist/messages/MessageButtons.svelte.d.ts +11 -0
- package/dist/messages/MessageForm.svelte +224 -0
- package/dist/messages/MessageForm.svelte.d.ts +11 -0
- package/dist/messages/MessageLinkPreview.svelte +42 -0
- package/dist/messages/MessageLinkPreview.svelte.d.ts +7 -0
- package/dist/messages/MessageSelect.svelte +118 -0
- package/dist/messages/MessageSelect.svelte.d.ts +11 -0
- package/dist/messages/MessageTable.svelte +48 -0
- package/dist/messages/MessageTable.svelte.d.ts +7 -0
- package/dist/messages/format.d.ts +15 -0
- package/dist/messages/format.js +46 -0
- package/dist/messages/index.d.ts +10 -0
- package/dist/messages/index.js +9 -0
- package/dist/messages/types.d.ts +33 -0
- package/dist/messages/types.js +11 -0
- package/dist/scroll-anchor.d.ts +13 -0
- package/dist/scroll-anchor.js +35 -0
- package/dist/styles.css +5 -0
- package/dist/styles.css.d.ts +1 -0
- package/dist/types.d.ts +25 -0
- package/dist/types.js +1 -0
- package/dist/voice/VoiceMode.svelte +230 -0
- package/dist/voice/VoiceMode.svelte.d.ts +13 -0
- package/dist/voice/index.d.ts +1 -0
- package/dist/voice/index.js +1 -0
- package/dist/widget/AssistantWidget.svelte +167 -0
- package/dist/widget/AssistantWidget.svelte.d.ts +5 -0
- package/dist/widget/AssistantWidgetPanel.svelte +11 -0
- package/dist/widget/AssistantWidgetPanel.svelte.d.ts +4 -0
- package/dist/widget/index.d.ts +2 -0
- package/dist/widget/index.js +1 -0
- package/dist/widget/types.d.ts +7 -0
- package/dist/widget/types.js +1 -0
- package/package.json +97 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dailybot, Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,153 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @protoyard/ai-ui
|
|
2
|
+
|
|
3
|
+
Svelte 5 components for prototypes that simulate AI assistants. The package renders conversations and interaction controls; it does not call a model provider, create database rows, or implement sharing itself. Mutations are callback props so the prototype can route them through its own services. Model labels and UI copy keep the simulated boundary visible.
|
|
4
|
+
|
|
5
|
+
**Platform guide:** [docs/AI_KITS.md](../../docs/AI_KITS.md) (workspace, virtual threads, widget launcher→sidebar→fullscreen, voice panel). **Measured bundles and long threads:** [docs/PERFORMANCE.md → AI kits](../../docs/PERFORMANCE.md#ai-kits).
|
|
6
|
+
|
|
7
|
+
## Install in a workspace prototype
|
|
8
|
+
|
|
9
|
+
Add `"@protoyard/ai-ui": "workspace:*"` to the prototype dependencies. Import the styles after Tailwind and the theme/UI styles:
|
|
10
|
+
|
|
11
|
+
```css
|
|
12
|
+
@import 'tailwindcss';
|
|
13
|
+
@import '@protoyard/themes/base.css';
|
|
14
|
+
@import '@protoyard/ui/styles.css';
|
|
15
|
+
@import '@protoyard/ai-ui/styles.css';
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Assistant workspace
|
|
19
|
+
|
|
20
|
+
`AssistantWorkspace` accepts conversation summaries and the active branch's messages. Send, stop, rename, pin, folder, feedback, branch, regenerate, edit and share operations are callbacks. The component does not persist changes. `onShare` must return a URL created by the consuming prototype; the UI then offers a copy action.
|
|
21
|
+
|
|
22
|
+
The workspace renders its conversation through `MessageList`, which is `@texuli/chat`'s `MessageList`: it virtualizes long threads, keeps the visible message anchored as streamed content changes and offers "Jump to latest". ai-ui restores the first message in view and its offset from session storage when the same conversation is reopened in the tab (and reopens at the latest message when the reader left it at the bottom). A reopened position is held until it settles, even when late content (a chart answer, an image) resizes the thread. Editing a message calls `onEditMessage` only when its text changed: "Save and resend" with the same text closes the editor (`@texuli/chat`'s inline edit), and Regenerate is the way to ask again for the last reply. The saved position is presentation state; conversation messages remain owned by the prototype.
|
|
23
|
+
|
|
24
|
+
```svelte
|
|
25
|
+
<script lang="ts">
|
|
26
|
+
import { AssistantWorkspace, type AssistantConversation, type AssistantMessage, type AssistantSendInput } from '@protoyard/ai-ui';
|
|
27
|
+
|
|
28
|
+
let conversations: AssistantConversation[] = $state([]);
|
|
29
|
+
let messages: AssistantMessage[] = $state([]);
|
|
30
|
+
let streaming = $state(false);
|
|
31
|
+
|
|
32
|
+
async function send(conversationId: string, input: AssistantSendInput) {
|
|
33
|
+
// A real prototype calls its own service and persists its own attachment metadata.
|
|
34
|
+
await assistantService.send(conversationId, input);
|
|
35
|
+
}
|
|
36
|
+
</script>
|
|
37
|
+
|
|
38
|
+
<AssistantWorkspace
|
|
39
|
+
{conversations}
|
|
40
|
+
{messages}
|
|
41
|
+
{streaming}
|
|
42
|
+
onSend={send}
|
|
43
|
+
onStop={() => assistantService.stop()}
|
|
44
|
+
onRenameConversation={(id, title) => assistantService.rename(id, title)}
|
|
45
|
+
onTogglePin={(id) => assistantService.togglePin(id)}
|
|
46
|
+
/>
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The composer supports Enter to send, Shift+Enter for a newline, Escape to stop a stream, model selection, file selection, slash-command suggestions, and @mention suggestions. The parent owns streaming state and should update `messages` as deterministic chunks arrive. `RichAnswer` renders the plain-data AST from `parseMarkdown` using text nodes and Svelte elements; it never uses `{@html}`. `CanvasPanel` displays code or editable document text in a responsive side panel.
|
|
50
|
+
|
|
51
|
+
**Scrollable code regions.** Overflowing `<pre>` blocks in `RichAnswer` (fenced code), `ChartAnswer` (invalid chart data), and `CanvasPanel` (code kind) use `tabindex="0"` and an `aria-label`, matching the ui-kit `CodeBlock` pattern, so keyboard users can reach them (axe `scrollable-region-focusable` / WCAG 2.1.1). Covered by `packages/ai-ui/tests/scroll-regions.test.ts`.
|
|
52
|
+
|
|
53
|
+
**Chart answers.** `RichAnswer` parses with `{ charts: true }`.
|
|
54
|
+
- A ```` ```chart ```` fence (see `@protoyard/ai/chart`) shows "Preparing
|
|
55
|
+
chart…" while it streams.
|
|
56
|
+
- When the fence closes, `ChartAnswer` loads on demand. It validates the spec,
|
|
57
|
+
shows a table of the values at once, then the matching `@protoyard/charts-ui`
|
|
58
|
+
chart (bar, line, area, donut, pie or funnel), which brings keyboard
|
|
59
|
+
navigation, tooltips and its own table toggle.
|
|
60
|
+
- An invalid spec shows a note with the reason and the raw data as text.
|
|
61
|
+
- The charts kit and the zod schema are lazy chunks; only the small parser and
|
|
62
|
+
renderer branch ship with `RichAnswer`. In nova-assistant-demo initial JS
|
|
63
|
+
went from 169.40 KB to 169.93 KB gzip.
|
|
64
|
+
- `styles.css` registers the charts-ui class sources, so prototypes need no
|
|
65
|
+
extra import.
|
|
66
|
+
|
|
67
|
+
Conversation folder controls require `folders`, `onCreateFolder`, and `onMoveToFolder` callbacks. Features without a callback are disabled. The caller supplies `onRegenerate`, `onEditMessage`, `onBranchChange`, `onFeedback`, and `onShare` only when those operations are implemented.
|
|
68
|
+
|
|
69
|
+
All components use Svelte 5 runes and theme token utility classes. Import `styles.css` to register this package's class sources with Tailwind.
|
|
70
|
+
|
|
71
|
+
### Built on Texuli
|
|
72
|
+
|
|
73
|
+
The list is `@texuli/chat`'s `MessageList` (per-message actions snippet, inline edit, stream follow) and the composer is its `Composer` (stop and Escape while streaming, keydown hook, attachments and actions snippets), and every control is a `texuli` component: `Button`/`IconButton`, `CopyButton`, `Textarea`/`Input` (the attachment picker is a file `Input`), `Select`, `Checkbox`/`RadioGroup`, `Field`, `Card`, `Badge`/`Tag`, `Table`, `Code`, `Kbd`, `DropdownMenu` and `EmptyState`. Lucide glyphs render through `LucideIcon` from `texuli/icons/lucide` (or `Icon` for Texuli's built-in names). ai-ui keeps what is assistant-specific: slash and mention suggestions, the model choice, feedback, branches and regenerate, and the reading position per conversation; the public props are unchanged. Rich answers are still parsed by `@protoyard/ai` (`parseMarkdown`) and render as text, never HTML. Components accept data-only messages from `@protoyard/ai`; no credentials, provider SDKs, generated models or external network calls are included.
|
|
74
|
+
|
|
75
|
+
## Simulated voice mode
|
|
76
|
+
|
|
77
|
+
Load the voice panel from its lazy subpath when the user opens voice mode:
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
const { VoiceMode } = await import('@protoyard/ai-ui/voice');
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Pass the injected `AiClock`, scripted transcript options, the latest simulated assistant reply and an `onTranscript` handler. Captions are always visible. Browser `speechSynthesis` speaks replies locally when available; otherwise output stays muted and the reply caption remains visible. The panel does not request a microphone by default. A consumer may opt into showing the local mic level meter; enabling it asks for microphone permission and reads amplitude only with Web Audio, without recording, storing or uploading audio. Stop the meter to release its stream; component teardown also stops it.
|
|
84
|
+
|
|
85
|
+
## Widget modes
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
const { AssistantWidget } = await import('@protoyard/ai-ui/widget');
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
`AssistantWidget` hosts the same workspace callbacks inside **launcher → sidebar → fullscreen** chrome. The panel module loads on first open. Mode is stored per `prototypeId` in `localStorage` when the browser allows it. Escape collapses one level. No model or network call is introduced by the widget shell.
|
|
92
|
+
|
|
93
|
+
## Package output
|
|
94
|
+
|
|
95
|
+
The repository keeps source exports for workspace development. `pnpm build:packages --filter svelte` creates the compiled ESM, declarations and Svelte components under `dist/` that the packed manifest exposes.
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
## Suggestion approvals (lazy)
|
|
99
|
+
|
|
100
|
+
Import from `@protoyard/ai-ui/approvals` so the approval surface stays out of the
|
|
101
|
+
assistant boot chunk. Pair with `@protoyard/ai/approvals` pure helpers.
|
|
102
|
+
|
|
103
|
+
| Export | Purpose |
|
|
104
|
+
| --- | --- |
|
|
105
|
+
| `SuggestionCard` | Waiting or decided suggestion with evidence and decision buttons |
|
|
106
|
+
| `ChatApprovalCard` | Compact chat-row approval |
|
|
107
|
+
| `AutonomyLevelPicker` | Per-area autonomy radios (`setLevel` via `onChange`) |
|
|
108
|
+
| `AuditTrail` | Newest-first audit table |
|
|
109
|
+
|
|
110
|
+
Callbacks are required for every decision. Show pending state is built in.
|
|
111
|
+
Label the UI as simulated — these controls are prototype UX, not security.
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
import {
|
|
115
|
+
AuditTrail,
|
|
116
|
+
AutonomyLevelPicker,
|
|
117
|
+
ChatApprovalCard,
|
|
118
|
+
SuggestionCard,
|
|
119
|
+
} from '@protoyard/ai-ui/approvals';
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Bundle note: the approvals subpath is optional; assistant workspace entry graphs
|
|
123
|
+
do not import it until a screen does.
|
|
124
|
+
|
|
125
|
+
## Chat message blocks (lazy)
|
|
126
|
+
|
|
127
|
+
Import `ChatMessageBlocks` from `@protoyard/ai-ui/messages` to render
|
|
128
|
+
`protoyard.message@1` messages from `@protoyard/ai/messages`. It renders text
|
|
129
|
+
(plain or safe markdown), buttons with inline confirmations, selects, forms,
|
|
130
|
+
tables, link previews, attachments and the fallback for unknown blocks.
|
|
131
|
+
|
|
132
|
+
```svelte
|
|
133
|
+
<script lang="ts">
|
|
134
|
+
import { ChatMessageBlocks, type MessageActionEvent } from '@protoyard/ai-ui/messages';
|
|
135
|
+
|
|
136
|
+
let { message, services } = $props();
|
|
137
|
+
const onAction = (event: MessageActionEvent) => services.chat.respond(event);
|
|
138
|
+
</script>
|
|
139
|
+
|
|
140
|
+
<ChatMessageBlocks {message} {onAction} onOpenAttachment={(file) => services.files.open(file.id)} />
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
- **Validation:** input is always validated with `parseChatMessage`. An invalid
|
|
144
|
+
message shows a short note and none of its content.
|
|
145
|
+
- **Actions:** buttons, selects and forms report one typed `MessageActionEvent`
|
|
146
|
+
(`kind: "button" | "select" | "form"`) through `onAction`. The returned
|
|
147
|
+
promise drives pending, error and done feedback. Without `onAction` the
|
|
148
|
+
controls are disabled with a note.
|
|
149
|
+
- **No data writes:** the component never writes data. Route every action
|
|
150
|
+
through a service (`session.assert` → zod → `environment.run`).
|
|
151
|
+
- **Size:** about 13.6 KB gzip over `RichAnswer` (zod external), and only for
|
|
152
|
+
screens that import the subpath.
|
|
2
153
|
|
|
3
|
-
This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { ArrowLeft, ArrowRight, ChevronUp, RefreshCw, ThumbsDown, ThumbsUp } from '@lucide/svelte';
|
|
3
|
+
import { type ChatMessage, MessageList as TexuliMessageList } from '@texuli/chat';
|
|
4
|
+
import { Badge } from 'texuli/badge';
|
|
5
|
+
import { Button, IconButton } from 'texuli/button';
|
|
6
|
+
import { LucideIcon } from 'texuli/icons/lucide';
|
|
7
|
+
import RichAnswer from './components/RichAnswer.svelte';
|
|
8
|
+
import { loadReadingAnchor, readingAnchorKey, saveReadingAnchor } from './scroll-anchor.js';
|
|
9
|
+
import type { AssistantMessage, FeedbackRating } from './types.js';
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
messages: readonly AssistantMessage[];
|
|
13
|
+
conversationId: string;
|
|
14
|
+
streaming?: boolean;
|
|
15
|
+
onRegenerate?: () => void;
|
|
16
|
+
onEditMessage?: (messageId: string, content: string) => void;
|
|
17
|
+
onBranchChange?: (messageId: string, direction: -1 | 1) => void;
|
|
18
|
+
onFeedback?: (messageId: string, rating: FeedbackRating) => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let {
|
|
22
|
+
messages,
|
|
23
|
+
conversationId,
|
|
24
|
+
streaming = false,
|
|
25
|
+
onRegenerate,
|
|
26
|
+
onEditMessage,
|
|
27
|
+
onBranchChange,
|
|
28
|
+
onFeedback,
|
|
29
|
+
}: Props = $props();
|
|
30
|
+
|
|
31
|
+
const USER = { id: 'user', name: 'You' };
|
|
32
|
+
const ASSISTANT = { id: 'assistant', name: 'Assistant' };
|
|
33
|
+
|
|
34
|
+
const lastAssistantId = $derived(
|
|
35
|
+
[...messages].reverse().find((message) => message.role === 'assistant')?.id
|
|
36
|
+
);
|
|
37
|
+
const byId = $derived(new Map(messages.map((message) => [message.id, message])));
|
|
38
|
+
const chatMessages = $derived(
|
|
39
|
+
messages.map(
|
|
40
|
+
(message): ChatMessage => ({
|
|
41
|
+
id: message.id,
|
|
42
|
+
author: message.role === 'user' ? USER : ASSISTANT,
|
|
43
|
+
body: message.content,
|
|
44
|
+
at: message.createdAt,
|
|
45
|
+
streaming: streaming && message.id === lastAssistantId,
|
|
46
|
+
})
|
|
47
|
+
)
|
|
48
|
+
);
|
|
49
|
+
const storageKey = $derived(readingAnchorKey(conversationId));
|
|
50
|
+
</script>
|
|
51
|
+
|
|
52
|
+
<!--
|
|
53
|
+
@texuli/chat's MessageList: virtual window, stream follow, jump to latest,
|
|
54
|
+
per-message actions, inline edit, full-width day separators (G70) and the
|
|
55
|
+
reading position (G71). ai-ui keeps its names ("Conversation messages",
|
|
56
|
+
"Your message", "Assistant message") that hosts and E2E address, the rich
|
|
57
|
+
answer body, and where each conversation's position is stored: the list is
|
|
58
|
+
keyed by conversation, so switching remounts it with that conversation's
|
|
59
|
+
saved anchor, and Texuli restores it.
|
|
60
|
+
-->
|
|
61
|
+
<div class="@container flex min-h-0 flex-1 flex-col overflow-hidden">
|
|
62
|
+
{#key conversationId}
|
|
63
|
+
<TexuliMessageList
|
|
64
|
+
messages={chatMessages}
|
|
65
|
+
currentUserId={USER.id}
|
|
66
|
+
label="Conversation messages"
|
|
67
|
+
emptyText="No messages yet. Ask the simulated assistant anything."
|
|
68
|
+
labels={{ editInput: 'Edit your message', saveEdit: 'Save and resend' }}
|
|
69
|
+
onEdit={onEditMessage ? (message, body) => onEditMessage(message.id, body) : undefined}
|
|
70
|
+
anchor={loadReadingAnchor(storageKey)}
|
|
71
|
+
onAnchorChange={(next) => saveReadingAnchor(storageKey, next)}
|
|
72
|
+
class="px-4 py-5 @xl:px-8"
|
|
73
|
+
>
|
|
74
|
+
{#snippet body(chat)}
|
|
75
|
+
{@const message = byId.get(chat.id)}
|
|
76
|
+
{#if message?.role === 'assistant'}
|
|
77
|
+
<article class="min-w-0 whitespace-normal" aria-label="Assistant message" data-message-role="assistant" data-py-message-id={chat.id}>
|
|
78
|
+
<Badge variant="outline" label="Simulated" class="mb-1" />
|
|
79
|
+
<RichAnswer content={chat.body} />
|
|
80
|
+
{#if chat.streaming}<p class="mt-2 text-xs text-muted-foreground">Writing…</p>{/if}
|
|
81
|
+
</article>
|
|
82
|
+
{:else}
|
|
83
|
+
<article class="min-w-0" aria-label="Your message" data-message-role="user" data-py-message-id={chat.id}>{chat.body}</article>
|
|
84
|
+
{/if}
|
|
85
|
+
{/snippet}
|
|
86
|
+
{#snippet actions(chat, controls)}
|
|
87
|
+
{@const message = byId.get(chat.id)}
|
|
88
|
+
{#if message?.role === 'user'}
|
|
89
|
+
{#if controls.canEdit}
|
|
90
|
+
<Button variant="ghost" size="sm" class="text-xs" aria-label="Edit your message" onclick={controls.edit}><LucideIcon icon={ChevronUp} size="xs" /> Edit</Button>
|
|
91
|
+
{/if}
|
|
92
|
+
{:else if message}
|
|
93
|
+
<div class="flex flex-wrap items-center gap-1 text-xs text-muted-foreground">
|
|
94
|
+
{#if onFeedback}
|
|
95
|
+
<Button variant="ghost" size="sm" class={message.feedback === 'up' ? 'text-primary' : ''} aria-label="Helpful response" aria-pressed={message.feedback === 'up'} onclick={() => onFeedback?.(message.id, 'up')}><LucideIcon icon={ThumbsUp} size="xs" />Helpful</Button>
|
|
96
|
+
<Button variant="ghost" size="sm" class={message.feedback === 'down' ? 'text-primary' : ''} aria-label="Not helpful response" aria-pressed={message.feedback === 'down'} onclick={() => onFeedback?.(message.id, 'down')}><LucideIcon icon={ThumbsDown} size="xs" />Not helpful</Button>
|
|
97
|
+
{/if}
|
|
98
|
+
{#if message.branchCount && message.branchCount > 1}
|
|
99
|
+
<span class="ml-1 inline-flex items-center gap-1">
|
|
100
|
+
<IconButton variant="ghost" size="sm" label="Previous response version" disabled={message.branchIndex === 0 || !onBranchChange} onclick={() => onBranchChange?.(message.id, -1)}><LucideIcon icon={ArrowLeft} size="xs" /></IconButton>
|
|
101
|
+
<span>{(message.branchIndex ?? 0) + 1} / {message.branchCount}</span>
|
|
102
|
+
<IconButton variant="ghost" size="sm" label="Next response version" disabled={(message.branchIndex ?? 0) >= message.branchCount - 1 || !onBranchChange} onclick={() => onBranchChange?.(message.id, 1)}><LucideIcon icon={ArrowRight} size="xs" /></IconButton>
|
|
103
|
+
</span>
|
|
104
|
+
{/if}
|
|
105
|
+
{#if lastAssistantId === message.id && onRegenerate}
|
|
106
|
+
<Button variant="ghost" size="sm" disabled={streaming} aria-label="Regenerate response" onclick={onRegenerate}><LucideIcon icon={RefreshCw} size="xs" /> Regenerate</Button>
|
|
107
|
+
{/if}
|
|
108
|
+
</div>
|
|
109
|
+
{/if}
|
|
110
|
+
{/snippet}
|
|
111
|
+
</TexuliMessageList>
|
|
112
|
+
{/key}
|
|
113
|
+
</div>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { AssistantMessage, FeedbackRating } from './types.ts';
|
|
2
|
+
interface Props {
|
|
3
|
+
messages: readonly AssistantMessage[];
|
|
4
|
+
conversationId: string;
|
|
5
|
+
streaming?: boolean;
|
|
6
|
+
onRegenerate?: () => void;
|
|
7
|
+
onEditMessage?: (messageId: string, content: string) => void;
|
|
8
|
+
onBranchChange?: (messageId: string, direction: -1 | 1) => void;
|
|
9
|
+
onFeedback?: (messageId: string, rating: FeedbackRating) => void;
|
|
10
|
+
}
|
|
11
|
+
declare const MessageList: import("svelte").Component<Props, {}, "">;
|
|
12
|
+
type MessageList = ReturnType<typeof MessageList>;
|
|
13
|
+
export default MessageList;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { type AuditEntry, sortAudit } from '@protoyard/ai';
|
|
3
|
+
import { EmptyState } from 'texuli/empty';
|
|
4
|
+
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from 'texuli/table';
|
|
5
|
+
import { AUDIT_ACTION_LABELS } from './labels.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Newest-first audit table for suggestion decisions, on Texuli's table and
|
|
9
|
+
* empty state. Pure presentation of engine AuditEntry rows.
|
|
10
|
+
*/
|
|
11
|
+
let {
|
|
12
|
+
entries,
|
|
13
|
+
emptyTitle = 'No audit entries yet',
|
|
14
|
+
emptyDescription = 'When the simulated agent suggests something and people decide, rows appear here.',
|
|
15
|
+
actorName = (entry: AuditEntry) =>
|
|
16
|
+
entry.actor === 'agent' ? 'Simulated agent' : (entry.actorId ?? 'Person'),
|
|
17
|
+
formatWhen = (iso: string) => {
|
|
18
|
+
try {
|
|
19
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
20
|
+
month: 'short',
|
|
21
|
+
day: 'numeric',
|
|
22
|
+
hour: 'numeric',
|
|
23
|
+
minute: '2-digit',
|
|
24
|
+
}).format(new Date(iso));
|
|
25
|
+
} catch {
|
|
26
|
+
return iso;
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
class: className = '',
|
|
30
|
+
}: {
|
|
31
|
+
entries: readonly AuditEntry[];
|
|
32
|
+
emptyTitle?: string;
|
|
33
|
+
emptyDescription?: string;
|
|
34
|
+
actorName?: (entry: AuditEntry) => string;
|
|
35
|
+
formatWhen?: (iso: string) => string;
|
|
36
|
+
class?: string;
|
|
37
|
+
} = $props();
|
|
38
|
+
|
|
39
|
+
const rows = $derived(sortAudit(entries));
|
|
40
|
+
</script>
|
|
41
|
+
|
|
42
|
+
<section class="grid gap-3 {className}" data-py-audit-trail aria-label="Approval audit trail">
|
|
43
|
+
<p class="m-0 text-xs text-muted-foreground">
|
|
44
|
+
Simulated audit log. Not a compliance or security record.
|
|
45
|
+
</p>
|
|
46
|
+
{#if rows.length === 0}
|
|
47
|
+
<EmptyState title={emptyTitle} description={emptyDescription} size="sm" />
|
|
48
|
+
{:else}
|
|
49
|
+
<Table density="compact" stack="sm" aria-label="Approval audit entries">
|
|
50
|
+
<TableHeader>
|
|
51
|
+
<TableRow>
|
|
52
|
+
<TableHead scope="col">When</TableHead>
|
|
53
|
+
<TableHead scope="col">Who</TableHead>
|
|
54
|
+
<TableHead scope="col">What</TableHead>
|
|
55
|
+
<TableHead scope="col">Detail</TableHead>
|
|
56
|
+
</TableRow>
|
|
57
|
+
</TableHeader>
|
|
58
|
+
<TableBody>
|
|
59
|
+
{#each rows as entry (entry.id)}
|
|
60
|
+
<TableRow data-py-audit-row={entry.id} data-action={entry.action}>
|
|
61
|
+
<TableCell class="whitespace-nowrap text-muted-foreground">
|
|
62
|
+
<time datetime={entry.at}>{formatWhen(entry.at)}</time>
|
|
63
|
+
</TableCell>
|
|
64
|
+
<TableCell>{actorName(entry)}</TableCell>
|
|
65
|
+
<TableCell>{AUDIT_ACTION_LABELS[entry.action]}</TableCell>
|
|
66
|
+
<TableCell class="text-muted-foreground">{entry.detail}</TableCell>
|
|
67
|
+
</TableRow>
|
|
68
|
+
{/each}
|
|
69
|
+
</TableBody>
|
|
70
|
+
</Table>
|
|
71
|
+
{/if}
|
|
72
|
+
</section>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type AuditEntry } from '@protoyard/ai';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
entries: readonly AuditEntry[];
|
|
4
|
+
emptyTitle?: string;
|
|
5
|
+
emptyDescription?: string;
|
|
6
|
+
actorName?: (entry: AuditEntry) => string;
|
|
7
|
+
formatWhen?: (iso: string) => string;
|
|
8
|
+
class?: string;
|
|
9
|
+
};
|
|
10
|
+
declare const AuditTrail: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
11
|
+
type AuditTrail = ReturnType<typeof AuditTrail>;
|
|
12
|
+
export default AuditTrail;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
AUTONOMY_LEVELS,
|
|
4
|
+
type AutonomyConfig,
|
|
5
|
+
type AutonomyLevel,
|
|
6
|
+
CAP_REASON,
|
|
7
|
+
setLevel,
|
|
8
|
+
} from '@protoyard/ai';
|
|
9
|
+
import { Radio, RadioGroup } from 'texuli/choice';
|
|
10
|
+
import { AUTONOMY_LEVEL_LABELS, areaLabel } from './labels.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Per-area autonomy level radios (Texuli's card radio group). Calls `onChange`
|
|
14
|
+
* with the next config from `setLevel` (pure). Caps disable higher options.
|
|
15
|
+
* Simulated product settings.
|
|
16
|
+
*/
|
|
17
|
+
let {
|
|
18
|
+
config,
|
|
19
|
+
area,
|
|
20
|
+
legend = 'Autonomy level',
|
|
21
|
+
disabled = false,
|
|
22
|
+
onChange,
|
|
23
|
+
class: className = '',
|
|
24
|
+
}: {
|
|
25
|
+
config: AutonomyConfig;
|
|
26
|
+
area: string;
|
|
27
|
+
legend?: string;
|
|
28
|
+
disabled?: boolean;
|
|
29
|
+
/** Required: receive the next AutonomyConfig after a level pick. */
|
|
30
|
+
onChange: (next: AutonomyConfig, level: AutonomyLevel) => unknown | Promise<unknown>;
|
|
31
|
+
class?: string;
|
|
32
|
+
} = $props();
|
|
33
|
+
|
|
34
|
+
let pending = $state(false);
|
|
35
|
+
let error = $state('');
|
|
36
|
+
const current = $derived(config.levels[area] ?? 1);
|
|
37
|
+
const cap = $derived(config.caps[area]);
|
|
38
|
+
|
|
39
|
+
async function pick(level: AutonomyLevel): Promise<void> {
|
|
40
|
+
if (disabled || pending || level === current) return;
|
|
41
|
+
pending = true;
|
|
42
|
+
error = '';
|
|
43
|
+
try {
|
|
44
|
+
const next = setLevel(config, area, level);
|
|
45
|
+
await Promise.resolve(onChange(next, level));
|
|
46
|
+
} catch (err) {
|
|
47
|
+
error = err instanceof Error ? err.message : 'Could not update autonomy.';
|
|
48
|
+
} finally {
|
|
49
|
+
pending = false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
</script>
|
|
53
|
+
|
|
54
|
+
<fieldset
|
|
55
|
+
class="m-0 grid gap-2 border-0 p-0 {className}"
|
|
56
|
+
data-py-autonomy-picker
|
|
57
|
+
data-area={area}
|
|
58
|
+
disabled={disabled || pending}
|
|
59
|
+
>
|
|
60
|
+
<legend class="mb-1 px-0 text-sm font-semibold text-foreground">
|
|
61
|
+
{legend}
|
|
62
|
+
<span class="font-normal text-muted-foreground"> · {areaLabel(area)}</span>
|
|
63
|
+
</legend>
|
|
64
|
+
<p class="m-0 text-xs text-muted-foreground">
|
|
65
|
+
Simulated settings. Levels are not security boundaries.
|
|
66
|
+
</p>
|
|
67
|
+
<RadioGroup
|
|
68
|
+
class="grid gap-2 sm:grid-cols-2"
|
|
69
|
+
variant="card"
|
|
70
|
+
name="autonomy-{area}"
|
|
71
|
+
value={String(current)}
|
|
72
|
+
disabled={disabled || pending}
|
|
73
|
+
aria-label="{legend} for {areaLabel(area)}"
|
|
74
|
+
onValueChange={(value) => void pick(Number(value) as AutonomyLevel)}
|
|
75
|
+
>
|
|
76
|
+
{#each AUTONOMY_LEVELS as level (level)}
|
|
77
|
+
{@const meta = AUTONOMY_LEVEL_LABELS[level]}
|
|
78
|
+
{@const capped = cap !== undefined && level > cap}
|
|
79
|
+
<Radio
|
|
80
|
+
card
|
|
81
|
+
value={String(level)}
|
|
82
|
+
label={`Level ${level}: ${meta.name}`}
|
|
83
|
+
description={meta.definition}
|
|
84
|
+
disabled={capped || disabled || pending}
|
|
85
|
+
>
|
|
86
|
+
{#if capped}
|
|
87
|
+
<span class="text-xs text-muted-foreground"
|
|
88
|
+
>{area === 'external' ? CAP_REASON : `Capped at level ${String(cap)}.`}</span
|
|
89
|
+
>
|
|
90
|
+
{/if}
|
|
91
|
+
</Radio>
|
|
92
|
+
{/each}
|
|
93
|
+
</RadioGroup>
|
|
94
|
+
{#if pending}
|
|
95
|
+
<p class="m-0 text-xs text-muted-foreground" role="status" aria-live="polite">Saving…</p>
|
|
96
|
+
{/if}
|
|
97
|
+
{#if error}
|
|
98
|
+
<p class="m-0 text-sm text-destructive" role="alert">{error}</p>
|
|
99
|
+
{/if}
|
|
100
|
+
</fieldset>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type AutonomyConfig, type AutonomyLevel } from '@protoyard/ai';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
config: AutonomyConfig;
|
|
4
|
+
area: string;
|
|
5
|
+
legend?: string;
|
|
6
|
+
disabled?: boolean;
|
|
7
|
+
/** Required: receive the next AutonomyConfig after a level pick. */
|
|
8
|
+
onChange: (next: AutonomyConfig, level: AutonomyLevel) => unknown | Promise<unknown>;
|
|
9
|
+
class?: string;
|
|
10
|
+
};
|
|
11
|
+
declare const AutonomyLevelPicker: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
12
|
+
type AutonomyLevelPicker = ReturnType<typeof AutonomyLevelPicker>;
|
|
13
|
+
export default AutonomyLevelPicker;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { type ApprovalActor, mayDecide, type Suggestion } from '@protoyard/ai';
|
|
3
|
+
import { Button } from 'texuli/button';
|
|
4
|
+
import { Card } from 'texuli/card';
|
|
5
|
+
import { modeNeedsApproval } from './labels.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Compact chat-row approval for a pending suggestion. Same decision callbacks
|
|
9
|
+
* as SuggestionCard; designed to sit inside a message list. Texuli card and
|
|
10
|
+
* buttons; a decision shows its pending state on its own button.
|
|
11
|
+
*/
|
|
12
|
+
let {
|
|
13
|
+
suggestion,
|
|
14
|
+
viewer,
|
|
15
|
+
onConfirm,
|
|
16
|
+
onApprove,
|
|
17
|
+
onDismiss,
|
|
18
|
+
class: className = '',
|
|
19
|
+
}: {
|
|
20
|
+
suggestion: Suggestion;
|
|
21
|
+
viewer: ApprovalActor;
|
|
22
|
+
onConfirm?: (suggestion: Suggestion) => unknown | Promise<unknown>;
|
|
23
|
+
onApprove?: (suggestion: Suggestion) => unknown | Promise<unknown>;
|
|
24
|
+
onDismiss?: (suggestion: Suggestion) => unknown | Promise<unknown>;
|
|
25
|
+
class?: string;
|
|
26
|
+
} = $props();
|
|
27
|
+
|
|
28
|
+
let pending = $state<'confirm' | 'approve' | 'dismiss' | null>(null);
|
|
29
|
+
let error = $state('');
|
|
30
|
+
|
|
31
|
+
const needsApproval = $derived(modeNeedsApproval(suggestion.mode));
|
|
32
|
+
const waiting = $derived(suggestion.status === 'pending');
|
|
33
|
+
|
|
34
|
+
async function run(
|
|
35
|
+
kind: 'confirm' | 'approve' | 'dismiss',
|
|
36
|
+
work: () => unknown | Promise<unknown>
|
|
37
|
+
): Promise<void> {
|
|
38
|
+
if (pending) return;
|
|
39
|
+
pending = kind;
|
|
40
|
+
error = '';
|
|
41
|
+
try {
|
|
42
|
+
await Promise.resolve(work());
|
|
43
|
+
} catch (err) {
|
|
44
|
+
error = err instanceof Error ? err.message : 'The action could not be completed.';
|
|
45
|
+
} finally {
|
|
46
|
+
pending = null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
</script>
|
|
50
|
+
|
|
51
|
+
<Card
|
|
52
|
+
class="grid gap-2 p-3 text-sm {className}"
|
|
53
|
+
data-py-chat-approval
|
|
54
|
+
data-status={suggestion.status}
|
|
55
|
+
role="group"
|
|
56
|
+
aria-label="Simulated approval request"
|
|
57
|
+
>
|
|
58
|
+
<p class="m-0 font-medium text-foreground">{suggestion.summary}</p>
|
|
59
|
+
{#if suggestion.preparedApplySummary}
|
|
60
|
+
<p class="m-0 text-xs text-muted-foreground">
|
|
61
|
+
Prepared: {suggestion.preparedApplySummary}
|
|
62
|
+
</p>
|
|
63
|
+
{/if}
|
|
64
|
+
<p class="m-0 text-[0.7rem] text-muted-foreground">Simulated approval · not security</p>
|
|
65
|
+
|
|
66
|
+
{#if waiting}
|
|
67
|
+
<div class="flex flex-wrap gap-2" aria-label="Approval actions">
|
|
68
|
+
{#if needsApproval && mayDecide(viewer, suggestion, 'approve') && onApprove}
|
|
69
|
+
<Button
|
|
70
|
+
size="sm"
|
|
71
|
+
variant="default"
|
|
72
|
+
autoLoading={false}
|
|
73
|
+
loading={pending === 'approve'}
|
|
74
|
+
disabled={pending !== null}
|
|
75
|
+
onclick={() => run('approve', () => onApprove(suggestion))}
|
|
76
|
+
>
|
|
77
|
+
Approve
|
|
78
|
+
</Button>
|
|
79
|
+
{:else if !needsApproval && mayDecide(viewer, suggestion, 'confirm') && onConfirm}
|
|
80
|
+
<Button
|
|
81
|
+
size="sm"
|
|
82
|
+
variant="default"
|
|
83
|
+
autoLoading={false}
|
|
84
|
+
loading={pending === 'confirm'}
|
|
85
|
+
disabled={pending !== null}
|
|
86
|
+
onclick={() => run('confirm', () => onConfirm(suggestion))}
|
|
87
|
+
>
|
|
88
|
+
Confirm
|
|
89
|
+
</Button>
|
|
90
|
+
{:else if needsApproval && !viewer.canApprove}
|
|
91
|
+
<span class="text-xs text-muted-foreground" role="status">Waiting for an approver</span>
|
|
92
|
+
{/if}
|
|
93
|
+
{#if mayDecide(viewer, suggestion, 'dismiss') && onDismiss}
|
|
94
|
+
<Button
|
|
95
|
+
size="sm"
|
|
96
|
+
variant="outline"
|
|
97
|
+
autoLoading={false}
|
|
98
|
+
loading={pending === 'dismiss'}
|
|
99
|
+
disabled={pending !== null}
|
|
100
|
+
onclick={() => run('dismiss', () => onDismiss(suggestion))}
|
|
101
|
+
>
|
|
102
|
+
Dismiss
|
|
103
|
+
</Button>
|
|
104
|
+
{/if}
|
|
105
|
+
</div>
|
|
106
|
+
{:else}
|
|
107
|
+
<p class="m-0 text-xs text-muted-foreground" role="status">
|
|
108
|
+
{suggestion.status === 'confirmed' || suggestion.status === 'auto'
|
|
109
|
+
? 'Decided'
|
|
110
|
+
: suggestion.status === 'dismissed'
|
|
111
|
+
? 'Dismissed'
|
|
112
|
+
: suggestion.status}
|
|
113
|
+
</p>
|
|
114
|
+
{/if}
|
|
115
|
+
{#if error}
|
|
116
|
+
<p class="m-0 text-xs text-destructive" role="alert">{error}</p>
|
|
117
|
+
{/if}
|
|
118
|
+
</Card>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type ApprovalActor, type Suggestion } from '@protoyard/ai';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
suggestion: Suggestion;
|
|
4
|
+
viewer: ApprovalActor;
|
|
5
|
+
onConfirm?: (suggestion: Suggestion) => unknown | Promise<unknown>;
|
|
6
|
+
onApprove?: (suggestion: Suggestion) => unknown | Promise<unknown>;
|
|
7
|
+
onDismiss?: (suggestion: Suggestion) => unknown | Promise<unknown>;
|
|
8
|
+
class?: string;
|
|
9
|
+
};
|
|
10
|
+
declare const ChatApprovalCard: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
11
|
+
type ChatApprovalCard = ReturnType<typeof ChatApprovalCard>;
|
|
12
|
+
export default ChatApprovalCard;
|