@tetherto/mdk-ui-agent 0.0.0 → 0.12.1
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 +202 -0
- package/README.md +310 -0
- package/dist/branding.d.ts +36 -0
- package/dist/branding.d.ts.map +1 -0
- package/dist/branding.js +37 -0
- package/dist/branding.js.map +1 -0
- package/dist/components/approval-card.d.ts +46 -0
- package/dist/components/approval-card.d.ts.map +1 -0
- package/dist/components/approval-card.js +84 -0
- package/dist/components/approval-card.js.map +1 -0
- package/dist/components/assistant-message.d.ts +25 -0
- package/dist/components/assistant-message.d.ts.map +1 -0
- package/dist/components/assistant-message.js +49 -0
- package/dist/components/assistant-message.js.map +1 -0
- package/dist/components/co-pilot-panel.d.ts +17 -0
- package/dist/components/co-pilot-panel.d.ts.map +1 -0
- package/dist/components/co-pilot-panel.js +33 -0
- package/dist/components/co-pilot-panel.js.map +1 -0
- package/dist/components/code-block.d.ts +17 -0
- package/dist/components/code-block.d.ts.map +1 -0
- package/dist/components/code-block.js +40 -0
- package/dist/components/code-block.js.map +1 -0
- package/dist/components/collapsible-answer.d.ts +26 -0
- package/dist/components/collapsible-answer.d.ts.map +1 -0
- package/dist/components/collapsible-answer.js +30 -0
- package/dist/components/collapsible-answer.js.map +1 -0
- package/dist/components/composer.d.ts +31 -0
- package/dist/components/composer.d.ts.map +1 -0
- package/dist/components/composer.js +82 -0
- package/dist/components/composer.js.map +1 -0
- package/dist/components/conversation-list.d.ts +21 -0
- package/dist/components/conversation-list.d.ts.map +1 -0
- package/dist/components/conversation-list.js +42 -0
- package/dist/components/conversation-list.js.map +1 -0
- package/dist/components/icons.d.ts +88 -0
- package/dist/components/icons.d.ts.map +1 -0
- package/dist/components/icons.js +112 -0
- package/dist/components/icons.js.map +1 -0
- package/dist/components/launcher-fab.d.ts +8 -0
- package/dist/components/launcher-fab.d.ts.map +1 -0
- package/dist/components/launcher-fab.js +6 -0
- package/dist/components/launcher-fab.js.map +1 -0
- package/dist/components/leaked-tool-call-notice.d.ts +18 -0
- package/dist/components/leaked-tool-call-notice.d.ts.map +1 -0
- package/dist/components/leaked-tool-call-notice.js +14 -0
- package/dist/components/leaked-tool-call-notice.js.map +1 -0
- package/dist/components/markdown-link.d.ts +17 -0
- package/dist/components/markdown-link.d.ts.map +1 -0
- package/dist/components/markdown-link.js +18 -0
- package/dist/components/markdown-link.js.map +1 -0
- package/dist/components/markdown-text.d.ts +26 -0
- package/dist/components/markdown-text.d.ts.map +1 -0
- package/dist/components/markdown-text.js +32 -0
- package/dist/components/markdown-text.js.map +1 -0
- package/dist/components/message-list.d.ts +24 -0
- package/dist/components/message-list.d.ts.map +1 -0
- package/dist/components/message-list.js +92 -0
- package/dist/components/message-list.js.map +1 -0
- package/dist/components/no-tools-note.d.ts +26 -0
- package/dist/components/no-tools-note.d.ts.map +1 -0
- package/dist/components/no-tools-note.js +27 -0
- package/dist/components/no-tools-note.js.map +1 -0
- package/dist/components/panel-header.d.ts +26 -0
- package/dist/components/panel-header.d.ts.map +1 -0
- package/dist/components/panel-header.js +13 -0
- package/dist/components/panel-header.js.map +1 -0
- package/dist/components/rehype-highlight-subset.d.ts +17 -0
- package/dist/components/rehype-highlight-subset.d.ts.map +1 -0
- package/dist/components/rehype-highlight-subset.js +77 -0
- package/dist/components/rehype-highlight-subset.js.map +1 -0
- package/dist/components/system-message.d.ts +15 -0
- package/dist/components/system-message.d.ts.map +1 -0
- package/dist/components/system-message.js +12 -0
- package/dist/components/system-message.js.map +1 -0
- package/dist/components/tool-chip.d.ts +19 -0
- package/dist/components/tool-chip.d.ts.map +1 -0
- package/dist/components/tool-chip.js +32 -0
- package/dist/components/tool-chip.js.map +1 -0
- package/dist/components/user-message.d.ts +9 -0
- package/dist/components/user-message.d.ts.map +1 -0
- package/dist/components/user-message.js +7 -0
- package/dist/components/user-message.js.map +1 -0
- package/dist/core/conversation-store.d.ts +107 -0
- package/dist/core/conversation-store.d.ts.map +1 -0
- package/dist/core/conversation-store.js +254 -0
- package/dist/core/conversation-store.js.map +1 -0
- package/dist/core/error-text.d.ts +19 -0
- package/dist/core/error-text.d.ts.map +1 -0
- package/dist/core/error-text.js +35 -0
- package/dist/core/error-text.js.map +1 -0
- package/dist/core/events.d.ts +98 -0
- package/dist/core/events.d.ts.map +1 -0
- package/dist/core/events.js +50 -0
- package/dist/core/events.js.map +1 -0
- package/dist/core/format.d.ts +23 -0
- package/dist/core/format.d.ts.map +1 -0
- package/dist/core/format.js +49 -0
- package/dist/core/format.js.map +1 -0
- package/dist/core/index.d.ts +27 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +20 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/markdown.d.ts +26 -0
- package/dist/core/markdown.d.ts.map +1 -0
- package/dist/core/markdown.js +49 -0
- package/dist/core/markdown.js.map +1 -0
- package/dist/core/prose.d.ts +27 -0
- package/dist/core/prose.d.ts.map +1 -0
- package/dist/core/prose.js +79 -0
- package/dist/core/prose.js.map +1 -0
- package/dist/core/small-talk.d.ts +15 -0
- package/dist/core/small-talk.d.ts.map +1 -0
- package/dist/core/small-talk.js +42 -0
- package/dist/core/small-talk.js.map +1 -0
- package/dist/core/sse.d.ts +108 -0
- package/dist/core/sse.d.ts.map +1 -0
- package/dist/core/sse.js +236 -0
- package/dist/core/sse.js.map +1 -0
- package/dist/core/tool-label.d.ts +16 -0
- package/dist/core/tool-label.d.ts.map +1 -0
- package/dist/core/tool-label.js +31 -0
- package/dist/core/tool-label.js.map +1 -0
- package/dist/core/transport.d.ts +71 -0
- package/dist/core/transport.d.ts.map +1 -0
- package/dist/core/transport.js +147 -0
- package/dist/core/transport.js.map +1 -0
- package/dist/core/turn.d.ts +87 -0
- package/dist/core/turn.d.ts.map +1 -0
- package/dist/core/turn.js +232 -0
- package/dist/core/turn.js.map +1 -0
- package/dist/core/uuid.d.ts +21 -0
- package/dist/core/uuid.d.ts.map +1 -0
- package/dist/core/uuid.js +54 -0
- package/dist/core/uuid.js.map +1 -0
- package/dist/entries/chat-page.d.ts +28 -0
- package/dist/entries/chat-page.d.ts.map +1 -0
- package/dist/entries/chat-page.js +25 -0
- package/dist/entries/chat-page.js.map +1 -0
- package/dist/entries/co-pilot.d.ts +41 -0
- package/dist/entries/co-pilot.d.ts.map +1 -0
- package/dist/entries/co-pilot.js +39 -0
- package/dist/entries/co-pilot.js.map +1 -0
- package/dist/entries/lazy-panel.d.ts +15 -0
- package/dist/entries/lazy-panel.d.ts.map +1 -0
- package/dist/entries/lazy-panel.js +19 -0
- package/dist/entries/lazy-panel.js.map +1 -0
- package/dist/hooks/index.d.ts +7 -0
- package/dist/hooks/index.d.ts.map +1 -0
- package/dist/hooks/index.js +4 -0
- package/dist/hooks/index.js.map +1 -0
- package/dist/hooks/use-agent-chat.d.ts +46 -0
- package/dist/hooks/use-agent-chat.d.ts.map +1 -0
- package/dist/hooks/use-agent-chat.js +288 -0
- package/dist/hooks/use-agent-chat.js.map +1 -0
- package/dist/hooks/use-agent-config.d.ts +29 -0
- package/dist/hooks/use-agent-config.d.ts.map +1 -0
- package/dist/hooks/use-agent-config.js +45 -0
- package/dist/hooks/use-agent-config.js.map +1 -0
- package/dist/hooks/use-conversations.d.ts +19 -0
- package/dist/hooks/use-conversations.d.ts.map +1 -0
- package/dist/hooks/use-conversations.js +19 -0
- package/dist/hooks/use-conversations.js.map +1 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +26 -0
- package/dist/index.js.map +1 -0
- package/dist/styles.css +1 -0
- package/package.json +105 -1
- package/src/branding.ts +40 -0
- package/src/components/approval-card.tsx +141 -0
- package/src/components/assistant-message.tsx +140 -0
- package/src/components/co-pilot-panel.tsx +109 -0
- package/src/components/code-block.tsx +63 -0
- package/src/components/collapsible-answer.tsx +55 -0
- package/src/components/composer.tsx +147 -0
- package/src/components/conversation-list.tsx +141 -0
- package/src/components/icons.tsx +189 -0
- package/src/components/launcher-fab.tsx +16 -0
- package/src/components/leaked-tool-call-notice.tsx +29 -0
- package/src/components/markdown-link.tsx +30 -0
- package/src/components/markdown-text.tsx +58 -0
- package/src/components/message-list.tsx +164 -0
- package/src/components/no-tools-note.tsx +33 -0
- package/src/components/panel-header.tsx +86 -0
- package/src/components/rehype-highlight-subset.ts +84 -0
- package/src/components/system-message.tsx +19 -0
- package/src/components/tool-chip.tsx +58 -0
- package/src/components/user-message.tsx +18 -0
- package/src/core/conversation-store.ts +342 -0
- package/src/core/error-text.ts +36 -0
- package/src/core/events.ts +127 -0
- package/src/core/format.ts +59 -0
- package/src/core/index.ts +115 -0
- package/src/core/markdown.ts +52 -0
- package/src/core/prose.ts +78 -0
- package/src/core/small-talk.ts +45 -0
- package/src/core/sse.ts +269 -0
- package/src/core/tool-label.ts +33 -0
- package/src/core/transport.ts +208 -0
- package/src/core/turn.ts +299 -0
- package/src/core/uuid.ts +59 -0
- package/src/entries/chat-page.tsx +56 -0
- package/src/entries/co-pilot.tsx +84 -0
- package/src/entries/lazy-panel.tsx +19 -0
- package/src/hooks/index.ts +8 -0
- package/src/hooks/use-agent-chat.ts +387 -0
- package/src/hooks/use-agent-config.ts +70 -0
- package/src/hooks/use-conversations.ts +39 -0
- package/src/index.ts +30 -0
- package/src/styles/_approval.scss +68 -0
- package/src/styles/_composer.scss +45 -0
- package/src/styles/_conversations.scss +160 -0
- package/src/styles/_messages.scss +444 -0
- package/src/styles/_mixins.scss +27 -0
- package/src/styles/_panel.scss +147 -0
- package/src/styles/_tokens.scss +30 -0
- package/src/styles.scss +16 -0
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conversation history, persisted client-side.
|
|
3
|
+
*
|
|
4
|
+
* The gateway keeps sessions in an in-memory `Map` per process — no titles, no
|
|
5
|
+
* timestamps, no transcripts, and nothing survives a restart — and exposes no
|
|
6
|
+
* route to list them. So the Conversations view is backed from here instead.
|
|
7
|
+
*
|
|
8
|
+
* A stored `sessionId` is therefore a weak reference: it 404s after a gateway
|
|
9
|
+
* restart or once the agent's own 30-minute session TTL lapses. That is expected,
|
|
10
|
+
* not an error — the transcript still reads back, and the next message mints a
|
|
11
|
+
* fresh session (see `useAgentChat`).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { createStore } from 'zustand/vanilla'
|
|
15
|
+
import type { StoreApi } from 'zustand/vanilla'
|
|
16
|
+
|
|
17
|
+
import type { ToolStep } from './turn'
|
|
18
|
+
import { uuid } from './uuid'
|
|
19
|
+
|
|
20
|
+
export const CONVERSATION_STORAGE_KEY = 'mdk-ui-agent:conversations:v1'
|
|
21
|
+
|
|
22
|
+
/** Keeps localStorage bounded. The oldest conversations fall off the end. */
|
|
23
|
+
export const MAX_CONVERSATIONS = 50
|
|
24
|
+
/** Mirrors the agent's own per-session ceiling in `backend/core/agent/src/session-store.js`. */
|
|
25
|
+
export const MAX_MESSAGES_PER_CONVERSATION = 200
|
|
26
|
+
/**
|
|
27
|
+
* How much of a tool result is kept in storage.
|
|
28
|
+
*
|
|
29
|
+
* A `list_devices` result is the whole fleet serialized, and it is written again on
|
|
30
|
+
* every subsequent change. The full text stays in memory for the session — this is
|
|
31
|
+
* only what survives a reload, where the chip's tooltip is a convenience and the
|
|
32
|
+
* quota is not. Mirrors `HISTORY_RESULT_CHARS` in the producer's own transcript.
|
|
33
|
+
*/
|
|
34
|
+
export const MAX_PERSISTED_TOOL_TEXT_CHARS = 400
|
|
35
|
+
|
|
36
|
+
export const MESSAGE_ROLE = {
|
|
37
|
+
USER: 'user',
|
|
38
|
+
ASSISTANT: 'assistant',
|
|
39
|
+
/** Not from the model: something the panel itself has to tell the operator. */
|
|
40
|
+
SYSTEM: 'system',
|
|
41
|
+
} as const
|
|
42
|
+
|
|
43
|
+
export type MessageRole = (typeof MESSAGE_ROLE)[keyof typeof MESSAGE_ROLE]
|
|
44
|
+
|
|
45
|
+
export type ChatMessage = {
|
|
46
|
+
id: string
|
|
47
|
+
role: MessageRole
|
|
48
|
+
text: string
|
|
49
|
+
at: number
|
|
50
|
+
/** Assistant only: the tool chips for this turn, in call order. */
|
|
51
|
+
tools?: ToolStep[]
|
|
52
|
+
/** Assistant only: set when the turn ended on an `error` event. Raw `ERR_*` codes included. */
|
|
53
|
+
error?: string | null
|
|
54
|
+
/**
|
|
55
|
+
* Assistant only: the turn was stopped before it finished — by the operator, or
|
|
56
|
+
* by the panel unmounting. Whatever streamed is kept, but it is not the whole
|
|
57
|
+
* answer and must not read back as one.
|
|
58
|
+
*/
|
|
59
|
+
interrupted?: boolean
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type Conversation = {
|
|
63
|
+
id: string
|
|
64
|
+
title: string
|
|
65
|
+
/** Null until the first turn creates one, or after a stale one is discarded. */
|
|
66
|
+
sessionId: string | null
|
|
67
|
+
messages: ChatMessage[]
|
|
68
|
+
createdAt: number
|
|
69
|
+
updatedAt: number
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type ConversationState = {
|
|
73
|
+
conversations: Conversation[]
|
|
74
|
+
activeId: string | null
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export type ConversationActions = {
|
|
78
|
+
create: (id?: string, at?: number) => string
|
|
79
|
+
select: (id: string) => void
|
|
80
|
+
remove: (id: string) => void
|
|
81
|
+
setSessionId: (id: string, sessionId: string | null) => void
|
|
82
|
+
appendMessage: (id: string, message: ChatMessage) => void
|
|
83
|
+
updateMessage: (id: string, messageId: string, patch: Partial<ChatMessage>) => void
|
|
84
|
+
reset: () => void
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export type ConversationStore = ConversationState & ConversationActions
|
|
88
|
+
|
|
89
|
+
export type ConversationStorage = Pick<Storage, 'getItem' | 'setItem'>
|
|
90
|
+
|
|
91
|
+
export type ConversationStoreOptions = {
|
|
92
|
+
storageKey?: string
|
|
93
|
+
/** Pass `null` to disable persistence entirely (tests, SSR). */
|
|
94
|
+
storage?: ConversationStorage | null
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const initialState: ConversationState = { conversations: [], activeId: null }
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* `localStorage` throws on access in some privacy modes and does not exist
|
|
101
|
+
* outside the browser, so it is probed rather than assumed.
|
|
102
|
+
*/
|
|
103
|
+
function defaultStorage(): ConversationStorage | null {
|
|
104
|
+
try {
|
|
105
|
+
return globalThis.localStorage ?? null
|
|
106
|
+
} catch {
|
|
107
|
+
return null
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** First line of the first user message, which is what the design lists. */
|
|
112
|
+
export function deriveTitle(text: string, max = 48): string {
|
|
113
|
+
const line = text.trim().split('\n')[0]?.trim() ?? ''
|
|
114
|
+
if (line.length === 0) return 'New conversation'
|
|
115
|
+
return line.length <= max ? line : `${line.slice(0, max - 1).trimEnd()}…`
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function newConversation(id: string, at: number): Conversation {
|
|
119
|
+
return { id, title: 'New conversation', sessionId: null, messages: [], createdAt: at, updatedAt: at }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function isConversation(value: unknown): value is Conversation {
|
|
123
|
+
if (typeof value !== 'object' || value === null) return false
|
|
124
|
+
const candidate = value as Partial<Conversation>
|
|
125
|
+
return typeof candidate.id === 'string' && Array.isArray(candidate.messages)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Anything unreadable is discarded rather than repaired: the stored shape can
|
|
130
|
+
* predate a release, and losing local history is a far better failure than
|
|
131
|
+
* booting the panel into a broken state.
|
|
132
|
+
*/
|
|
133
|
+
function readPersisted(storage: ConversationStorage | null, key: string): ConversationState {
|
|
134
|
+
if (!storage) return initialState
|
|
135
|
+
try {
|
|
136
|
+
const raw = storage.getItem(key)
|
|
137
|
+
if (!raw) return initialState
|
|
138
|
+
const parsed = JSON.parse(raw) as Partial<ConversationState>
|
|
139
|
+
const conversations = Array.isArray(parsed.conversations)
|
|
140
|
+
? parsed.conversations.filter(isConversation)
|
|
141
|
+
: []
|
|
142
|
+
const activeId
|
|
143
|
+
= typeof parsed.activeId === 'string'
|
|
144
|
+
&& conversations.some((conversation) => conversation.id === parsed.activeId)
|
|
145
|
+
? parsed.activeId
|
|
146
|
+
: null
|
|
147
|
+
return { conversations, activeId }
|
|
148
|
+
} catch {
|
|
149
|
+
return initialState
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Keeps a full fleet dump out of every write. The in-memory copy is untouched. */
|
|
154
|
+
function trimForStorage(conversation: Conversation): Conversation {
|
|
155
|
+
if (!conversation.messages.some((message) => message.tools?.length)) return conversation
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
...conversation,
|
|
159
|
+
messages: conversation.messages.map((message) =>
|
|
160
|
+
message.tools?.length
|
|
161
|
+
? {
|
|
162
|
+
...message,
|
|
163
|
+
tools: message.tools.map((step) =>
|
|
164
|
+
step.text === undefined || step.text.length <= MAX_PERSISTED_TOOL_TEXT_CHARS
|
|
165
|
+
? step
|
|
166
|
+
: { ...step, text: `${step.text.slice(0, MAX_PERSISTED_TOOL_TEXT_CHARS)}…` },
|
|
167
|
+
),
|
|
168
|
+
}
|
|
169
|
+
: message,
|
|
170
|
+
),
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function serialize(state: ConversationState): string {
|
|
175
|
+
return JSON.stringify({
|
|
176
|
+
conversations: state.conversations.map(trimForStorage),
|
|
177
|
+
activeId: state.activeId,
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function writePersisted(storage: ConversationStorage | null, key: string, payload: string): void {
|
|
182
|
+
if (!storage) return
|
|
183
|
+
try {
|
|
184
|
+
storage.setItem(key, payload)
|
|
185
|
+
} catch {
|
|
186
|
+
// Quota exceeded or storage disabled mid-session. History is a convenience,
|
|
187
|
+
// never a correctness requirement, so the live conversation continues.
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function touch(conversation: Conversation, at: number): Conversation {
|
|
192
|
+
return { ...conversation, updatedAt: at }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Newest first, capped — the order the Conversations view renders. */
|
|
196
|
+
function sortAndCap(conversations: Conversation[]): Conversation[] {
|
|
197
|
+
return [...conversations].sort((a, b) => b.updatedAt - a.updatedAt).slice(0, MAX_CONVERSATIONS)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Reconciles this tab's conversations with what another tab has just written.
|
|
202
|
+
*
|
|
203
|
+
* Union by id, newer `updatedAt` wins. Without it the last tab to act overwrites
|
|
204
|
+
* the other's turns with its own staler snapshot — and since the gateway keeps no
|
|
205
|
+
* history, that is permanent loss rather than a cache miss.
|
|
206
|
+
*
|
|
207
|
+
* The trade-off is deliberate: a conversation deleted in one tab can come back
|
|
208
|
+
* from the other tab's copy, because "absent" and "never seen" are the same thing
|
|
209
|
+
* in a union. Resurrecting a transcript is the recoverable direction; losing one
|
|
210
|
+
* is not.
|
|
211
|
+
*/
|
|
212
|
+
export function mergeConversations(mine: Conversation[], theirs: Conversation[]): Conversation[] {
|
|
213
|
+
const byId = new Map(mine.map((conversation) => [conversation.id, conversation]))
|
|
214
|
+
|
|
215
|
+
for (const other of theirs) {
|
|
216
|
+
const own = byId.get(other.id)
|
|
217
|
+
if (!own || other.updatedAt > own.updatedAt) byId.set(other.id, other)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return sortAndCap([...byId.values()])
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Factory for an isolated conversation store. Tests and SSR should use this;
|
|
225
|
+
* runtime code binds to the singleton {@link conversationStore}.
|
|
226
|
+
*/
|
|
227
|
+
export function createConversationStore(
|
|
228
|
+
options: ConversationStoreOptions = {},
|
|
229
|
+
): StoreApi<ConversationStore> {
|
|
230
|
+
const key = options.storageKey ?? CONVERSATION_STORAGE_KEY
|
|
231
|
+
const storage = options.storage === undefined ? defaultStorage() : options.storage
|
|
232
|
+
|
|
233
|
+
const store = createStore<ConversationStore>((set, get) => ({
|
|
234
|
+
...readPersisted(storage, key),
|
|
235
|
+
|
|
236
|
+
create: (id, at) => {
|
|
237
|
+
const conversationId = id ?? uuid()
|
|
238
|
+
const createdAt = at ?? Date.now()
|
|
239
|
+
set((state) => ({
|
|
240
|
+
conversations: sortAndCap([newConversation(conversationId, createdAt), ...state.conversations]),
|
|
241
|
+
activeId: conversationId,
|
|
242
|
+
}))
|
|
243
|
+
return conversationId
|
|
244
|
+
},
|
|
245
|
+
|
|
246
|
+
select: (id) => {
|
|
247
|
+
if (!get().conversations.some((conversation) => conversation.id === id)) return
|
|
248
|
+
set({ activeId: id })
|
|
249
|
+
},
|
|
250
|
+
|
|
251
|
+
remove: (id) => {
|
|
252
|
+
set((state) => {
|
|
253
|
+
const conversations = state.conversations.filter((conversation) => conversation.id !== id)
|
|
254
|
+
return {
|
|
255
|
+
conversations,
|
|
256
|
+
activeId: state.activeId === id ? (conversations[0]?.id ?? null) : state.activeId,
|
|
257
|
+
}
|
|
258
|
+
})
|
|
259
|
+
},
|
|
260
|
+
|
|
261
|
+
setSessionId: (id, sessionId) => {
|
|
262
|
+
set((state) => ({
|
|
263
|
+
conversations: state.conversations.map((conversation) =>
|
|
264
|
+
conversation.id === id ? { ...conversation, sessionId } : conversation,
|
|
265
|
+
),
|
|
266
|
+
}))
|
|
267
|
+
},
|
|
268
|
+
|
|
269
|
+
appendMessage: (id, message) => {
|
|
270
|
+
set((state) => ({
|
|
271
|
+
conversations: sortAndCap(
|
|
272
|
+
state.conversations.map((conversation) => {
|
|
273
|
+
if (conversation.id !== id) return conversation
|
|
274
|
+
const messages = [...conversation.messages, message].slice(-MAX_MESSAGES_PER_CONVERSATION)
|
|
275
|
+
const title
|
|
276
|
+
= conversation.messages.length === 0 && message.role === MESSAGE_ROLE.USER
|
|
277
|
+
? deriveTitle(message.text)
|
|
278
|
+
: conversation.title
|
|
279
|
+
return touch({ ...conversation, messages, title }, message.at)
|
|
280
|
+
}),
|
|
281
|
+
),
|
|
282
|
+
}))
|
|
283
|
+
},
|
|
284
|
+
|
|
285
|
+
updateMessage: (id, messageId, patch) => {
|
|
286
|
+
set((state) => ({
|
|
287
|
+
conversations: state.conversations.map((conversation) => {
|
|
288
|
+
if (conversation.id !== id) return conversation
|
|
289
|
+
return {
|
|
290
|
+
...conversation,
|
|
291
|
+
messages: conversation.messages.map((message) =>
|
|
292
|
+
message.id === messageId ? { ...message, ...patch } : message,
|
|
293
|
+
),
|
|
294
|
+
}
|
|
295
|
+
}),
|
|
296
|
+
}))
|
|
297
|
+
},
|
|
298
|
+
|
|
299
|
+
reset: () => set({ ...initialState }),
|
|
300
|
+
}))
|
|
301
|
+
|
|
302
|
+
// The last payload written or read, so an unchanged state is not written again —
|
|
303
|
+
// and, with the cross-tab listener below, so two tabs cannot ping-pong identical
|
|
304
|
+
// writes at each other forever.
|
|
305
|
+
let persisted = serialize(store.getState())
|
|
306
|
+
|
|
307
|
+
store.subscribe((state) => {
|
|
308
|
+
const payload = serialize(state)
|
|
309
|
+
if (payload === persisted) return
|
|
310
|
+
persisted = payload
|
|
311
|
+
writePersisted(storage, key, payload)
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Another tab wrote the same key: merge its conversations in rather than letting
|
|
316
|
+
* whichever tab acts last overwrite the other.
|
|
317
|
+
*
|
|
318
|
+
* Only wired up for real `localStorage`, because the `storage` event describes
|
|
319
|
+
* nothing else — an injected storage (tests, SSR) has no cross-tab notion at all.
|
|
320
|
+
*/
|
|
321
|
+
if (storage && storage === globalThis.localStorage && typeof globalThis.addEventListener === 'function') {
|
|
322
|
+
globalThis.addEventListener('storage', (event) => {
|
|
323
|
+
if (event.storageArea && event.storageArea !== globalThis.localStorage) return
|
|
324
|
+
// A null key is `localStorage.clear()`, which drops ours too.
|
|
325
|
+
if (event.key !== null && event.key !== key) return
|
|
326
|
+
|
|
327
|
+
const external = readPersisted(storage, key)
|
|
328
|
+
const merged = mergeConversations(store.getState().conversations, external.conversations)
|
|
329
|
+
persisted = serialize({ conversations: merged, activeId: store.getState().activeId })
|
|
330
|
+
// The other tab's `activeId` is about its own window, so ours is kept.
|
|
331
|
+
store.setState({ conversations: merged })
|
|
332
|
+
})
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return store
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Module-level singleton holding every conversation the operator has had in this
|
|
340
|
+
* browser. React subscribers bind to it through `useConversations`.
|
|
341
|
+
*/
|
|
342
|
+
export const conversationStore = createConversationStore()
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operator-readable text for the failures a turn can end on.
|
|
3
|
+
*
|
|
4
|
+
* `reduceTurn` stores whatever the `error` event carried, which is a bare code
|
|
5
|
+
* like `ERR_AGENT_STREAM_TRUNCATED` — useful in a bug report, meaningless in a
|
|
6
|
+
* transcript. The raw code stays on the message; this is applied at render time,
|
|
7
|
+
* so the stored history keeps the diagnosable value.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { ERR_STREAM_IDLE, ERR_STREAM_TRUNCATED } from './sse'
|
|
11
|
+
import { AGENT_ERROR } from './transport'
|
|
12
|
+
|
|
13
|
+
/** Shown when a turn dies before the gateway said anything useful. */
|
|
14
|
+
export const GENERIC_TURN_ERROR = 'The agent could not complete that request.'
|
|
15
|
+
|
|
16
|
+
const TEXT_FOR_CODE: Record<string, string> = {
|
|
17
|
+
[AGENT_ERROR.UNAVAILABLE]: 'The agent is not configured on this gateway.',
|
|
18
|
+
[AGENT_ERROR.TURN_ACTIVE]: 'A turn is already running in this conversation.',
|
|
19
|
+
[AGENT_ERROR.TEXT_REQUIRED]: 'Type a message first.',
|
|
20
|
+
[AGENT_ERROR.SESSION_NOT_FOUND]: 'That conversation is no longer open on the gateway.',
|
|
21
|
+
[ERR_STREAM_TRUNCATED]: 'The connection to the agent ended before the answer finished.',
|
|
22
|
+
[ERR_STREAM_IDLE]: 'The agent stopped responding, so the connection was assumed lost.',
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Maps a code to a sentence, passing anything that is not one through unchanged.
|
|
27
|
+
*
|
|
28
|
+
* The producer's codes are all `ERR_*`, so a value in that shape that is not in
|
|
29
|
+
* the table is a code this release has never seen — it becomes the generic
|
|
30
|
+
* sentence rather than being shown verbatim. Anything else is already prose.
|
|
31
|
+
*/
|
|
32
|
+
export function describeAgentError(error: string | null | undefined): string | null {
|
|
33
|
+
if (!error) return null
|
|
34
|
+
if (TEXT_FOR_CODE[error]) return TEXT_FOR_CODE[error]
|
|
35
|
+
return error.startsWith('ERR_') ? GENERIC_TURN_ERROR : error
|
|
36
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The agent event contract, redeclared for the browser.
|
|
3
|
+
*
|
|
4
|
+
* The producer of record is `backend/core/agent/src/events.js`. That module is
|
|
5
|
+
* Node-only ESM inside a private package with no `exports` map, and importing it
|
|
6
|
+
* would pull the model provider and the MCP client into the bundle — so the six
|
|
7
|
+
* events are restated here instead. `src/core/events.contract.test.ts` reads the
|
|
8
|
+
* backend file and fails if the two ever drift.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import isPlainObject from 'lodash/isPlainObject'
|
|
12
|
+
|
|
13
|
+
/** Bump only alongside the producer's `CONTRACT_VERSION`. */
|
|
14
|
+
export const AGENT_CONTRACT_VERSION = 'v1'
|
|
15
|
+
|
|
16
|
+
/** Reference these constants, never the raw strings, so a typo is a type error. */
|
|
17
|
+
export const EVENT = {
|
|
18
|
+
TOKEN: 'token',
|
|
19
|
+
TOOL_CALL: 'tool_call',
|
|
20
|
+
TOOL_RESULT: 'tool_result',
|
|
21
|
+
PENDING_APPROVAL: 'pending_approval',
|
|
22
|
+
ERROR: 'error',
|
|
23
|
+
DONE: 'done',
|
|
24
|
+
} as const
|
|
25
|
+
|
|
26
|
+
export type EventType = (typeof EVENT)[keyof typeof EVENT]
|
|
27
|
+
|
|
28
|
+
/** `done` and `error` only. Exactly one of them ends a turn, and it is always last. */
|
|
29
|
+
export const TERMINAL_EVENTS = [EVENT.DONE, EVENT.ERROR] as const
|
|
30
|
+
|
|
31
|
+
export type TerminalEventType = (typeof TERMINAL_EVENTS)[number]
|
|
32
|
+
|
|
33
|
+
export type ToolArgs = Record<string, unknown>
|
|
34
|
+
|
|
35
|
+
/** A chunk of the answer, to append to the open bubble. */
|
|
36
|
+
export type TokenEvent = { type: typeof EVENT.TOKEN, text: string }
|
|
37
|
+
|
|
38
|
+
/** The model chose a tool. Always followed by a `tool_result` with the same `name`. */
|
|
39
|
+
export type ToolCallEvent = { type: typeof EVENT.TOOL_CALL, name: string, args: ToolArgs }
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The tool answered. `isError` means the call itself failed; `contractViolation`
|
|
43
|
+
* — which the producer only ever emits together with `isError` — means the tool
|
|
44
|
+
* answered but the answer broke its verb's contract.
|
|
45
|
+
*/
|
|
46
|
+
export type ToolResultEvent = {
|
|
47
|
+
type: typeof EVENT.TOOL_RESULT
|
|
48
|
+
name: string
|
|
49
|
+
text: string
|
|
50
|
+
isError?: boolean
|
|
51
|
+
contractViolation?: string
|
|
52
|
+
/**
|
|
53
|
+
* How long the operator held the turn at the approval prompt, on a gated call only.
|
|
54
|
+
* Subtract it before showing a duration: the gap between a `tool_call` and its
|
|
55
|
+
* `tool_result` spans the wait, so a card left open for a minute reads as a minute
|
|
56
|
+
* of tool.
|
|
57
|
+
*/
|
|
58
|
+
approvalWaitMs?: number
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A write tool is waiting on the operator. The stream PAUSES here. */
|
|
62
|
+
export type PendingApprovalEvent = {
|
|
63
|
+
type: typeof EVENT.PENDING_APPROVAL
|
|
64
|
+
name: string
|
|
65
|
+
args: ToolArgs
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Terminal. Distinct from a decline, which is tokens + `done` with no `tool_call`. */
|
|
69
|
+
export type ErrorEvent = { type: typeof EVENT.ERROR, error: string }
|
|
70
|
+
|
|
71
|
+
/** Terminal. */
|
|
72
|
+
export type DoneEvent = {
|
|
73
|
+
type: typeof EVENT.DONE
|
|
74
|
+
text?: string
|
|
75
|
+
usage?: Record<string, unknown>
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export type AgentEvent =
|
|
79
|
+
| TokenEvent
|
|
80
|
+
| ToolCallEvent
|
|
81
|
+
| ToolResultEvent
|
|
82
|
+
| PendingApprovalEvent
|
|
83
|
+
| ErrorEvent
|
|
84
|
+
| DoneEvent
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* What actually arrives on the wire: the event's own fields with the gateway's
|
|
88
|
+
* envelope spread onto the same flat object, not nested under a key.
|
|
89
|
+
*/
|
|
90
|
+
export type WireEvent = AgentEvent & {
|
|
91
|
+
/** Correlates every event of one turn. */
|
|
92
|
+
turnId: string
|
|
93
|
+
/** Monotonic per turn — ordering and dedup. */
|
|
94
|
+
seq: number
|
|
95
|
+
/** Stamped by the gateway on every event, despite being optional in the docs. */
|
|
96
|
+
ts: number
|
|
97
|
+
/** Present ONLY on `pending_approval`. Echo it back to decide. */
|
|
98
|
+
approvalId?: string
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const EVENT_TYPES = new Set<string>(Object.values(EVENT))
|
|
102
|
+
const TERMINAL_TYPES = new Set<string>(TERMINAL_EVENTS)
|
|
103
|
+
|
|
104
|
+
export function isTerminal(event: Pick<AgentEvent, 'type'>): boolean {
|
|
105
|
+
return TERMINAL_TYPES.has(event.type)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Structural check only — enough to decide whether we know how to render it.
|
|
110
|
+
*
|
|
111
|
+
* Contract invariant 5: unknown event types MUST be ignored, not thrown on. That
|
|
112
|
+
* is what lets the producer add event types without a breaking release, so this
|
|
113
|
+
* returns false rather than raising.
|
|
114
|
+
*/
|
|
115
|
+
export function isAgentEvent(value: unknown): value is AgentEvent {
|
|
116
|
+
// Every event arrives from `JSON.parse`, so a plain object is exactly the shape
|
|
117
|
+
// to accept — anything else on the wire is malformed by definition.
|
|
118
|
+
if (!isPlainObject(value)) return false
|
|
119
|
+
const type = (value as { type?: unknown }).type
|
|
120
|
+
return typeof type === 'string' && EVENT_TYPES.has(type)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function isWireEvent(value: unknown): value is WireEvent {
|
|
124
|
+
if (!isAgentEvent(value)) return false
|
|
125
|
+
const { turnId, seq } = value as Partial<WireEvent>
|
|
126
|
+
return typeof turnId === 'string' && typeof seq === 'number'
|
|
127
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Formatting for the conversation list.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately local-time, not the fleet timezone from `timezoneStore`: these
|
|
5
|
+
* are records of when the operator had a conversation in this browser, not
|
|
6
|
+
* telemetry timestamps.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const DAY_MS = 86_400_000
|
|
10
|
+
|
|
11
|
+
function startOfDay(at: number): number {
|
|
12
|
+
const date = new Date(at)
|
|
13
|
+
date.setHours(0, 0, 0, 0)
|
|
14
|
+
return date.getTime()
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** `Today` / `Yesterday` / `Jul 28`. */
|
|
18
|
+
export function formatDay(at: number, now: number = Date.now()): string {
|
|
19
|
+
const days = Math.round((startOfDay(now) - startOfDay(at)) / DAY_MS)
|
|
20
|
+
if (days <= 0) return 'Today'
|
|
21
|
+
if (days === 1) return 'Yesterday'
|
|
22
|
+
return new Date(at).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** `14:32`, 24-hour so it lines up in a monospace column. */
|
|
26
|
+
export function formatClock(at: number): string {
|
|
27
|
+
return new Date(at).toLocaleTimeString(undefined, {
|
|
28
|
+
hour: '2-digit',
|
|
29
|
+
minute: '2-digit',
|
|
30
|
+
hour12: false,
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function formatMessageCount(count: number): string {
|
|
35
|
+
return count === 1 ? '1 message' : `${count} messages`
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* `4 conversations · 1 running` — the header line for the conversations view.
|
|
40
|
+
*
|
|
41
|
+
* "Running" counts only what this client is streaming: the gateway holds
|
|
42
|
+
* sessions in memory per process and exposes no way to list them, so a turn
|
|
43
|
+
* started in another tab is invisible here.
|
|
44
|
+
*/
|
|
45
|
+
export function formatConversationSummary(count: number, running: boolean): string {
|
|
46
|
+
const conversations = count === 1 ? '1 conversation' : `${count} conversations`
|
|
47
|
+
return running ? `${conversations} · 1 running` : conversations
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** `Today · 14:32 · 6 messages` */
|
|
51
|
+
export function formatConversationMeta(
|
|
52
|
+
updatedAt: number,
|
|
53
|
+
messageCount: number,
|
|
54
|
+
now?: number,
|
|
55
|
+
): string {
|
|
56
|
+
return [formatDay(updatedAt, now), formatClock(updatedAt), formatMessageCount(messageCount)].join(
|
|
57
|
+
' · ',
|
|
58
|
+
)
|
|
59
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The headless half of the package: contract types, SSE transport, the turn
|
|
3
|
+
* reducer and the conversation store. No React, no DOM.
|
|
4
|
+
*
|
|
5
|
+
* Exported as a subpath (`@tetherto/mdk-ui-agent/core`) so a consumer building
|
|
6
|
+
* its own chat surface can reuse the protocol work without the components.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type {
|
|
10
|
+
ChatMessage,
|
|
11
|
+
Conversation,
|
|
12
|
+
ConversationActions,
|
|
13
|
+
ConversationState,
|
|
14
|
+
ConversationStorage,
|
|
15
|
+
ConversationStore,
|
|
16
|
+
ConversationStoreOptions,
|
|
17
|
+
MessageRole,
|
|
18
|
+
} from './conversation-store'
|
|
19
|
+
export {
|
|
20
|
+
CONVERSATION_STORAGE_KEY,
|
|
21
|
+
conversationStore,
|
|
22
|
+
createConversationStore,
|
|
23
|
+
deriveTitle,
|
|
24
|
+
MAX_CONVERSATIONS,
|
|
25
|
+
MAX_MESSAGES_PER_CONVERSATION,
|
|
26
|
+
MAX_PERSISTED_TOOL_TEXT_CHARS,
|
|
27
|
+
mergeConversations,
|
|
28
|
+
MESSAGE_ROLE,
|
|
29
|
+
newConversation,
|
|
30
|
+
} from './conversation-store'
|
|
31
|
+
|
|
32
|
+
export { describeAgentError, GENERIC_TURN_ERROR } from './error-text'
|
|
33
|
+
|
|
34
|
+
export type {
|
|
35
|
+
AgentEvent,
|
|
36
|
+
DoneEvent,
|
|
37
|
+
ErrorEvent,
|
|
38
|
+
EventType,
|
|
39
|
+
PendingApprovalEvent,
|
|
40
|
+
TerminalEventType,
|
|
41
|
+
TokenEvent,
|
|
42
|
+
ToolArgs,
|
|
43
|
+
ToolCallEvent,
|
|
44
|
+
ToolResultEvent,
|
|
45
|
+
WireEvent,
|
|
46
|
+
} from './events'
|
|
47
|
+
export {
|
|
48
|
+
AGENT_CONTRACT_VERSION,
|
|
49
|
+
EVENT,
|
|
50
|
+
isAgentEvent,
|
|
51
|
+
isTerminal,
|
|
52
|
+
isWireEvent,
|
|
53
|
+
TERMINAL_EVENTS,
|
|
54
|
+
} from './events'
|
|
55
|
+
|
|
56
|
+
export {
|
|
57
|
+
formatClock,
|
|
58
|
+
formatConversationMeta,
|
|
59
|
+
formatConversationSummary,
|
|
60
|
+
formatDay,
|
|
61
|
+
formatMessageCount,
|
|
62
|
+
} from './format'
|
|
63
|
+
|
|
64
|
+
export { clipToBoundary, unwrapWholeAnswerFence } from './markdown'
|
|
65
|
+
|
|
66
|
+
export type { LeakedToolCall } from './prose'
|
|
67
|
+
|
|
68
|
+
export { parseLeakedToolCall } from './prose'
|
|
69
|
+
export { isSmallTalk } from './small-talk'
|
|
70
|
+
|
|
71
|
+
export type { ReadAgentStreamOptions, ReadFramesOptions } from './sse'
|
|
72
|
+
export {
|
|
73
|
+
APPROVAL_IDLE_TIMEOUT_MS,
|
|
74
|
+
ERR_STREAM_IDLE,
|
|
75
|
+
ERR_STREAM_TRUNCATED,
|
|
76
|
+
FIRST_EVENT_IDLE_TIMEOUT_MS,
|
|
77
|
+
parseFrame,
|
|
78
|
+
readAgentStream,
|
|
79
|
+
readFrames,
|
|
80
|
+
STREAM_IDLE_TIMEOUT_MS,
|
|
81
|
+
StreamIdleError,
|
|
82
|
+
} from './sse'
|
|
83
|
+
|
|
84
|
+
export type { ToolLabels } from './tool-label'
|
|
85
|
+
export { formatDuration, humanizeToolName, toolLabel } from './tool-label'
|
|
86
|
+
|
|
87
|
+
export type {
|
|
88
|
+
AgentErrorCode,
|
|
89
|
+
AgentTransportOptions,
|
|
90
|
+
StreamTurnOptions,
|
|
91
|
+
TokenSource,
|
|
92
|
+
} from './transport'
|
|
93
|
+
export {
|
|
94
|
+
AGENT_ERROR,
|
|
95
|
+
AGENT_ROUTE_PREFIX,
|
|
96
|
+
AgentApiError,
|
|
97
|
+
createSession,
|
|
98
|
+
decideApproval,
|
|
99
|
+
deleteSession,
|
|
100
|
+
streamTurn,
|
|
101
|
+
} from './transport'
|
|
102
|
+
|
|
103
|
+
export type { AgentTurnState, PendingApproval, ToolStatus, ToolStep, TurnStatus } from './turn'
|
|
104
|
+
export {
|
|
105
|
+
abortTurn,
|
|
106
|
+
createTurnState,
|
|
107
|
+
failTurn,
|
|
108
|
+
isRejectionResult,
|
|
109
|
+
isTurnSettled,
|
|
110
|
+
reduceTurn,
|
|
111
|
+
TOOL_STATUS,
|
|
112
|
+
TURN_STATUS,
|
|
113
|
+
} from './turn'
|
|
114
|
+
|
|
115
|
+
export { uuid } from './uuid'
|