@open-mercato/ui 0.5.1-develop.3036.f02c281f23 → 0.5.1-develop.3045.b4b3320cc2
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/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +2 -1
- package/__integration__/TC-AI-UI-003-aichat-registry.spec.tsx +204 -0
- package/dist/ai/AiAssistantLauncher.js +596 -0
- package/dist/ai/AiAssistantLauncher.js.map +7 -0
- package/dist/ai/AiChat.js +1092 -0
- package/dist/ai/AiChat.js.map +7 -0
- package/dist/ai/AiChatSessions.js +297 -0
- package/dist/ai/AiChatSessions.js.map +7 -0
- package/dist/ai/AiDock.js +347 -0
- package/dist/ai/AiDock.js.map +7 -0
- package/dist/ai/AiMessageContent.js +369 -0
- package/dist/ai/AiMessageContent.js.map +7 -0
- package/dist/ai/ChatPaneTabs.js +251 -0
- package/dist/ai/ChatPaneTabs.js.map +7 -0
- package/dist/ai/index.js +115 -0
- package/dist/ai/index.js.map +7 -0
- package/dist/ai/parts/ConfirmationCard.js +211 -0
- package/dist/ai/parts/ConfirmationCard.js.map +7 -0
- package/dist/ai/parts/FieldDiffCard.js +119 -0
- package/dist/ai/parts/FieldDiffCard.js.map +7 -0
- package/dist/ai/parts/MutationPreviewCard.js +224 -0
- package/dist/ai/parts/MutationPreviewCard.js.map +7 -0
- package/dist/ai/parts/MutationResultCard.js +240 -0
- package/dist/ai/parts/MutationResultCard.js.map +7 -0
- package/dist/ai/parts/approval-cards-map.js +15 -0
- package/dist/ai/parts/approval-cards-map.js.map +7 -0
- package/dist/ai/parts/index.js +24 -0
- package/dist/ai/parts/index.js.map +7 -0
- package/dist/ai/parts/pending-action-api.js +60 -0
- package/dist/ai/parts/pending-action-api.js.map +7 -0
- package/dist/ai/parts/types.js +1 -0
- package/dist/ai/parts/types.js.map +7 -0
- package/dist/ai/parts/useAiPendingActionPolling.js +126 -0
- package/dist/ai/parts/useAiPendingActionPolling.js.map +7 -0
- package/dist/ai/records/ActivityCard.js +83 -0
- package/dist/ai/records/ActivityCard.js.map +7 -0
- package/dist/ai/records/CompanyCard.js +81 -0
- package/dist/ai/records/CompanyCard.js.map +7 -0
- package/dist/ai/records/DealCard.js +76 -0
- package/dist/ai/records/DealCard.js.map +7 -0
- package/dist/ai/records/PersonCard.js +68 -0
- package/dist/ai/records/PersonCard.js.map +7 -0
- package/dist/ai/records/ProductCard.js +68 -0
- package/dist/ai/records/ProductCard.js.map +7 -0
- package/dist/ai/records/RecordCard.js +29 -0
- package/dist/ai/records/RecordCard.js.map +7 -0
- package/dist/ai/records/RecordCardShell.js +103 -0
- package/dist/ai/records/RecordCardShell.js.map +7 -0
- package/dist/ai/records/index.js +31 -0
- package/dist/ai/records/index.js.map +7 -0
- package/dist/ai/records/registry.js +51 -0
- package/dist/ai/records/registry.js.map +7 -0
- package/dist/ai/records/types.js +1 -0
- package/dist/ai/records/types.js.map +7 -0
- package/dist/ai/ui-part-registry.js +112 -0
- package/dist/ai/ui-part-registry.js.map +7 -0
- package/dist/ai/ui-part-slots.js +14 -0
- package/dist/ai/ui-part-slots.js.map +7 -0
- package/dist/ai/ui-parts/pending-phase3-placeholder.js +35 -0
- package/dist/ai/ui-parts/pending-phase3-placeholder.js.map +7 -0
- package/dist/ai/upload-adapter.js +256 -0
- package/dist/ai/upload-adapter.js.map +7 -0
- package/dist/ai/useAiChat.js +549 -0
- package/dist/ai/useAiChat.js.map +7 -0
- package/dist/ai/useAiChatUpload.js +127 -0
- package/dist/ai/useAiChatUpload.js.map +7 -0
- package/dist/ai/useAiShortcuts.js +43 -0
- package/dist/ai/useAiShortcuts.js.map +7 -0
- package/dist/backend/AppShell.js +8 -4
- package/dist/backend/AppShell.js.map +2 -2
- package/dist/backend/BackendChromeProvider.js +2 -0
- package/dist/backend/BackendChromeProvider.js.map +2 -2
- package/dist/backend/DataTable.js +19 -2
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/FilterBar.js +19 -15
- package/dist/backend/FilterBar.js.map +2 -2
- package/dist/backend/dashboard/DashboardScreen.js +31 -3
- package/dist/backend/dashboard/DashboardScreen.js.map +2 -2
- package/dist/backend/injection/spotIds.js +6 -0
- package/dist/backend/injection/spotIds.js.map +2 -2
- package/dist/backend/notifications/useNotificationEffect.js +38 -2
- package/dist/backend/notifications/useNotificationEffect.js.map +2 -2
- package/dist/index.js +1 -0
- package/dist/index.js.map +2 -2
- package/jest.config.cjs +7 -1
- package/jest.markdown-mock.tsx +7 -0
- package/package.json +10 -4
- package/src/ai/AiAssistantLauncher.tsx +805 -0
- package/src/ai/AiChat.tsx +1483 -0
- package/src/ai/AiChatSessions.tsx +429 -0
- package/src/ai/AiDock.tsx +505 -0
- package/src/ai/AiMessageContent.tsx +515 -0
- package/src/ai/ChatPaneTabs.tsx +310 -0
- package/src/ai/__tests__/AiChat.conversation.test.tsx +160 -0
- package/src/ai/__tests__/AiChat.debug.test.tsx +152 -0
- package/src/ai/__tests__/AiChat.registry.test.tsx +213 -0
- package/src/ai/__tests__/AiChat.test.tsx +257 -0
- package/src/ai/__tests__/AiDock.test.tsx +124 -0
- package/src/ai/__tests__/AiMessageContent.test.ts +111 -0
- package/src/ai/__tests__/ui-part-registry.test.ts +199 -0
- package/src/ai/__tests__/ui-part-slots.test.ts +43 -0
- package/src/ai/__tests__/upload-adapter.test.ts +213 -0
- package/src/ai/__tests__/useAiChatUpload.test.tsx +163 -0
- package/src/ai/__tests__/useAiShortcuts.test.tsx +100 -0
- package/src/ai/index.ts +125 -0
- package/src/ai/parts/ConfirmationCard.tsx +310 -0
- package/src/ai/parts/FieldDiffCard.tsx +173 -0
- package/src/ai/parts/MutationPreviewCard.tsx +302 -0
- package/src/ai/parts/MutationResultCard.tsx +360 -0
- package/src/ai/parts/__tests__/ConfirmationCard.test.tsx +169 -0
- package/src/ai/parts/__tests__/FieldDiffCard.test.tsx +74 -0
- package/src/ai/parts/__tests__/MutationPreviewCard.test.tsx +177 -0
- package/src/ai/parts/__tests__/MutationResultCard.test.tsx +127 -0
- package/src/ai/parts/__tests__/useAiPendingActionPolling.test.tsx +151 -0
- package/src/ai/parts/approval-cards-map.ts +24 -0
- package/src/ai/parts/index.ts +27 -0
- package/src/ai/parts/pending-action-api.ts +123 -0
- package/src/ai/parts/types.ts +84 -0
- package/src/ai/parts/useAiPendingActionPolling.ts +210 -0
- package/src/ai/records/ActivityCard.tsx +102 -0
- package/src/ai/records/CompanyCard.tsx +89 -0
- package/src/ai/records/DealCard.tsx +85 -0
- package/src/ai/records/PersonCard.tsx +77 -0
- package/src/ai/records/ProductCard.tsx +83 -0
- package/src/ai/records/RecordCard.tsx +37 -0
- package/src/ai/records/RecordCardShell.tsx +169 -0
- package/src/ai/records/index.ts +30 -0
- package/src/ai/records/registry.tsx +80 -0
- package/src/ai/records/types.ts +90 -0
- package/src/ai/ui-part-registry.ts +233 -0
- package/src/ai/ui-part-slots.ts +32 -0
- package/src/ai/ui-parts/pending-phase3-placeholder.tsx +50 -0
- package/src/ai/upload-adapter.ts +421 -0
- package/src/ai/useAiChat.ts +865 -0
- package/src/ai/useAiChatUpload.ts +180 -0
- package/src/ai/useAiShortcuts.ts +79 -0
- package/src/backend/AppShell.tsx +12 -5
- package/src/backend/BackendChromeProvider.tsx +2 -0
- package/src/backend/DataTable.tsx +20 -1
- package/src/backend/FilterBar.tsx +26 -13
- package/src/backend/__tests__/BackendChromeProvider.test.tsx +45 -0
- package/src/backend/dashboard/DashboardScreen.tsx +38 -3
- package/src/backend/dashboard/__tests__/DashboardScreen.test.tsx +24 -1
- package/src/backend/injection/spotIds.ts +6 -0
- package/src/backend/notifications/__tests__/useNotificationEffect.test.tsx +77 -0
- package/src/backend/notifications/useNotificationEffect.ts +47 -2
- package/src/index.ts +1 -0
|
@@ -0,0 +1,1483 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import {
|
|
5
|
+
Bot,
|
|
6
|
+
Check,
|
|
7
|
+
ChevronDown,
|
|
8
|
+
ChevronRight,
|
|
9
|
+
Copy,
|
|
10
|
+
Lightbulb,
|
|
11
|
+
Loader2,
|
|
12
|
+
Paperclip,
|
|
13
|
+
Plus,
|
|
14
|
+
Send,
|
|
15
|
+
Square,
|
|
16
|
+
User,
|
|
17
|
+
Wrench,
|
|
18
|
+
X,
|
|
19
|
+
} from 'lucide-react'
|
|
20
|
+
import ReactMarkdown from 'react-markdown'
|
|
21
|
+
import remarkGfm from 'remark-gfm'
|
|
22
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
23
|
+
import { cn } from '@open-mercato/shared/lib/utils'
|
|
24
|
+
import { Alert, AlertDescription, AlertTitle } from '../primitives/alert'
|
|
25
|
+
import { Button } from '../primitives/button'
|
|
26
|
+
import { IconButton } from '../primitives/icon-button'
|
|
27
|
+
import { Label } from '../primitives/label'
|
|
28
|
+
import { Textarea } from '../primitives/textarea'
|
|
29
|
+
import { parseAiContentSegments } from './AiMessageContent'
|
|
30
|
+
import { RecordCard } from './records/RecordCard'
|
|
31
|
+
import {
|
|
32
|
+
defaultAiUiPartRegistry,
|
|
33
|
+
isReservedAiUiPartId,
|
|
34
|
+
type AiUiPartComponentId,
|
|
35
|
+
type AiUiPartRegistry,
|
|
36
|
+
} from './ui-part-registry'
|
|
37
|
+
import {
|
|
38
|
+
useAiChat,
|
|
39
|
+
type AiChatMessage,
|
|
40
|
+
type AiChatMessageFile,
|
|
41
|
+
type AiChatMessageUiPart,
|
|
42
|
+
type AiChatToolCallSnapshot,
|
|
43
|
+
} from './useAiChat'
|
|
44
|
+
import { useAiChatUpload } from './useAiChatUpload'
|
|
45
|
+
import { useAiShortcuts } from './useAiShortcuts'
|
|
46
|
+
|
|
47
|
+
// Cap inline previews so we do not blow past localStorage quota (~5MB on most
|
|
48
|
+
// browsers). Images larger than this still upload + send to the LLM as inline
|
|
49
|
+
// base64 server-side; only the in-chat preview is dropped on reload.
|
|
50
|
+
const PREVIEW_DATA_URL_MAX_BYTES = 2 * 1024 * 1024
|
|
51
|
+
|
|
52
|
+
async function readFileAsDataUrl(file: File): Promise<string | undefined> {
|
|
53
|
+
if (!file.type.startsWith('image/')) return undefined
|
|
54
|
+
if (file.size > PREVIEW_DATA_URL_MAX_BYTES) return undefined
|
|
55
|
+
return new Promise<string | undefined>((resolve) => {
|
|
56
|
+
const reader = new FileReader()
|
|
57
|
+
reader.onload = () =>
|
|
58
|
+
resolve(typeof reader.result === 'string' ? reader.result : undefined)
|
|
59
|
+
reader.onerror = () => resolve(undefined)
|
|
60
|
+
try {
|
|
61
|
+
reader.readAsDataURL(file)
|
|
62
|
+
} catch {
|
|
63
|
+
resolve(undefined)
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Optional resolved-tool snapshot the host can feed into the debug panel.
|
|
70
|
+
* Step 4.6 wires this from the `GET /api/ai_assistant/ai/agents` response
|
|
71
|
+
* (`tools[]`). Step 5.3+ will replace the manual wiring with a streamed
|
|
72
|
+
* `debug` part once the dispatcher emits one.
|
|
73
|
+
*/
|
|
74
|
+
export interface AiChatDebugTool {
|
|
75
|
+
name: string
|
|
76
|
+
displayName?: string
|
|
77
|
+
isMutation?: boolean
|
|
78
|
+
requiredFeatures?: string[]
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Resolved prompt-section snapshot for the debug panel. Until Phase 3 Step
|
|
83
|
+
* 5.3 lands structured `PromptTemplate.sections`, hosts synthesise this
|
|
84
|
+
* from the agent's `systemPrompt` + additive overrides.
|
|
85
|
+
*/
|
|
86
|
+
export interface AiChatDebugPromptSection {
|
|
87
|
+
id: string
|
|
88
|
+
source?: 'default' | 'override' | 'placeholder'
|
|
89
|
+
text?: string
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Quick-action suggestion shown in the welcome state. */
|
|
93
|
+
export interface AiChatSuggestion {
|
|
94
|
+
label: string
|
|
95
|
+
prompt: string
|
|
96
|
+
icon?: React.ReactNode
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Context item displayed as a chip/pill in the chat header. */
|
|
100
|
+
export interface AiChatContextItem {
|
|
101
|
+
label: string
|
|
102
|
+
detail?: string
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface AiChatProps {
|
|
106
|
+
agent: string
|
|
107
|
+
apiPath?: string
|
|
108
|
+
pageContext?: Record<string, unknown>
|
|
109
|
+
attachmentIds?: string[]
|
|
110
|
+
initialMessages?: Array<{ role: 'user' | 'assistant'; content: string }>
|
|
111
|
+
debug?: boolean
|
|
112
|
+
className?: string
|
|
113
|
+
placeholder?: string
|
|
114
|
+
onMutationRequested?: (pendingActionId: string) => void
|
|
115
|
+
onError?: (err: { code?: string; message: string }) => void
|
|
116
|
+
/**
|
|
117
|
+
* Optional stable conversation id. Forwarded verbatim to
|
|
118
|
+
* `POST /api/ai_assistant/ai/chat` request bodies and reused across
|
|
119
|
+
* turns so the Step 5.6 `prepareMutation` idempotency hash stays
|
|
120
|
+
* stable across retries within the same chat. When omitted, the hook
|
|
121
|
+
* mints a fresh random id once on mount — remounting the component
|
|
122
|
+
* resets the conversation.
|
|
123
|
+
*/
|
|
124
|
+
conversationId?: string
|
|
125
|
+
/**
|
|
126
|
+
* Optional UI-part registry. Defaults to the module-global
|
|
127
|
+
* {@link defaultAiUiPartRegistry}. Pass a scoped registry from
|
|
128
|
+
* {@link createAiUiPartRegistry} when embedding multiple `<AiChat>`
|
|
129
|
+
* instances that should not share registrations (playground, tests).
|
|
130
|
+
*/
|
|
131
|
+
registry?: AiUiPartRegistry
|
|
132
|
+
/**
|
|
133
|
+
* Optional list of server-emitted UI parts to render inside the chat
|
|
134
|
+
* transcript. The registry resolves each part via `componentId`. Phase 3
|
|
135
|
+
* will populate this from the streamed dispatcher response; Phase 2 WS-A
|
|
136
|
+
* leaves the wiring exposed so hosts can preview the registry path.
|
|
137
|
+
*/
|
|
138
|
+
uiParts?: Array<{
|
|
139
|
+
componentId: AiUiPartComponentId
|
|
140
|
+
payload?: unknown
|
|
141
|
+
pendingActionId?: string
|
|
142
|
+
}>
|
|
143
|
+
/**
|
|
144
|
+
* Optional resolved-tool map for the debug panel. Ignored when
|
|
145
|
+
* `debug` is falsy.
|
|
146
|
+
*/
|
|
147
|
+
debugTools?: AiChatDebugTool[]
|
|
148
|
+
/**
|
|
149
|
+
* Optional resolved prompt sections for the debug panel. Ignored when
|
|
150
|
+
* `debug` is falsy.
|
|
151
|
+
*/
|
|
152
|
+
debugPromptSections?: AiChatDebugPromptSection[]
|
|
153
|
+
/** Suggested prompts shown in the empty / welcome state. */
|
|
154
|
+
suggestions?: AiChatSuggestion[]
|
|
155
|
+
/** Context items shown as pills above the transcript (e.g. selected products). */
|
|
156
|
+
contextItems?: AiChatContextItem[]
|
|
157
|
+
/** Welcome heading shown when there are no messages yet. */
|
|
158
|
+
welcomeTitle?: string
|
|
159
|
+
/** Welcome description shown below the heading. */
|
|
160
|
+
welcomeDescription?: string
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
interface ServerEmittedUiPartRef {
|
|
164
|
+
componentId: AiUiPartComponentId
|
|
165
|
+
payload?: unknown
|
|
166
|
+
pendingActionId?: string
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function mapErrorCodeToVariant(
|
|
170
|
+
code: string | undefined,
|
|
171
|
+
): 'destructive' | 'warning' {
|
|
172
|
+
if (!code) return 'destructive'
|
|
173
|
+
// Policy denies that describe a filtered tool or attachment surface a
|
|
174
|
+
// warning alert; caller can still continue. Hard denials (agent_unknown,
|
|
175
|
+
// agent_features_denied, unauthenticated, execution_mode_not_supported,
|
|
176
|
+
// mutation_blocked_by_*, validation_error) surface destructive alerts.
|
|
177
|
+
const warningCodes = new Set<string>([
|
|
178
|
+
'tool_not_whitelisted',
|
|
179
|
+
'tool_features_denied',
|
|
180
|
+
'attachment_type_not_accepted',
|
|
181
|
+
])
|
|
182
|
+
return warningCodes.has(code) ? 'warning' : 'destructive'
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const MARKDOWN_TYPOGRAPHY_CLASS = cn(
|
|
186
|
+
'[&_p]:my-1 [&_p:first-child]:mt-0 [&_p:last-child]:mb-0',
|
|
187
|
+
'[&_ul]:my-2 [&_ol]:my-2 [&_ul]:ml-4 [&_ol]:ml-4 [&_ul]:list-disc [&_ol]:list-decimal',
|
|
188
|
+
'[&_li]:my-0.5',
|
|
189
|
+
'[&_h1]:mt-3 [&_h1]:mb-2 [&_h1]:text-base [&_h1]:font-semibold',
|
|
190
|
+
'[&_h2]:mt-3 [&_h2]:mb-2 [&_h2]:text-sm [&_h2]:font-semibold',
|
|
191
|
+
'[&_h3]:mt-2 [&_h3]:mb-1 [&_h3]:text-sm [&_h3]:font-semibold',
|
|
192
|
+
'[&_a]:text-primary [&_a]:underline [&_a]:underline-offset-2',
|
|
193
|
+
'[&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-xs',
|
|
194
|
+
'[&_pre]:my-2 [&_pre]:overflow-x-auto [&_pre]:rounded-md [&_pre]:border [&_pre]:border-border [&_pre]:bg-muted [&_pre]:p-3',
|
|
195
|
+
'[&_pre_code]:bg-transparent [&_pre_code]:p-0',
|
|
196
|
+
'[&_blockquote]:my-2 [&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-3 [&_blockquote]:text-muted-foreground',
|
|
197
|
+
'[&_table]:my-2 [&_table]:w-full [&_table]:border-collapse [&_table]:text-xs',
|
|
198
|
+
'[&_th]:border [&_th]:border-border [&_th]:px-2 [&_th]:py-1 [&_th]:text-left [&_th]:font-medium',
|
|
199
|
+
'[&_td]:border [&_td]:border-border [&_td]:px-2 [&_td]:py-1',
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
const MARKDOWN_COMPONENTS = {
|
|
203
|
+
a: ({ node, ...props }: React.AnchorHTMLAttributes<HTMLAnchorElement> & { node?: unknown }) => (
|
|
204
|
+
<a {...props} target="_blank" rel="noreferrer" />
|
|
205
|
+
),
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function MarkdownChunk({ text }: { text: string }) {
|
|
209
|
+
if (!text.trim()) return null
|
|
210
|
+
return (
|
|
211
|
+
<div className={cn('text-sm', MARKDOWN_TYPOGRAPHY_CLASS)}>
|
|
212
|
+
<ReactMarkdown remarkPlugins={[remarkGfm]} components={MARKDOWN_COMPONENTS}>
|
|
213
|
+
{text}
|
|
214
|
+
</ReactMarkdown>
|
|
215
|
+
</div>
|
|
216
|
+
)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function MessageContent({ content, isAssistant }: { content: string; isAssistant: boolean }) {
|
|
220
|
+
if (!isAssistant) {
|
|
221
|
+
return <div className="whitespace-pre-wrap text-sm">{content}</div>
|
|
222
|
+
}
|
|
223
|
+
if (!content) {
|
|
224
|
+
return null
|
|
225
|
+
}
|
|
226
|
+
const segments = parseAiContentSegments(content)
|
|
227
|
+
if (segments.length === 0) {
|
|
228
|
+
return null
|
|
229
|
+
}
|
|
230
|
+
return (
|
|
231
|
+
<div className="space-y-1" data-ai-message-content="">
|
|
232
|
+
{segments.map((segment, index) => {
|
|
233
|
+
if (segment.kind === 'record-card') {
|
|
234
|
+
return <RecordCard key={`card-${index}`} data={segment.payload} />
|
|
235
|
+
}
|
|
236
|
+
if (segment.kind === 'invalid-card') {
|
|
237
|
+
return (
|
|
238
|
+
<pre
|
|
239
|
+
key={`raw-${index}`}
|
|
240
|
+
className="my-2 max-h-60 overflow-auto rounded-md border border-dashed border-border bg-muted p-2 text-xs"
|
|
241
|
+
data-ai-record-card-invalid={segment.info}
|
|
242
|
+
>
|
|
243
|
+
{segment.raw}
|
|
244
|
+
</pre>
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
return <MarkdownChunk key={`md-${index}`} text={segment.text} />
|
|
248
|
+
})}
|
|
249
|
+
</div>
|
|
250
|
+
)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function safeStringify(value: unknown): string {
|
|
254
|
+
if (value === null || value === undefined) return ''
|
|
255
|
+
if (typeof value === 'string') return value
|
|
256
|
+
try {
|
|
257
|
+
return JSON.stringify(value, null, 2)
|
|
258
|
+
} catch {
|
|
259
|
+
return String(value)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function ToolCallList({ toolCalls }: { toolCalls: AiChatToolCallSnapshot[] }) {
|
|
264
|
+
const t = useT()
|
|
265
|
+
const [openId, setOpenId] = React.useState<string | null>(null)
|
|
266
|
+
if (!toolCalls || toolCalls.length === 0) return null
|
|
267
|
+
return (
|
|
268
|
+
<div className="space-y-1" data-ai-chat-tool-calls="">
|
|
269
|
+
{toolCalls.map((call) => {
|
|
270
|
+
const isOpen = openId === call.id
|
|
271
|
+
const isError = call.state === 'error'
|
|
272
|
+
const isPending = call.state === 'pending'
|
|
273
|
+
const isComplete = call.state === 'complete'
|
|
274
|
+
const statusLabel = isError
|
|
275
|
+
? t('ai_assistant.chat.toolError', 'failed')
|
|
276
|
+
: isPending
|
|
277
|
+
? t('ai_assistant.chat.toolRunning', 'running…')
|
|
278
|
+
: t('ai_assistant.chat.toolDone', 'done')
|
|
279
|
+
return (
|
|
280
|
+
<div
|
|
281
|
+
key={call.id}
|
|
282
|
+
className={cn(
|
|
283
|
+
'rounded-md border border-border bg-muted/30',
|
|
284
|
+
isError ? 'border-destructive/40 bg-destructive/5' : '',
|
|
285
|
+
)}
|
|
286
|
+
data-ai-chat-tool-call={call.toolName}
|
|
287
|
+
data-ai-chat-tool-state={call.state}
|
|
288
|
+
>
|
|
289
|
+
<button
|
|
290
|
+
type="button"
|
|
291
|
+
onClick={() => setOpenId(isOpen ? null : call.id)}
|
|
292
|
+
className="flex w-full items-center gap-2 px-2 py-1.5 text-left text-xs font-medium hover:bg-muted/60"
|
|
293
|
+
>
|
|
294
|
+
{isOpen ? (
|
|
295
|
+
<ChevronDown className="size-3.5 text-muted-foreground" aria-hidden />
|
|
296
|
+
) : (
|
|
297
|
+
<ChevronRight className="size-3.5 text-muted-foreground" aria-hidden />
|
|
298
|
+
)}
|
|
299
|
+
{isPending ? (
|
|
300
|
+
<Loader2 className="size-3.5 animate-spin text-muted-foreground" aria-hidden />
|
|
301
|
+
) : (
|
|
302
|
+
<Wrench
|
|
303
|
+
className={cn(
|
|
304
|
+
'size-3.5',
|
|
305
|
+
isError ? 'text-destructive' : 'text-muted-foreground',
|
|
306
|
+
)}
|
|
307
|
+
aria-hidden
|
|
308
|
+
/>
|
|
309
|
+
)}
|
|
310
|
+
<span className="font-mono">{call.toolName}</span>
|
|
311
|
+
<span
|
|
312
|
+
className={cn(
|
|
313
|
+
'ml-auto text-[10px] uppercase tracking-wide',
|
|
314
|
+
isError
|
|
315
|
+
? 'text-destructive'
|
|
316
|
+
: isComplete
|
|
317
|
+
? 'text-status-success-text'
|
|
318
|
+
: 'text-muted-foreground',
|
|
319
|
+
)}
|
|
320
|
+
>
|
|
321
|
+
{statusLabel}
|
|
322
|
+
</span>
|
|
323
|
+
</button>
|
|
324
|
+
{isOpen ? (
|
|
325
|
+
<div className="space-y-1 border-t border-border/60 px-2 py-1.5 text-xs">
|
|
326
|
+
{call.input !== undefined ? (
|
|
327
|
+
<div>
|
|
328
|
+
<div className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
|
329
|
+
{t('ai_assistant.chat.toolInput', 'Input')}
|
|
330
|
+
</div>
|
|
331
|
+
<pre className="mt-0.5 max-h-32 overflow-auto rounded bg-background p-1.5 font-mono text-[11px]">
|
|
332
|
+
{safeStringify(call.input)}
|
|
333
|
+
</pre>
|
|
334
|
+
</div>
|
|
335
|
+
) : null}
|
|
336
|
+
{call.output !== undefined && !isError ? (
|
|
337
|
+
<div>
|
|
338
|
+
<div className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
|
339
|
+
{t('ai_assistant.chat.toolOutput', 'Output')}
|
|
340
|
+
</div>
|
|
341
|
+
<pre className="mt-0.5 max-h-32 overflow-auto rounded bg-background p-1.5 font-mono text-[11px]">
|
|
342
|
+
{safeStringify(call.output)}
|
|
343
|
+
</pre>
|
|
344
|
+
</div>
|
|
345
|
+
) : null}
|
|
346
|
+
{call.errorMessage ? (
|
|
347
|
+
<div className="text-destructive">{call.errorMessage}</div>
|
|
348
|
+
) : null}
|
|
349
|
+
</div>
|
|
350
|
+
) : null}
|
|
351
|
+
</div>
|
|
352
|
+
)
|
|
353
|
+
})}
|
|
354
|
+
</div>
|
|
355
|
+
)
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function ReasoningPanel({ text, streaming }: { text: string; streaming: boolean }) {
|
|
359
|
+
const t = useT()
|
|
360
|
+
const [open, setOpen] = React.useState(false)
|
|
361
|
+
if (!text) return null
|
|
362
|
+
return (
|
|
363
|
+
<div
|
|
364
|
+
className="rounded-md border border-border bg-muted/30"
|
|
365
|
+
data-ai-chat-reasoning={streaming ? 'streaming' : 'complete'}
|
|
366
|
+
>
|
|
367
|
+
<button
|
|
368
|
+
type="button"
|
|
369
|
+
onClick={() => setOpen((o) => !o)}
|
|
370
|
+
className="flex w-full items-center gap-2 px-2 py-1.5 text-left text-xs font-medium hover:bg-muted/60"
|
|
371
|
+
>
|
|
372
|
+
{open ? (
|
|
373
|
+
<ChevronDown className="size-3.5 text-muted-foreground" aria-hidden />
|
|
374
|
+
) : (
|
|
375
|
+
<ChevronRight className="size-3.5 text-muted-foreground" aria-hidden />
|
|
376
|
+
)}
|
|
377
|
+
<Lightbulb className="size-3.5 text-muted-foreground" aria-hidden />
|
|
378
|
+
<span>{t('ai_assistant.chat.reasoning', 'Reasoning')}</span>
|
|
379
|
+
{streaming ? (
|
|
380
|
+
<Loader2
|
|
381
|
+
className="ml-1 size-3 animate-spin text-muted-foreground"
|
|
382
|
+
aria-hidden
|
|
383
|
+
/>
|
|
384
|
+
) : null}
|
|
385
|
+
</button>
|
|
386
|
+
{open ? (
|
|
387
|
+
<pre className="max-h-48 overflow-auto whitespace-pre-wrap border-t border-border/60 px-2 py-1.5 text-xs text-muted-foreground">
|
|
388
|
+
{text}
|
|
389
|
+
</pre>
|
|
390
|
+
) : null}
|
|
391
|
+
</div>
|
|
392
|
+
)
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function MessageRow({
|
|
396
|
+
message,
|
|
397
|
+
registry,
|
|
398
|
+
onMutationRequested,
|
|
399
|
+
}: {
|
|
400
|
+
message: AiChatMessage
|
|
401
|
+
registry?: AiUiPartRegistry
|
|
402
|
+
onMutationRequested?: (pendingActionId: string) => void
|
|
403
|
+
}) {
|
|
404
|
+
const t = useT()
|
|
405
|
+
const isAssistant = message.role === 'assistant'
|
|
406
|
+
const label = isAssistant
|
|
407
|
+
? t('ai_assistant.chat.assistantRoleLabel', 'Assistant')
|
|
408
|
+
: t('ai_assistant.chat.userRoleLabel', 'You')
|
|
409
|
+
const Icon = isAssistant ? Bot : User
|
|
410
|
+
const [copied, setCopied] = React.useState(false)
|
|
411
|
+
const copyTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
412
|
+
|
|
413
|
+
React.useEffect(() => {
|
|
414
|
+
return () => {
|
|
415
|
+
if (copyTimerRef.current) clearTimeout(copyTimerRef.current)
|
|
416
|
+
}
|
|
417
|
+
}, [])
|
|
418
|
+
|
|
419
|
+
const handleCopy = React.useCallback(async () => {
|
|
420
|
+
const text = message.content
|
|
421
|
+
if (!text) return
|
|
422
|
+
try {
|
|
423
|
+
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
|
|
424
|
+
await navigator.clipboard.writeText(text)
|
|
425
|
+
} else {
|
|
426
|
+
const textarea = document.createElement('textarea')
|
|
427
|
+
textarea.value = text
|
|
428
|
+
textarea.setAttribute('readonly', '')
|
|
429
|
+
textarea.style.position = 'absolute'
|
|
430
|
+
textarea.style.left = '-9999px'
|
|
431
|
+
document.body.appendChild(textarea)
|
|
432
|
+
textarea.select()
|
|
433
|
+
document.execCommand('copy')
|
|
434
|
+
document.body.removeChild(textarea)
|
|
435
|
+
}
|
|
436
|
+
setCopied(true)
|
|
437
|
+
if (copyTimerRef.current) clearTimeout(copyTimerRef.current)
|
|
438
|
+
copyTimerRef.current = setTimeout(() => setCopied(false), 1500)
|
|
439
|
+
} catch {
|
|
440
|
+
// Clipboard API blocked (no permission, http context) — silently fail.
|
|
441
|
+
}
|
|
442
|
+
}, [message.content])
|
|
443
|
+
|
|
444
|
+
return (
|
|
445
|
+
<div
|
|
446
|
+
className={cn(
|
|
447
|
+
'group/message flex gap-3 px-3 py-2',
|
|
448
|
+
isAssistant ? 'bg-muted/40 rounded-md' : '',
|
|
449
|
+
)}
|
|
450
|
+
data-role={message.role}
|
|
451
|
+
>
|
|
452
|
+
<div
|
|
453
|
+
className={cn(
|
|
454
|
+
'flex size-6 shrink-0 items-center justify-center rounded-full',
|
|
455
|
+
isAssistant
|
|
456
|
+
? 'bg-primary/10 text-primary'
|
|
457
|
+
: 'bg-secondary text-secondary-foreground',
|
|
458
|
+
)}
|
|
459
|
+
aria-hidden
|
|
460
|
+
>
|
|
461
|
+
<Icon className="size-4" />
|
|
462
|
+
</div>
|
|
463
|
+
<div className="flex-1 space-y-1">
|
|
464
|
+
<div className="flex items-center justify-between gap-2">
|
|
465
|
+
<div className="text-xs font-medium text-muted-foreground">{label}</div>
|
|
466
|
+
{message.content ? (
|
|
467
|
+
<IconButton
|
|
468
|
+
type="button"
|
|
469
|
+
variant="ghost"
|
|
470
|
+
size="sm"
|
|
471
|
+
onClick={handleCopy}
|
|
472
|
+
aria-label={
|
|
473
|
+
copied
|
|
474
|
+
? t('ai_assistant.chat.copied', 'Copied')
|
|
475
|
+
: t('ai_assistant.chat.copyMessage', 'Copy message')
|
|
476
|
+
}
|
|
477
|
+
data-ai-chat-copy-button=""
|
|
478
|
+
className="opacity-0 transition-opacity group-hover/message:opacity-100 focus-visible:opacity-100"
|
|
479
|
+
>
|
|
480
|
+
{copied ? (
|
|
481
|
+
<Check className="size-3.5 text-status-success-icon" aria-hidden />
|
|
482
|
+
) : (
|
|
483
|
+
<Copy className="size-3.5" aria-hidden />
|
|
484
|
+
)}
|
|
485
|
+
</IconButton>
|
|
486
|
+
) : null}
|
|
487
|
+
</div>
|
|
488
|
+
{message.files && message.files.length > 0 ? (
|
|
489
|
+
<div className="flex flex-wrap gap-2 py-1">
|
|
490
|
+
{message.files.map((file, i) =>
|
|
491
|
+
file.previewUrl ? (
|
|
492
|
+
<img
|
|
493
|
+
key={i}
|
|
494
|
+
src={file.previewUrl}
|
|
495
|
+
alt={file.name}
|
|
496
|
+
className="max-h-32 max-w-[200px] rounded-md border border-border object-cover"
|
|
497
|
+
/>
|
|
498
|
+
) : (
|
|
499
|
+
<span
|
|
500
|
+
key={i}
|
|
501
|
+
className="inline-flex items-center gap-1 rounded-full border border-border bg-muted px-2 py-0.5 text-xs"
|
|
502
|
+
>
|
|
503
|
+
<Paperclip className="size-3" aria-hidden />
|
|
504
|
+
{file.name}
|
|
505
|
+
</span>
|
|
506
|
+
),
|
|
507
|
+
)}
|
|
508
|
+
</div>
|
|
509
|
+
) : null}
|
|
510
|
+
{isAssistant && message.reasoning ? (
|
|
511
|
+
<ReasoningPanel
|
|
512
|
+
text={message.reasoning}
|
|
513
|
+
streaming={message.reasoningStreaming === true}
|
|
514
|
+
/>
|
|
515
|
+
) : null}
|
|
516
|
+
{isAssistant && message.toolCalls && message.toolCalls.length > 0 ? (
|
|
517
|
+
<ToolCallList toolCalls={message.toolCalls} />
|
|
518
|
+
) : null}
|
|
519
|
+
<MessageContent content={message.content} isAssistant={isAssistant} />
|
|
520
|
+
{isAssistant && registry && message.uiParts && message.uiParts.length > 0 ? (
|
|
521
|
+
<MessageUiParts
|
|
522
|
+
parts={message.uiParts}
|
|
523
|
+
registry={registry}
|
|
524
|
+
onMutationRequested={onMutationRequested}
|
|
525
|
+
/>
|
|
526
|
+
) : null}
|
|
527
|
+
</div>
|
|
528
|
+
</div>
|
|
529
|
+
)
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function MessageUiParts({
|
|
533
|
+
parts,
|
|
534
|
+
registry,
|
|
535
|
+
onMutationRequested,
|
|
536
|
+
}: {
|
|
537
|
+
parts: AiChatMessageUiPart[]
|
|
538
|
+
registry: AiUiPartRegistry
|
|
539
|
+
onMutationRequested?: (pendingActionId: string) => void
|
|
540
|
+
}) {
|
|
541
|
+
return (
|
|
542
|
+
<div className="mt-2 flex flex-col gap-2" data-ai-message-ui-parts="">
|
|
543
|
+
{parts.map((part) => (
|
|
544
|
+
<AiUiPartRenderer
|
|
545
|
+
key={part.key}
|
|
546
|
+
part={{
|
|
547
|
+
componentId: part.componentId as AiUiPartComponentId,
|
|
548
|
+
payload: part.payload,
|
|
549
|
+
pendingActionId: part.pendingActionId,
|
|
550
|
+
}}
|
|
551
|
+
registry={registry}
|
|
552
|
+
onMutationRequested={onMutationRequested}
|
|
553
|
+
/>
|
|
554
|
+
))}
|
|
555
|
+
</div>
|
|
556
|
+
)
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function UnknownUiPartPlaceholder({ componentId }: { componentId: AiUiPartComponentId }) {
|
|
560
|
+
const t = useT()
|
|
561
|
+
return (
|
|
562
|
+
<div
|
|
563
|
+
className="inline-flex items-center gap-2 rounded-full border border-dashed border-border bg-muted px-3 py-1 text-xs text-muted-foreground"
|
|
564
|
+
data-ai-ui-part-placeholder={componentId}
|
|
565
|
+
>
|
|
566
|
+
<span>
|
|
567
|
+
{t('ai_assistant.chat.uiPartPending', 'Pending UI part:')} {componentId}
|
|
568
|
+
</span>
|
|
569
|
+
</div>
|
|
570
|
+
)
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function AiUiPartRenderer({
|
|
574
|
+
part,
|
|
575
|
+
registry,
|
|
576
|
+
onMutationRequested,
|
|
577
|
+
}: {
|
|
578
|
+
part: ServerEmittedUiPartRef
|
|
579
|
+
registry: AiUiPartRegistry
|
|
580
|
+
onMutationRequested?: (pendingActionId: string) => void
|
|
581
|
+
}) {
|
|
582
|
+
const Component = registry.resolve(part.componentId)
|
|
583
|
+
const isReserved = isReservedAiUiPartId(part.componentId)
|
|
584
|
+
React.useEffect(() => {
|
|
585
|
+
if (Component) return
|
|
586
|
+
if (isReserved) return
|
|
587
|
+
try {
|
|
588
|
+
// eslint-disable-next-line no-console
|
|
589
|
+
console.warn(
|
|
590
|
+
`[AiChat] No component registered for UI part "${part.componentId}".`,
|
|
591
|
+
)
|
|
592
|
+
} catch {
|
|
593
|
+
// noop
|
|
594
|
+
}
|
|
595
|
+
}, [Component, part.componentId, isReserved])
|
|
596
|
+
React.useEffect(() => {
|
|
597
|
+
if (part.pendingActionId) {
|
|
598
|
+
onMutationRequested?.(part.pendingActionId)
|
|
599
|
+
}
|
|
600
|
+
}, [part.pendingActionId, onMutationRequested])
|
|
601
|
+
if (!Component) {
|
|
602
|
+
return <UnknownUiPartPlaceholder componentId={part.componentId} />
|
|
603
|
+
}
|
|
604
|
+
return (
|
|
605
|
+
<Component
|
|
606
|
+
componentId={part.componentId}
|
|
607
|
+
payload={part.payload}
|
|
608
|
+
pendingActionId={part.pendingActionId}
|
|
609
|
+
/>
|
|
610
|
+
)
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function WelcomeState({
|
|
614
|
+
title,
|
|
615
|
+
description,
|
|
616
|
+
suggestions,
|
|
617
|
+
onSuggestionClick,
|
|
618
|
+
}: {
|
|
619
|
+
title?: string
|
|
620
|
+
description?: string
|
|
621
|
+
suggestions?: AiChatSuggestion[]
|
|
622
|
+
onSuggestionClick: (prompt: string) => void
|
|
623
|
+
}) {
|
|
624
|
+
const t = useT()
|
|
625
|
+
const heading = title ?? t('ai_assistant.chat.welcomeTitle', 'How can I help?')
|
|
626
|
+
const desc =
|
|
627
|
+
description ??
|
|
628
|
+
t(
|
|
629
|
+
'ai_assistant.chat.welcomeDescription',
|
|
630
|
+
'Ask me anything about your data. Here are some things I can do:',
|
|
631
|
+
)
|
|
632
|
+
return (
|
|
633
|
+
<div className="flex flex-1 flex-col items-center justify-center gap-4 px-4 py-8">
|
|
634
|
+
<div
|
|
635
|
+
className="flex size-12 items-center justify-center rounded-full bg-primary/10 text-primary"
|
|
636
|
+
aria-hidden
|
|
637
|
+
>
|
|
638
|
+
<Bot className="size-6" />
|
|
639
|
+
</div>
|
|
640
|
+
<div className="space-y-1 text-center">
|
|
641
|
+
<h3 className="text-sm font-semibold">{heading}</h3>
|
|
642
|
+
<p className="text-xs text-muted-foreground">{desc}</p>
|
|
643
|
+
</div>
|
|
644
|
+
{suggestions && suggestions.length > 0 ? (
|
|
645
|
+
<div className="flex w-full max-w-md flex-col gap-2" data-ai-chat-suggestions="">
|
|
646
|
+
{suggestions.map((suggestion, index) => (
|
|
647
|
+
<button
|
|
648
|
+
key={index}
|
|
649
|
+
type="button"
|
|
650
|
+
className="flex items-center gap-2 rounded-lg border border-border bg-card px-3 py-2.5 text-left text-sm transition-colors hover:bg-accent hover:text-accent-foreground"
|
|
651
|
+
onClick={() => onSuggestionClick(suggestion.prompt)}
|
|
652
|
+
data-ai-chat-suggestion={index}
|
|
653
|
+
>
|
|
654
|
+
{suggestion.icon ? (
|
|
655
|
+
<span className="shrink-0 text-muted-foreground" aria-hidden>
|
|
656
|
+
{suggestion.icon}
|
|
657
|
+
</span>
|
|
658
|
+
) : null}
|
|
659
|
+
<span>{suggestion.label}</span>
|
|
660
|
+
</button>
|
|
661
|
+
))}
|
|
662
|
+
</div>
|
|
663
|
+
) : null}
|
|
664
|
+
</div>
|
|
665
|
+
)
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function ContextItemsPill({ items }: { items: AiChatContextItem[] }) {
|
|
669
|
+
if (items.length === 0) return null
|
|
670
|
+
return (
|
|
671
|
+
<div
|
|
672
|
+
className="flex flex-wrap gap-1.5 border-b border-border px-3 py-2"
|
|
673
|
+
data-ai-chat-context-items=""
|
|
674
|
+
>
|
|
675
|
+
{items.map((item, index) => (
|
|
676
|
+
<span
|
|
677
|
+
key={index}
|
|
678
|
+
className="inline-flex items-center gap-1 rounded-full border border-border bg-secondary px-2 py-0.5 text-xs text-secondary-foreground"
|
|
679
|
+
data-ai-chat-context-item={index}
|
|
680
|
+
title={item.detail}
|
|
681
|
+
>
|
|
682
|
+
{item.label}
|
|
683
|
+
</span>
|
|
684
|
+
))}
|
|
685
|
+
</div>
|
|
686
|
+
)
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Embeddable AI chat component. Binds to the dispatcher route
|
|
691
|
+
* `POST /api/ai_assistant/ai/chat?agent=<module>.<agent>` via
|
|
692
|
+
* {@link createAiAgentTransport}. Phase 2 WS-A deliverable (Step 4.1).
|
|
693
|
+
*
|
|
694
|
+
* - Keyboard: `Enter` submits; `Shift+Enter` inserts a newline; `Escape`
|
|
695
|
+
* aborts streaming (or blurs the composer when idle).
|
|
696
|
+
* - Error envelopes from the dispatcher surface as `Alert` + `onError`.
|
|
697
|
+
* - UI parts render via the client-side registry; unknown parts render a
|
|
698
|
+
* neutral placeholder chip so mutation-card slots reserved for Phase 3
|
|
699
|
+
* never throw before their implementations land.
|
|
700
|
+
*/
|
|
701
|
+
export function AiChat({
|
|
702
|
+
agent,
|
|
703
|
+
apiPath,
|
|
704
|
+
pageContext,
|
|
705
|
+
attachmentIds,
|
|
706
|
+
initialMessages,
|
|
707
|
+
debug,
|
|
708
|
+
className,
|
|
709
|
+
placeholder,
|
|
710
|
+
onMutationRequested,
|
|
711
|
+
onError,
|
|
712
|
+
registry,
|
|
713
|
+
uiParts: uiPartsProp,
|
|
714
|
+
debugTools,
|
|
715
|
+
debugPromptSections,
|
|
716
|
+
conversationId,
|
|
717
|
+
suggestions,
|
|
718
|
+
contextItems,
|
|
719
|
+
welcomeTitle,
|
|
720
|
+
welcomeDescription,
|
|
721
|
+
}: AiChatProps) {
|
|
722
|
+
const t = useT()
|
|
723
|
+
const textareaRef = React.useRef<HTMLTextAreaElement | null>(null)
|
|
724
|
+
const transcriptRef = React.useRef<HTMLDivElement | null>(null)
|
|
725
|
+
const fileInputRef = React.useRef<HTMLInputElement | null>(null)
|
|
726
|
+
const [input, setInput] = React.useState('')
|
|
727
|
+
type PendingAttachment = {
|
|
728
|
+
file: File
|
|
729
|
+
attachmentId?: string
|
|
730
|
+
/**
|
|
731
|
+
* Base64 data URL of the image (capped by PREVIEW_DATA_URL_MAX_BYTES).
|
|
732
|
+
* Stored on the message so the preview survives a reload — durable
|
|
733
|
+
* server URLs were intentionally avoided because the LLM provider can
|
|
734
|
+
* never reach a localhost dev URL anyway, and HTTP fetches add latency
|
|
735
|
+
* the chat doesn't need.
|
|
736
|
+
*/
|
|
737
|
+
previewDataUrl?: string
|
|
738
|
+
error?: string
|
|
739
|
+
}
|
|
740
|
+
const [pendingFiles, setPendingFiles] = React.useState<PendingAttachment[]>([])
|
|
741
|
+
const upload = useAiChatUpload()
|
|
742
|
+
const isUploading = upload.busy
|
|
743
|
+
|
|
744
|
+
const uploadedAttachmentIds = React.useMemo(
|
|
745
|
+
() =>
|
|
746
|
+
pendingFiles
|
|
747
|
+
.map((entry) => entry.attachmentId)
|
|
748
|
+
.filter((id): id is string => typeof id === 'string' && id.length > 0),
|
|
749
|
+
[pendingFiles],
|
|
750
|
+
)
|
|
751
|
+
|
|
752
|
+
const allAttachmentIds = React.useMemo(
|
|
753
|
+
() => [...(attachmentIds ?? []), ...uploadedAttachmentIds],
|
|
754
|
+
[attachmentIds, uploadedAttachmentIds],
|
|
755
|
+
)
|
|
756
|
+
|
|
757
|
+
const chat = useAiChat({
|
|
758
|
+
agent,
|
|
759
|
+
apiPath,
|
|
760
|
+
pageContext,
|
|
761
|
+
attachmentIds: allAttachmentIds.length > 0 ? allAttachmentIds : undefined,
|
|
762
|
+
debug,
|
|
763
|
+
initialMessages,
|
|
764
|
+
onError,
|
|
765
|
+
conversationId,
|
|
766
|
+
})
|
|
767
|
+
|
|
768
|
+
const isStreaming = chat.status === 'streaming'
|
|
769
|
+
const isSubmitting = chat.status === 'submitting'
|
|
770
|
+
const isBusy = isStreaming || isSubmitting
|
|
771
|
+
|
|
772
|
+
// Surface a "Thinking..." placeholder so the chat does not look frozen.
|
|
773
|
+
// Visible whenever ANY of the following is true while a turn is in flight:
|
|
774
|
+
// (a) we're still in the submit phase before the first stream chunk
|
|
775
|
+
// (b) streaming, but no content / reasoning / tool calls have arrived yet
|
|
776
|
+
// (c) streaming, and at least one tool call is still in `pending` state
|
|
777
|
+
// (the model is waiting on a tool result — the previous version
|
|
778
|
+
// treated `toolCalls.length > 0` as "has content" and hid the
|
|
779
|
+
// indicator the moment the first tool started, even though the
|
|
780
|
+
// model had not produced any user-visible output yet)
|
|
781
|
+
// (d) streaming, and the last visible event was a finished tool call
|
|
782
|
+
// — the model is reasoning about the result before emitting more
|
|
783
|
+
// text or kicking off the next tool
|
|
784
|
+
// (e) streaming, but no delta has landed in the last ~300 ms (idle gap)
|
|
785
|
+
const lastAssistant = React.useMemo(() => {
|
|
786
|
+
for (let index = chat.messages.length - 1; index >= 0; index -= 1) {
|
|
787
|
+
const candidate = chat.messages[index]
|
|
788
|
+
if (candidate?.role === 'assistant') return candidate
|
|
789
|
+
}
|
|
790
|
+
return null
|
|
791
|
+
}, [chat.messages])
|
|
792
|
+
|
|
793
|
+
const trimmedContent = lastAssistant?.content?.trim() ?? ''
|
|
794
|
+
const hasReasoning = !!(lastAssistant?.reasoning && lastAssistant.reasoning.length > 0)
|
|
795
|
+
const toolCalls = lastAssistant?.toolCalls ?? []
|
|
796
|
+
const hasPendingToolCall = toolCalls.some((call) => call.state === 'pending')
|
|
797
|
+
const hasCompletedToolCall = toolCalls.some(
|
|
798
|
+
(call) => call.state === 'complete' || call.state === 'error',
|
|
799
|
+
)
|
|
800
|
+
const hasAnyVisibleSignal = !!(
|
|
801
|
+
trimmedContent || hasReasoning || toolCalls.length > 0
|
|
802
|
+
)
|
|
803
|
+
|
|
804
|
+
const assistantStreamSnapshot = React.useMemo(() => {
|
|
805
|
+
if (!lastAssistant) return ''
|
|
806
|
+
const toolSig = toolCalls
|
|
807
|
+
.map((call) => `${call.id}:${call.state}:${call.output != null ? 1 : 0}`)
|
|
808
|
+
.join('|')
|
|
809
|
+
return [
|
|
810
|
+
lastAssistant.id,
|
|
811
|
+
lastAssistant.content?.length ?? 0,
|
|
812
|
+
lastAssistant.reasoning?.length ?? 0,
|
|
813
|
+
lastAssistant.reasoningStreaming ? 1 : 0,
|
|
814
|
+
toolSig,
|
|
815
|
+
].join('#')
|
|
816
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
817
|
+
}, [lastAssistant])
|
|
818
|
+
|
|
819
|
+
const lastStreamUpdateRef = React.useRef<number>(Date.now())
|
|
820
|
+
const lastSnapshotRef = React.useRef<string>('')
|
|
821
|
+
const [, setStreamTick] = React.useState(0)
|
|
822
|
+
|
|
823
|
+
React.useEffect(() => {
|
|
824
|
+
if (assistantStreamSnapshot !== lastSnapshotRef.current) {
|
|
825
|
+
lastSnapshotRef.current = assistantStreamSnapshot
|
|
826
|
+
lastStreamUpdateRef.current = Date.now()
|
|
827
|
+
setStreamTick((value) => value + 1)
|
|
828
|
+
}
|
|
829
|
+
}, [assistantStreamSnapshot])
|
|
830
|
+
|
|
831
|
+
React.useEffect(() => {
|
|
832
|
+
if (!isStreaming && !isSubmitting) return
|
|
833
|
+
const interval = window.setInterval(() => {
|
|
834
|
+
setStreamTick((value) => value + 1)
|
|
835
|
+
}, 200)
|
|
836
|
+
return () => window.clearInterval(interval)
|
|
837
|
+
}, [isStreaming, isSubmitting])
|
|
838
|
+
|
|
839
|
+
const idleDuringStream =
|
|
840
|
+
isStreaming && Date.now() - lastStreamUpdateRef.current >= 300
|
|
841
|
+
|
|
842
|
+
const showThinkingIndicator =
|
|
843
|
+
isSubmitting ||
|
|
844
|
+
(isStreaming &&
|
|
845
|
+
(
|
|
846
|
+
!hasAnyVisibleSignal ||
|
|
847
|
+
hasPendingToolCall ||
|
|
848
|
+
// Tool just returned and the model hasn't started speaking yet.
|
|
849
|
+
(hasCompletedToolCall && !trimmedContent) ||
|
|
850
|
+
idleDuringStream
|
|
851
|
+
))
|
|
852
|
+
|
|
853
|
+
const activeRegistry = registry ?? defaultAiUiPartRegistry
|
|
854
|
+
|
|
855
|
+
// Reserved UI parts. Phase 3 will populate this from the streamed response;
|
|
856
|
+
// for Phase 2 WS-A it stays empty unless the host surfaces test/debug parts
|
|
857
|
+
// via the optional `uiParts` prop so the registry resolution path can be
|
|
858
|
+
// exercised without waiting for the runtime emitter.
|
|
859
|
+
const uiParts: ServerEmittedUiPartRef[] = React.useMemo(
|
|
860
|
+
() => (uiPartsProp ?? []).map((part) => ({
|
|
861
|
+
componentId: part.componentId,
|
|
862
|
+
payload: part.payload,
|
|
863
|
+
pendingActionId: part.pendingActionId,
|
|
864
|
+
})),
|
|
865
|
+
[uiPartsProp],
|
|
866
|
+
)
|
|
867
|
+
|
|
868
|
+
const hasUploadingFiles = React.useMemo(
|
|
869
|
+
() => pendingFiles.some((entry) => !entry.attachmentId && !entry.error),
|
|
870
|
+
[pendingFiles],
|
|
871
|
+
)
|
|
872
|
+
|
|
873
|
+
const handleSendMessage = React.useCallback(
|
|
874
|
+
(text: string) => {
|
|
875
|
+
if (!text.trim() || isBusy) return
|
|
876
|
+
// Block send while any attachment is still uploading. Without this guard
|
|
877
|
+
// the message would ship with an empty attachmentIds list (the chip is
|
|
878
|
+
// visible but the server hasn't returned an id yet), the model would
|
|
879
|
+
// never see the file, and `setPendingFiles([])` below would erase the
|
|
880
|
+
// chip — so the upload finishes into the void. Surface the wait via the
|
|
881
|
+
// disabled Send button + composer hint instead.
|
|
882
|
+
if (hasUploadingFiles || isUploading) return
|
|
883
|
+
const filesToAttach = pendingFiles.map((entry): AiChatMessageFile => {
|
|
884
|
+
const isImage = entry.file.type.startsWith('image/')
|
|
885
|
+
const fallback = isImage ? URL.createObjectURL(entry.file) : undefined
|
|
886
|
+
return {
|
|
887
|
+
name: entry.file.name,
|
|
888
|
+
type: entry.file.type,
|
|
889
|
+
previewUrl: isImage ? (entry.previewDataUrl ?? fallback) : undefined,
|
|
890
|
+
}
|
|
891
|
+
})
|
|
892
|
+
setInput('')
|
|
893
|
+
setPendingFiles([])
|
|
894
|
+
void chat.sendMessage(text, filesToAttach.length > 0 ? filesToAttach : undefined)
|
|
895
|
+
},
|
|
896
|
+
[chat, hasUploadingFiles, isBusy, isUploading, pendingFiles],
|
|
897
|
+
)
|
|
898
|
+
|
|
899
|
+
const handleSubmit = React.useCallback(() => {
|
|
900
|
+
handleSendMessage(input)
|
|
901
|
+
}, [handleSendMessage, input])
|
|
902
|
+
|
|
903
|
+
// Listen for "Fix with AI" requests dispatched by the failure variant
|
|
904
|
+
// of `MutationResultCard`. Any rendered failure card can fire a custom
|
|
905
|
+
// DOM event with the prompt — this side-steps having to thread a
|
|
906
|
+
// sendMessage callback through the UI-part registry while keeping the
|
|
907
|
+
// chat the single owner of message creation. Idempotency is handled
|
|
908
|
+
// server-side: `prepareMutation` only dedupes against active `pending`
|
|
909
|
+
// rows, so a retry after a terminal failure always produces a fresh
|
|
910
|
+
// pending action.
|
|
911
|
+
React.useEffect(() => {
|
|
912
|
+
if (typeof window === 'undefined') return
|
|
913
|
+
const handler = (event: Event) => {
|
|
914
|
+
const detail = (event as CustomEvent<{ message?: string }>).detail
|
|
915
|
+
const message = detail?.message
|
|
916
|
+
if (typeof message !== 'string' || message.trim().length === 0) return
|
|
917
|
+
handleSendMessage(message)
|
|
918
|
+
}
|
|
919
|
+
window.addEventListener('om-ai-chat-fix-request', handler as EventListener)
|
|
920
|
+
return () => {
|
|
921
|
+
window.removeEventListener('om-ai-chat-fix-request', handler as EventListener)
|
|
922
|
+
}
|
|
923
|
+
}, [handleSendMessage])
|
|
924
|
+
|
|
925
|
+
const cancelOrBlur = React.useCallback(() => {
|
|
926
|
+
if (isBusy) {
|
|
927
|
+
chat.cancel()
|
|
928
|
+
return
|
|
929
|
+
}
|
|
930
|
+
textareaRef.current?.blur()
|
|
931
|
+
}, [chat, isBusy])
|
|
932
|
+
|
|
933
|
+
const handleFileSelect = React.useCallback(
|
|
934
|
+
async (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
935
|
+
const files = Array.from(event.target.files ?? [])
|
|
936
|
+
if (files.length === 0) return
|
|
937
|
+
const queued: PendingAttachment[] = files.map((file) => ({ file }))
|
|
938
|
+
setPendingFiles((prev) => [...prev, ...queued])
|
|
939
|
+
const [previewResults, result] = await Promise.all([
|
|
940
|
+
Promise.all(files.map((file) => readFileAsDataUrl(file))),
|
|
941
|
+
upload.upload(files),
|
|
942
|
+
])
|
|
943
|
+
// Pair upload outcomes back to chips by input INDEX, not by filename.
|
|
944
|
+
// The server may sanitize the uploaded name (whitespace, unicode,
|
|
945
|
+
// dangerous characters), and two files in the same batch can share a
|
|
946
|
+
// name — both cases broke the previous Map-by-fileName matching and
|
|
947
|
+
// left the chip stuck on the spinner forever.
|
|
948
|
+
const idByIndex = new Map<number, string>()
|
|
949
|
+
for (const item of result.items) {
|
|
950
|
+
if (typeof item.inputIndex === 'number') idByIndex.set(item.inputIndex, item.attachmentId)
|
|
951
|
+
}
|
|
952
|
+
const errorByIndex = new Map<number, string>()
|
|
953
|
+
for (const failure of result.failed) {
|
|
954
|
+
if (typeof failure.inputIndex === 'number') errorByIndex.set(failure.inputIndex, failure.message)
|
|
955
|
+
}
|
|
956
|
+
setPendingFiles((prev) => {
|
|
957
|
+
const next = prev.slice()
|
|
958
|
+
const baseIndex = next.length - files.length
|
|
959
|
+
for (let offset = 0; offset < files.length; offset += 1) {
|
|
960
|
+
const index = baseIndex + offset
|
|
961
|
+
if (index < 0) continue
|
|
962
|
+
const entry = next[index]
|
|
963
|
+
if (!entry) continue
|
|
964
|
+
const dataUrl = previewResults[offset]
|
|
965
|
+
const patch: PendingAttachment = {
|
|
966
|
+
...entry,
|
|
967
|
+
previewDataUrl: dataUrl ?? entry.previewDataUrl,
|
|
968
|
+
}
|
|
969
|
+
if (!patch.attachmentId) {
|
|
970
|
+
const id = idByIndex.get(offset)
|
|
971
|
+
if (id) {
|
|
972
|
+
patch.attachmentId = id
|
|
973
|
+
patch.error = undefined
|
|
974
|
+
} else {
|
|
975
|
+
// Defensive fallback: if neither success nor failure carried an
|
|
976
|
+
// index for this slot (older transports, partial outcome), the
|
|
977
|
+
// chip would otherwise stay on the spinner. Mark it as a
|
|
978
|
+
// generic error so the user can remove it and retry instead of
|
|
979
|
+
// staring at a dead spinner that also blocks the Send button.
|
|
980
|
+
const explicitError = errorByIndex.get(offset)
|
|
981
|
+
patch.error =
|
|
982
|
+
explicitError ??
|
|
983
|
+
'Upload finished without a server response. Remove the file and try again.'
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
next[index] = patch
|
|
987
|
+
}
|
|
988
|
+
return next
|
|
989
|
+
})
|
|
990
|
+
if (fileInputRef.current) fileInputRef.current.value = ''
|
|
991
|
+
},
|
|
992
|
+
[upload],
|
|
993
|
+
)
|
|
994
|
+
|
|
995
|
+
const removePendingFile = React.useCallback((index: number) => {
|
|
996
|
+
setPendingFiles((prev) => prev.filter((_, i) => i !== index))
|
|
997
|
+
}, [])
|
|
998
|
+
|
|
999
|
+
const { handleKeyDown } = useAiShortcuts({
|
|
1000
|
+
onSubmit: handleSubmit,
|
|
1001
|
+
onCancel: cancelOrBlur,
|
|
1002
|
+
})
|
|
1003
|
+
|
|
1004
|
+
React.useEffect(() => {
|
|
1005
|
+
textareaRef.current?.focus()
|
|
1006
|
+
}, [])
|
|
1007
|
+
|
|
1008
|
+
// Sticky-bottom autoscroll: only re-pin to the bottom when the user is
|
|
1009
|
+
// already there (or within a small tolerance). If they have scrolled up to
|
|
1010
|
+
// read an earlier part of a long response, every streaming delta would
|
|
1011
|
+
// otherwise yank them back to the tail and make the message look truncated.
|
|
1012
|
+
// Tolerance is generous enough to absorb sub-pixel rounding, but tight
|
|
1013
|
+
// enough that an intentional scroll-up keeps the user where they want.
|
|
1014
|
+
const stickToBottomRef = React.useRef(true)
|
|
1015
|
+
const SCROLL_STICK_TOLERANCE_PX = 64
|
|
1016
|
+
|
|
1017
|
+
React.useEffect(() => {
|
|
1018
|
+
const node = transcriptRef.current
|
|
1019
|
+
if (!node) return
|
|
1020
|
+
const handleScroll = () => {
|
|
1021
|
+
const distanceFromBottom = node.scrollHeight - node.scrollTop - node.clientHeight
|
|
1022
|
+
stickToBottomRef.current = distanceFromBottom <= SCROLL_STICK_TOLERANCE_PX
|
|
1023
|
+
}
|
|
1024
|
+
node.addEventListener('scroll', handleScroll, { passive: true })
|
|
1025
|
+
return () => node.removeEventListener('scroll', handleScroll)
|
|
1026
|
+
}, [])
|
|
1027
|
+
|
|
1028
|
+
React.useEffect(() => {
|
|
1029
|
+
const node = transcriptRef.current
|
|
1030
|
+
if (!node) return
|
|
1031
|
+
if (!stickToBottomRef.current) return
|
|
1032
|
+
node.scrollTop = node.scrollHeight
|
|
1033
|
+
}, [chat.messages])
|
|
1034
|
+
|
|
1035
|
+
// Mark the body so floating UI surfaces (e.g. the demo feedback FAB) can
|
|
1036
|
+
// hide themselves while the chat is open and would otherwise overlay the
|
|
1037
|
+
// composer's send button. CSS lives in `apps/mercato/src/app/globals.css`
|
|
1038
|
+
// alongside the column-chooser precedent.
|
|
1039
|
+
React.useEffect(() => {
|
|
1040
|
+
if (typeof document === 'undefined') return
|
|
1041
|
+
const previous = document.body.getAttribute('data-ai-chat-open')
|
|
1042
|
+
document.body.setAttribute('data-ai-chat-open', 'true')
|
|
1043
|
+
return () => {
|
|
1044
|
+
if (previous === null) {
|
|
1045
|
+
document.body.removeAttribute('data-ai-chat-open')
|
|
1046
|
+
} else {
|
|
1047
|
+
document.body.setAttribute('data-ai-chat-open', previous)
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
}, [])
|
|
1051
|
+
|
|
1052
|
+
const handleNewConversation = React.useCallback(() => {
|
|
1053
|
+
chat.reset()
|
|
1054
|
+
setInput('')
|
|
1055
|
+
setPendingFiles([])
|
|
1056
|
+
upload.reset()
|
|
1057
|
+
setTimeout(() => textareaRef.current?.focus(), 0)
|
|
1058
|
+
}, [chat, upload])
|
|
1059
|
+
|
|
1060
|
+
const resolvedPlaceholder =
|
|
1061
|
+
placeholder ?? t('ai_assistant.chat.composerPlaceholder', 'Message the AI agent...')
|
|
1062
|
+
|
|
1063
|
+
const errorVariant = mapErrorCodeToVariant(chat.error?.code)
|
|
1064
|
+
|
|
1065
|
+
return (
|
|
1066
|
+
<section
|
|
1067
|
+
className={cn(
|
|
1068
|
+
'flex h-full min-h-[320px] flex-col gap-3 rounded-lg border border-border bg-background p-3',
|
|
1069
|
+
className,
|
|
1070
|
+
)}
|
|
1071
|
+
aria-label={t('ai_assistant.chat.regionLabel', 'AI chat')}
|
|
1072
|
+
data-ai-chat-agent={agent}
|
|
1073
|
+
data-ai-chat-conversation-id={chat.conversationId}
|
|
1074
|
+
>
|
|
1075
|
+
<div className="flex items-center justify-between gap-2 border-b border-border pb-2">
|
|
1076
|
+
<div className="flex flex-1 items-center gap-2 text-xs text-muted-foreground">
|
|
1077
|
+
{contextItems && contextItems.length > 0 ? (
|
|
1078
|
+
<ContextItemsPill items={contextItems} />
|
|
1079
|
+
) : (
|
|
1080
|
+
<span className="font-mono opacity-70" aria-hidden>
|
|
1081
|
+
{chat.conversationId.slice(0, 8)}
|
|
1082
|
+
</span>
|
|
1083
|
+
)}
|
|
1084
|
+
</div>
|
|
1085
|
+
<IconButton
|
|
1086
|
+
type="button"
|
|
1087
|
+
variant="ghost"
|
|
1088
|
+
size="sm"
|
|
1089
|
+
onClick={handleNewConversation}
|
|
1090
|
+
disabled={isBusy}
|
|
1091
|
+
aria-label={t('ai_assistant.chat.newConversation', 'Start new conversation')}
|
|
1092
|
+
title={t('ai_assistant.chat.newConversation', 'Start new conversation')}
|
|
1093
|
+
data-ai-chat-new-conversation=""
|
|
1094
|
+
>
|
|
1095
|
+
<Plus className="size-4" aria-hidden />
|
|
1096
|
+
</IconButton>
|
|
1097
|
+
</div>
|
|
1098
|
+
<div
|
|
1099
|
+
ref={transcriptRef}
|
|
1100
|
+
role="log"
|
|
1101
|
+
aria-live="polite"
|
|
1102
|
+
aria-label={t('ai_assistant.chat.transcriptLabel', 'Chat transcript')}
|
|
1103
|
+
className="flex-1 space-y-2 overflow-y-auto pr-1"
|
|
1104
|
+
>
|
|
1105
|
+
{chat.messages.length === 0 ? (
|
|
1106
|
+
<WelcomeState
|
|
1107
|
+
title={welcomeTitle}
|
|
1108
|
+
description={welcomeDescription}
|
|
1109
|
+
suggestions={suggestions}
|
|
1110
|
+
onSuggestionClick={handleSendMessage}
|
|
1111
|
+
/>
|
|
1112
|
+
) : (
|
|
1113
|
+
chat.messages.map((message) => (
|
|
1114
|
+
<MessageRow
|
|
1115
|
+
key={message.id}
|
|
1116
|
+
message={message}
|
|
1117
|
+
registry={activeRegistry}
|
|
1118
|
+
onMutationRequested={onMutationRequested}
|
|
1119
|
+
/>
|
|
1120
|
+
))
|
|
1121
|
+
)}
|
|
1122
|
+
{uiParts.map((part, index) => (
|
|
1123
|
+
<AiUiPartRenderer
|
|
1124
|
+
key={`${part.componentId}-${index}`}
|
|
1125
|
+
part={part}
|
|
1126
|
+
registry={activeRegistry}
|
|
1127
|
+
onMutationRequested={onMutationRequested}
|
|
1128
|
+
/>
|
|
1129
|
+
))}
|
|
1130
|
+
{showThinkingIndicator ? (
|
|
1131
|
+
<div
|
|
1132
|
+
className="flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground"
|
|
1133
|
+
data-ai-chat-state="thinking"
|
|
1134
|
+
>
|
|
1135
|
+
<Loader2 className="size-4 animate-spin" aria-hidden />
|
|
1136
|
+
<span>{t('ai_assistant.chat.thinking', 'Thinking...')}</span>
|
|
1137
|
+
</div>
|
|
1138
|
+
) : null}
|
|
1139
|
+
</div>
|
|
1140
|
+
|
|
1141
|
+
{chat.error ? (
|
|
1142
|
+
<Alert variant={errorVariant} data-ai-chat-error={chat.error.code ?? 'unknown'}>
|
|
1143
|
+
<AlertTitle>
|
|
1144
|
+
{t('ai_assistant.chat.errorTitle', 'Agent dispatch failed')}
|
|
1145
|
+
</AlertTitle>
|
|
1146
|
+
<AlertDescription>
|
|
1147
|
+
{chat.error.code ? (
|
|
1148
|
+
<span className="mr-2 font-mono text-xs">{chat.error.code}</span>
|
|
1149
|
+
) : null}
|
|
1150
|
+
{chat.error.message}
|
|
1151
|
+
</AlertDescription>
|
|
1152
|
+
</Alert>
|
|
1153
|
+
) : null}
|
|
1154
|
+
|
|
1155
|
+
<form
|
|
1156
|
+
className="flex flex-col gap-2"
|
|
1157
|
+
onSubmit={(event) => {
|
|
1158
|
+
event.preventDefault()
|
|
1159
|
+
handleSubmit()
|
|
1160
|
+
}}
|
|
1161
|
+
>
|
|
1162
|
+
<Label
|
|
1163
|
+
htmlFor="ai-chat-composer"
|
|
1164
|
+
className="sr-only"
|
|
1165
|
+
>
|
|
1166
|
+
{t('ai_assistant.chat.composerLabel', 'Message composer')}
|
|
1167
|
+
</Label>
|
|
1168
|
+
{pendingFiles.length > 0 ? (
|
|
1169
|
+
<div className="flex flex-wrap gap-1.5 rounded-md border border-border bg-muted/30 px-2 py-1.5" data-ai-chat-attachments="">
|
|
1170
|
+
{pendingFiles.map((entry, index) => (
|
|
1171
|
+
<span
|
|
1172
|
+
key={index}
|
|
1173
|
+
className="inline-flex items-center gap-1 rounded-full border border-border bg-background px-2 py-0.5 text-xs"
|
|
1174
|
+
title={entry.error ? entry.error : undefined}
|
|
1175
|
+
data-ai-chat-attachment-state={
|
|
1176
|
+
entry.error ? 'error' : entry.attachmentId ? 'ready' : 'uploading'
|
|
1177
|
+
}
|
|
1178
|
+
>
|
|
1179
|
+
<Paperclip className="size-3 text-muted-foreground" aria-hidden />
|
|
1180
|
+
<span className="max-w-[120px] truncate">{entry.file.name}</span>
|
|
1181
|
+
{!entry.attachmentId && !entry.error ? (
|
|
1182
|
+
<Loader2 className="size-3 animate-spin text-muted-foreground" aria-hidden />
|
|
1183
|
+
) : null}
|
|
1184
|
+
<button
|
|
1185
|
+
type="button"
|
|
1186
|
+
className="ml-0.5 rounded-full p-0.5 hover:bg-muted"
|
|
1187
|
+
onClick={() => removePendingFile(index)}
|
|
1188
|
+
aria-label={t('ai_assistant.chat.removeFile', 'Remove file')}
|
|
1189
|
+
>
|
|
1190
|
+
<X className="size-3" />
|
|
1191
|
+
</button>
|
|
1192
|
+
</span>
|
|
1193
|
+
))}
|
|
1194
|
+
{isUploading ? <Loader2 className="size-3 animate-spin text-muted-foreground" aria-hidden /> : null}
|
|
1195
|
+
</div>
|
|
1196
|
+
) : null}
|
|
1197
|
+
<Textarea
|
|
1198
|
+
id="ai-chat-composer"
|
|
1199
|
+
ref={textareaRef}
|
|
1200
|
+
value={input}
|
|
1201
|
+
placeholder={resolvedPlaceholder}
|
|
1202
|
+
onChange={(event) => setInput(event.target.value)}
|
|
1203
|
+
onKeyDown={handleKeyDown}
|
|
1204
|
+
rows={3}
|
|
1205
|
+
aria-label={t('ai_assistant.chat.composerLabel', 'Message composer')}
|
|
1206
|
+
className="resize-none"
|
|
1207
|
+
/>
|
|
1208
|
+
<input
|
|
1209
|
+
ref={fileInputRef}
|
|
1210
|
+
type="file"
|
|
1211
|
+
multiple
|
|
1212
|
+
accept="image/*,.pdf,.doc,.docx,.txt,.csv"
|
|
1213
|
+
className="hidden"
|
|
1214
|
+
onChange={handleFileSelect}
|
|
1215
|
+
data-ai-chat-file-input=""
|
|
1216
|
+
/>
|
|
1217
|
+
<div className="flex items-center justify-between gap-2">
|
|
1218
|
+
<div className="flex items-center gap-2">
|
|
1219
|
+
<IconButton
|
|
1220
|
+
type="button"
|
|
1221
|
+
variant="ghost"
|
|
1222
|
+
size="sm"
|
|
1223
|
+
onClick={() => fileInputRef.current?.click()}
|
|
1224
|
+
disabled={isBusy || isUploading}
|
|
1225
|
+
aria-label={t('ai_assistant.chat.attachFile', 'Attach file')}
|
|
1226
|
+
>
|
|
1227
|
+
<Paperclip className="size-4" aria-hidden />
|
|
1228
|
+
</IconButton>
|
|
1229
|
+
<p className="text-xs text-muted-foreground">
|
|
1230
|
+
{hasUploadingFiles || isUploading
|
|
1231
|
+
? t(
|
|
1232
|
+
'ai_assistant.chat.uploadingHint',
|
|
1233
|
+
'Uploading attachments… Send is disabled until they finish.',
|
|
1234
|
+
)
|
|
1235
|
+
: t(
|
|
1236
|
+
'ai_assistant.chat.shortcutHint',
|
|
1237
|
+
'Press Enter to send, Shift+Enter for new line.',
|
|
1238
|
+
)}
|
|
1239
|
+
</p>
|
|
1240
|
+
</div>
|
|
1241
|
+
<div className="flex items-center gap-2">
|
|
1242
|
+
{isStreaming ? (
|
|
1243
|
+
<IconButton
|
|
1244
|
+
type="button"
|
|
1245
|
+
variant="outline"
|
|
1246
|
+
size="sm"
|
|
1247
|
+
onClick={() => chat.cancel()}
|
|
1248
|
+
aria-label={t('ai_assistant.chat.cancel', 'Cancel streaming response')}
|
|
1249
|
+
>
|
|
1250
|
+
<Square className="size-4" aria-hidden />
|
|
1251
|
+
</IconButton>
|
|
1252
|
+
) : null}
|
|
1253
|
+
<Button
|
|
1254
|
+
type="submit"
|
|
1255
|
+
size="sm"
|
|
1256
|
+
disabled={
|
|
1257
|
+
isBusy ||
|
|
1258
|
+
isUploading ||
|
|
1259
|
+
hasUploadingFiles ||
|
|
1260
|
+
input.trim().length === 0
|
|
1261
|
+
}
|
|
1262
|
+
aria-label={
|
|
1263
|
+
hasUploadingFiles || isUploading
|
|
1264
|
+
? t('ai_assistant.chat.sendWaitingForUpload', 'Waiting for upload to finish…')
|
|
1265
|
+
: t('ai_assistant.chat.send', 'Send message')
|
|
1266
|
+
}
|
|
1267
|
+
title={
|
|
1268
|
+
hasUploadingFiles || isUploading
|
|
1269
|
+
? t('ai_assistant.chat.sendWaitingForUpload', 'Waiting for upload to finish…')
|
|
1270
|
+
: undefined
|
|
1271
|
+
}
|
|
1272
|
+
>
|
|
1273
|
+
<Send className="size-4" aria-hidden />
|
|
1274
|
+
<span>{t('ai_assistant.chat.send', 'Send message')}</span>
|
|
1275
|
+
</Button>
|
|
1276
|
+
</div>
|
|
1277
|
+
</div>
|
|
1278
|
+
</form>
|
|
1279
|
+
|
|
1280
|
+
{debug ? (
|
|
1281
|
+
<AiChatDebugPanel
|
|
1282
|
+
tools={debugTools}
|
|
1283
|
+
promptSections={debugPromptSections}
|
|
1284
|
+
lastRequestDebug={chat.lastRequestDebug}
|
|
1285
|
+
lastResponseDebug={chat.lastResponseDebug}
|
|
1286
|
+
status={chat.status}
|
|
1287
|
+
errorCode={chat.error?.code}
|
|
1288
|
+
/>
|
|
1289
|
+
) : null}
|
|
1290
|
+
</section>
|
|
1291
|
+
)
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
interface DebugPanelProps {
|
|
1295
|
+
tools?: AiChatDebugTool[]
|
|
1296
|
+
promptSections?: AiChatDebugPromptSection[]
|
|
1297
|
+
lastRequestDebug: { url: string; body: unknown } | null
|
|
1298
|
+
lastResponseDebug: { status: number; text: string } | null
|
|
1299
|
+
status: 'idle' | 'submitting' | 'streaming'
|
|
1300
|
+
errorCode?: string
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
function AiChatDebugPanel({
|
|
1304
|
+
tools,
|
|
1305
|
+
promptSections,
|
|
1306
|
+
lastRequestDebug,
|
|
1307
|
+
lastResponseDebug,
|
|
1308
|
+
status,
|
|
1309
|
+
errorCode,
|
|
1310
|
+
}: DebugPanelProps) {
|
|
1311
|
+
const t = useT()
|
|
1312
|
+
return (
|
|
1313
|
+
<div
|
|
1314
|
+
className="flex flex-col gap-2 rounded-md border border-border bg-muted/60 p-2 text-xs"
|
|
1315
|
+
data-ai-chat-debug="true"
|
|
1316
|
+
>
|
|
1317
|
+
<div className="font-semibold">
|
|
1318
|
+
{t('ai_assistant.chat.debug.panelTitle', 'Debug panel')}
|
|
1319
|
+
</div>
|
|
1320
|
+
|
|
1321
|
+
<details className="rounded border border-border bg-background" data-ai-chat-debug-section="tools" open>
|
|
1322
|
+
<summary className="cursor-pointer px-2 py-1 font-semibold">
|
|
1323
|
+
{t('ai_assistant.chat.debug.toolsSection', 'Resolved tools')}
|
|
1324
|
+
{tools ? (
|
|
1325
|
+
<span className="ml-2 font-mono text-muted-foreground">({tools.length})</span>
|
|
1326
|
+
) : null}
|
|
1327
|
+
</summary>
|
|
1328
|
+
<div className="px-2 pb-2">
|
|
1329
|
+
{tools && tools.length > 0 ? (
|
|
1330
|
+
<ul className="flex flex-col gap-1" data-ai-chat-debug-tools>
|
|
1331
|
+
{tools.map((tool) => (
|
|
1332
|
+
<li
|
|
1333
|
+
key={tool.name}
|
|
1334
|
+
className="flex flex-col rounded border border-border bg-muted/40 px-2 py-1"
|
|
1335
|
+
data-ai-chat-debug-tool={tool.name}
|
|
1336
|
+
>
|
|
1337
|
+
<span className="font-mono">{tool.name}</span>
|
|
1338
|
+
{tool.displayName ? (
|
|
1339
|
+
<span className="text-muted-foreground">{tool.displayName}</span>
|
|
1340
|
+
) : null}
|
|
1341
|
+
<span className="mt-1 flex flex-wrap gap-2 text-muted-foreground">
|
|
1342
|
+
<span>
|
|
1343
|
+
{tool.isMutation
|
|
1344
|
+
? t('ai_assistant.chat.debug.toolMutation', 'mutation')
|
|
1345
|
+
: t('ai_assistant.chat.debug.toolRead', 'read')}
|
|
1346
|
+
</span>
|
|
1347
|
+
{tool.requiredFeatures && tool.requiredFeatures.length > 0 ? (
|
|
1348
|
+
<span className="font-mono">
|
|
1349
|
+
[{tool.requiredFeatures.join(', ')}]
|
|
1350
|
+
</span>
|
|
1351
|
+
) : (
|
|
1352
|
+
<span>
|
|
1353
|
+
{t('ai_assistant.chat.debug.toolNoFeatures', 'no required features')}
|
|
1354
|
+
</span>
|
|
1355
|
+
)}
|
|
1356
|
+
</span>
|
|
1357
|
+
</li>
|
|
1358
|
+
))}
|
|
1359
|
+
</ul>
|
|
1360
|
+
) : (
|
|
1361
|
+
<p className="text-muted-foreground">
|
|
1362
|
+
{t(
|
|
1363
|
+
'ai_assistant.chat.debug.toolsEmpty',
|
|
1364
|
+
'No tools resolved for this agent yet.',
|
|
1365
|
+
)}
|
|
1366
|
+
</p>
|
|
1367
|
+
)}
|
|
1368
|
+
</div>
|
|
1369
|
+
</details>
|
|
1370
|
+
|
|
1371
|
+
<details
|
|
1372
|
+
className="rounded border border-border bg-background"
|
|
1373
|
+
data-ai-chat-debug-section="promptSections"
|
|
1374
|
+
>
|
|
1375
|
+
<summary className="cursor-pointer px-2 py-1 font-semibold">
|
|
1376
|
+
{t('ai_assistant.chat.debug.promptSection', 'Prompt sections')}
|
|
1377
|
+
{promptSections ? (
|
|
1378
|
+
<span className="ml-2 font-mono text-muted-foreground">({promptSections.length})</span>
|
|
1379
|
+
) : null}
|
|
1380
|
+
</summary>
|
|
1381
|
+
<div className="px-2 pb-2">
|
|
1382
|
+
{promptSections && promptSections.length > 0 ? (
|
|
1383
|
+
<ul className="flex flex-col gap-1" data-ai-chat-debug-prompt-sections>
|
|
1384
|
+
{promptSections.map((section) => (
|
|
1385
|
+
<li
|
|
1386
|
+
key={section.id}
|
|
1387
|
+
className="rounded border border-border bg-muted/40 px-2 py-1"
|
|
1388
|
+
data-ai-chat-debug-prompt-section-id={section.id}
|
|
1389
|
+
>
|
|
1390
|
+
<div className="flex items-center justify-between gap-2">
|
|
1391
|
+
<span className="font-mono">{section.id}</span>
|
|
1392
|
+
<span className="text-muted-foreground">
|
|
1393
|
+
{section.source === 'override'
|
|
1394
|
+
? t('ai_assistant.chat.debug.promptOverride', 'override')
|
|
1395
|
+
: section.source === 'placeholder'
|
|
1396
|
+
? t('ai_assistant.chat.debug.promptPlaceholder', 'placeholder')
|
|
1397
|
+
: t('ai_assistant.chat.debug.promptDefault', 'default')}
|
|
1398
|
+
</span>
|
|
1399
|
+
</div>
|
|
1400
|
+
{section.text ? (
|
|
1401
|
+
<pre className="mt-1 max-h-24 overflow-auto whitespace-pre-wrap font-mono text-muted-foreground">
|
|
1402
|
+
{section.text}
|
|
1403
|
+
</pre>
|
|
1404
|
+
) : null}
|
|
1405
|
+
</li>
|
|
1406
|
+
))}
|
|
1407
|
+
</ul>
|
|
1408
|
+
) : (
|
|
1409
|
+
<p className="text-muted-foreground">
|
|
1410
|
+
{t(
|
|
1411
|
+
'ai_assistant.chat.debug.promptEmpty',
|
|
1412
|
+
'No prompt sections resolved for this agent.',
|
|
1413
|
+
)}
|
|
1414
|
+
</p>
|
|
1415
|
+
)}
|
|
1416
|
+
</div>
|
|
1417
|
+
</details>
|
|
1418
|
+
|
|
1419
|
+
<details
|
|
1420
|
+
className="rounded border border-border bg-background"
|
|
1421
|
+
data-ai-chat-debug-section="lastRequest"
|
|
1422
|
+
>
|
|
1423
|
+
<summary className="cursor-pointer px-2 py-1 font-semibold">
|
|
1424
|
+
{t('ai_assistant.chat.debug.lastRequestSection', 'Last request')}
|
|
1425
|
+
</summary>
|
|
1426
|
+
<div className="px-2 pb-2">
|
|
1427
|
+
{lastRequestDebug ? (
|
|
1428
|
+
<pre
|
|
1429
|
+
className="max-h-40 overflow-auto whitespace-pre-wrap font-mono"
|
|
1430
|
+
data-ai-chat-debug-last-request
|
|
1431
|
+
>
|
|
1432
|
+
{JSON.stringify(lastRequestDebug, null, 2)}
|
|
1433
|
+
</pre>
|
|
1434
|
+
) : (
|
|
1435
|
+
<p className="text-muted-foreground">
|
|
1436
|
+
{t(
|
|
1437
|
+
'ai_assistant.chat.debug.lastRequestEmpty',
|
|
1438
|
+
'No request has been sent yet.',
|
|
1439
|
+
)}
|
|
1440
|
+
</p>
|
|
1441
|
+
)}
|
|
1442
|
+
</div>
|
|
1443
|
+
</details>
|
|
1444
|
+
|
|
1445
|
+
<details
|
|
1446
|
+
className="rounded border border-border bg-background"
|
|
1447
|
+
data-ai-chat-debug-section="lastResponse"
|
|
1448
|
+
>
|
|
1449
|
+
<summary className="cursor-pointer px-2 py-1 font-semibold">
|
|
1450
|
+
{t('ai_assistant.chat.debug.lastResponseSection', 'Last response')}
|
|
1451
|
+
</summary>
|
|
1452
|
+
<div className="px-2 pb-2">
|
|
1453
|
+
{lastResponseDebug ? (
|
|
1454
|
+
<pre
|
|
1455
|
+
className="max-h-40 overflow-auto whitespace-pre-wrap font-mono"
|
|
1456
|
+
data-ai-chat-debug-last-response
|
|
1457
|
+
>
|
|
1458
|
+
{JSON.stringify(
|
|
1459
|
+
{ status: lastResponseDebug.status, text: lastResponseDebug.text, errorCode },
|
|
1460
|
+
null,
|
|
1461
|
+
2,
|
|
1462
|
+
)}
|
|
1463
|
+
</pre>
|
|
1464
|
+
) : (
|
|
1465
|
+
<p className="text-muted-foreground">
|
|
1466
|
+
{t(
|
|
1467
|
+
'ai_assistant.chat.debug.lastResponseEmpty',
|
|
1468
|
+
'No response received yet.',
|
|
1469
|
+
)}
|
|
1470
|
+
</p>
|
|
1471
|
+
)}
|
|
1472
|
+
</div>
|
|
1473
|
+
</details>
|
|
1474
|
+
|
|
1475
|
+
<div className="text-muted-foreground" data-ai-chat-debug-status={status}>
|
|
1476
|
+
{t('ai_assistant.chat.debug.statusLabel', 'Status:')}{' '}
|
|
1477
|
+
<span className="font-mono">{status}</span>
|
|
1478
|
+
</div>
|
|
1479
|
+
</div>
|
|
1480
|
+
)
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
export default AiChat
|