@open-mercato/ui 0.6.1-develop.3246.1.dbef9d7392 → 0.6.1-develop.3256.1.fe3dec2464
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/__integration__/TC-AI-UI-003-aichat-registry.spec.tsx +13 -0
- package/dist/ai/AiAssistantLauncher.js +8 -0
- package/dist/ai/AiAssistantLauncher.js.map +2 -2
- package/dist/ai/AiChat.js +226 -58
- package/dist/ai/AiChat.js.map +2 -2
- package/dist/ai/AiDock.js +3 -2
- package/dist/ai/AiDock.js.map +2 -2
- package/dist/ai/ModelPicker.js +186 -0
- package/dist/ai/ModelPicker.js.map +7 -0
- package/dist/ai/index.js +3 -1
- package/dist/ai/index.js.map +2 -2
- package/dist/ai/useAiChat.js +12 -16
- package/dist/ai/useAiChat.js.map +2 -2
- package/package.json +3 -3
- package/src/ai/AiAssistantLauncher.tsx +8 -0
- package/src/ai/AiChat.tsx +229 -11
- package/src/ai/AiDock.tsx +3 -2
- package/src/ai/ModelPicker.tsx +248 -0
- package/src/ai/__tests__/AiAssistantLauncher.test.tsx +58 -0
- package/src/ai/__tests__/AiChat.conversation.test.tsx +16 -0
- package/src/ai/__tests__/AiChat.debug.test.tsx +16 -0
- package/src/ai/__tests__/AiChat.registry.test.tsx +16 -0
- package/src/ai/__tests__/AiChat.test.tsx +180 -0
- package/src/ai/__tests__/AiChat.thinking-unmount.test.tsx +16 -0
- package/src/ai/__tests__/AiDock.test.tsx +12 -1
- package/src/ai/__tests__/ModelPicker.test.tsx +252 -0
- package/src/ai/index.ts +1 -0
- package/src/ai/useAiChat.ts +29 -16
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/ui",
|
|
3
|
-
"version": "0.6.1-develop.
|
|
3
|
+
"version": "0.6.1-develop.3256.1.fe3dec2464",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -140,12 +140,12 @@
|
|
|
140
140
|
"remark-gfm": "^4.0.1"
|
|
141
141
|
},
|
|
142
142
|
"peerDependencies": {
|
|
143
|
-
"@open-mercato/shared": "0.6.1-develop.
|
|
143
|
+
"@open-mercato/shared": "0.6.1-develop.3256.1.fe3dec2464",
|
|
144
144
|
"react": ">=18.0.0",
|
|
145
145
|
"react-dom": ">=18.0.0"
|
|
146
146
|
},
|
|
147
147
|
"devDependencies": {
|
|
148
|
-
"@open-mercato/shared": "0.6.1-develop.
|
|
148
|
+
"@open-mercato/shared": "0.6.1-develop.3256.1.fe3dec2464",
|
|
149
149
|
"@testing-library/dom": "^10.4.1",
|
|
150
150
|
"@testing-library/jest-dom": "^6.9.1",
|
|
151
151
|
"@testing-library/react": "^16.3.1",
|
|
@@ -131,6 +131,7 @@ const DEFAULT_AGENTS_ENDPOINT = '/api/ai_assistant/ai/agents'
|
|
|
131
131
|
const DEFAULT_HEALTH_ENDPOINT = '/api/ai_assistant/health'
|
|
132
132
|
const AI_ASSISTANT_DOCS_URL = 'https://docs.openmercato.com/framework/ai-assistant/overview'
|
|
133
133
|
const AI_ASSISTANT_SETTINGS_DOCS_URL = 'https://docs.openmercato.com/framework/ai-assistant/settings'
|
|
134
|
+
export const AI_ASSISTANT_LAUNCHER_OPEN_EVENT = 'om:open-ai-assistant-launcher'
|
|
134
135
|
|
|
135
136
|
function isMutationCapable(policy: string | null | undefined): boolean {
|
|
136
137
|
return policy === 'confirm-required' || policy === 'destructive-confirm-required'
|
|
@@ -319,6 +320,13 @@ export function AiAssistantLauncher({
|
|
|
319
320
|
setPickerOpen(true)
|
|
320
321
|
}, [])
|
|
321
322
|
|
|
323
|
+
React.useEffect(() => {
|
|
324
|
+
if (typeof window === 'undefined') return
|
|
325
|
+
const listener = () => openPicker()
|
|
326
|
+
window.addEventListener(AI_ASSISTANT_LAUNCHER_OPEN_EVENT, listener)
|
|
327
|
+
return () => window.removeEventListener(AI_ASSISTANT_LAUNCHER_OPEN_EVENT, listener)
|
|
328
|
+
}, [openPicker])
|
|
329
|
+
|
|
322
330
|
const handleSelectAgent = React.useCallback((agent: AiAssistantLauncherAgent) => {
|
|
323
331
|
if (dock.state.assistant?.agent === agent.id) {
|
|
324
332
|
dock.dock(dock.state.assistant)
|
package/src/ai/AiChat.tsx
CHANGED
|
@@ -26,7 +26,13 @@ import { Button } from '../primitives/button'
|
|
|
26
26
|
import { IconButton } from '../primitives/icon-button'
|
|
27
27
|
import { Label } from '../primitives/label'
|
|
28
28
|
import { Textarea } from '../primitives/textarea'
|
|
29
|
+
import { apiCall } from '../backend/utils/apiCall'
|
|
29
30
|
import { parseAiContentSegments } from './AiMessageContent'
|
|
31
|
+
import {
|
|
32
|
+
ModelPicker,
|
|
33
|
+
type ModelPickerProvider,
|
|
34
|
+
type ModelPickerValue,
|
|
35
|
+
} from './ModelPicker'
|
|
30
36
|
import { RecordCard } from './records/RecordCard'
|
|
31
37
|
import {
|
|
32
38
|
defaultAiUiPartRegistry,
|
|
@@ -48,6 +54,113 @@ import { useAiShortcuts } from './useAiShortcuts'
|
|
|
48
54
|
// browsers). Images larger than this still upload + send to the LLM as inline
|
|
49
55
|
// base64 server-side; only the in-chat preview is dropped on reload.
|
|
50
56
|
const PREVIEW_DATA_URL_MAX_BYTES = 2 * 1024 * 1024
|
|
57
|
+
const COMPACT_FOOTER_MAX_WIDTH = 640
|
|
58
|
+
|
|
59
|
+
const MODEL_PICKER_STORAGE_PREFIX = 'om-ai-model-picker:'
|
|
60
|
+
|
|
61
|
+
function readModelPickerValue(agentId: string): ModelPickerValue | null {
|
|
62
|
+
if (typeof window === 'undefined') return null
|
|
63
|
+
try {
|
|
64
|
+
const raw = window.localStorage.getItem(`${MODEL_PICKER_STORAGE_PREFIX}${agentId}`)
|
|
65
|
+
if (!raw) return null
|
|
66
|
+
const parsed = JSON.parse(raw) as unknown
|
|
67
|
+
if (
|
|
68
|
+
parsed &&
|
|
69
|
+
typeof parsed === 'object' &&
|
|
70
|
+
typeof (parsed as Record<string, unknown>).providerId === 'string' &&
|
|
71
|
+
typeof (parsed as Record<string, unknown>).modelId === 'string'
|
|
72
|
+
) {
|
|
73
|
+
const value = parsed as ModelPickerValue
|
|
74
|
+
return { providerId: value.providerId, modelId: value.modelId }
|
|
75
|
+
}
|
|
76
|
+
return null
|
|
77
|
+
} catch {
|
|
78
|
+
return null
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function writeModelPickerValue(agentId: string, value: ModelPickerValue | null): void {
|
|
83
|
+
if (typeof window === 'undefined') return
|
|
84
|
+
try {
|
|
85
|
+
const key = `${MODEL_PICKER_STORAGE_PREFIX}${agentId}`
|
|
86
|
+
if (value === null) {
|
|
87
|
+
window.localStorage.removeItem(key)
|
|
88
|
+
} else {
|
|
89
|
+
window.localStorage.setItem(key, JSON.stringify({ providerId: value.providerId, modelId: value.modelId }))
|
|
90
|
+
}
|
|
91
|
+
} catch {
|
|
92
|
+
// Quota exceeded / privacy mode — silently ignore.
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
interface ModelsApiResponse {
|
|
97
|
+
agentId: string
|
|
98
|
+
allowRuntimeModelOverride: boolean
|
|
99
|
+
defaultProviderId: string | null
|
|
100
|
+
defaultModelId: string | null
|
|
101
|
+
defaultProviderName?: string | null
|
|
102
|
+
defaultModelName?: string | null
|
|
103
|
+
providers: ModelPickerProvider[]
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function useAgentModels(agent: string): {
|
|
107
|
+
providers: ModelPickerProvider[]
|
|
108
|
+
allowRuntimeModelOverride: boolean
|
|
109
|
+
defaultLabel: string | null
|
|
110
|
+
loaded: boolean
|
|
111
|
+
} {
|
|
112
|
+
const [providers, setProviders] = React.useState<ModelPickerProvider[]>([])
|
|
113
|
+
const [allowRuntimeModelOverride, setAllowRuntimeModelOverride] = React.useState(false)
|
|
114
|
+
const [defaultLabel, setDefaultLabel] = React.useState<string | null>(null)
|
|
115
|
+
const [loaded, setLoaded] = React.useState(false)
|
|
116
|
+
|
|
117
|
+
React.useEffect(() => {
|
|
118
|
+
const modelsUrl = `/api/ai_assistant/ai/agents/${encodeURIComponent(agent)}/models`
|
|
119
|
+
setLoaded(false)
|
|
120
|
+
setDefaultLabel(null)
|
|
121
|
+
void apiCall<ModelsApiResponse>(modelsUrl).then((result) => {
|
|
122
|
+
if (!result.ok || !result.result) {
|
|
123
|
+
setLoaded(true)
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
setAllowRuntimeModelOverride(result.result.allowRuntimeModelOverride)
|
|
127
|
+
setProviders(result.result.providers)
|
|
128
|
+
setDefaultLabel(
|
|
129
|
+
result.result.defaultProviderName && result.result.defaultModelName
|
|
130
|
+
? `${result.result.defaultProviderName} / ${result.result.defaultModelName}`
|
|
131
|
+
: result.result.defaultProviderId && result.result.defaultModelId
|
|
132
|
+
? `${result.result.defaultProviderId} / ${result.result.defaultModelId}`
|
|
133
|
+
: null,
|
|
134
|
+
)
|
|
135
|
+
setLoaded(true)
|
|
136
|
+
})
|
|
137
|
+
}, [agent])
|
|
138
|
+
|
|
139
|
+
return { providers, allowRuntimeModelOverride, defaultLabel, loaded }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function firstAvailableModelPickerValue(
|
|
143
|
+
providers: ModelPickerProvider[],
|
|
144
|
+
): ModelPickerValue | null {
|
|
145
|
+
for (const provider of providers) {
|
|
146
|
+
const model = provider.models[0]
|
|
147
|
+
if (model) {
|
|
148
|
+
return { providerId: provider.id, modelId: model.id }
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return null
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function isModelPickerValueAvailable(
|
|
155
|
+
value: ModelPickerValue,
|
|
156
|
+
providers: ModelPickerProvider[],
|
|
157
|
+
): boolean {
|
|
158
|
+
return providers.some(
|
|
159
|
+
(provider) =>
|
|
160
|
+
provider.id === value.providerId &&
|
|
161
|
+
provider.models.some((model) => model.id === value.modelId),
|
|
162
|
+
)
|
|
163
|
+
}
|
|
51
164
|
|
|
52
165
|
async function readFileAsDataUrl(file: File): Promise<string | undefined> {
|
|
53
166
|
if (!file.type.startsWith('image/')) return undefined
|
|
@@ -158,6 +271,8 @@ export interface AiChatProps {
|
|
|
158
271
|
welcomeTitle?: string
|
|
159
272
|
/** Welcome description shown below the heading. */
|
|
160
273
|
welcomeDescription?: string
|
|
274
|
+
/** Initial compact composer state used before the footer has been measured. */
|
|
275
|
+
defaultCompactFooter?: boolean
|
|
161
276
|
}
|
|
162
277
|
|
|
163
278
|
interface ServerEmittedUiPartRef {
|
|
@@ -718,6 +833,7 @@ export function AiChat({
|
|
|
718
833
|
contextItems,
|
|
719
834
|
welcomeTitle,
|
|
720
835
|
welcomeDescription,
|
|
836
|
+
defaultCompactFooter = false,
|
|
721
837
|
}: AiChatProps) {
|
|
722
838
|
const t = useT()
|
|
723
839
|
const textareaRef = React.useRef<HTMLTextAreaElement | null>(null)
|
|
@@ -740,6 +856,26 @@ export function AiChat({
|
|
|
740
856
|
const [pendingFiles, setPendingFiles] = React.useState<PendingAttachment[]>([])
|
|
741
857
|
const upload = useAiChatUpload()
|
|
742
858
|
const isUploading = upload.busy
|
|
859
|
+
const footerRef = React.useRef<HTMLDivElement | null>(null)
|
|
860
|
+
const [isCompactFooter, setIsCompactFooter] = React.useState(defaultCompactFooter)
|
|
861
|
+
|
|
862
|
+
React.useEffect(() => {
|
|
863
|
+
const element = footerRef.current
|
|
864
|
+
if (!element || typeof ResizeObserver === 'undefined') return
|
|
865
|
+
|
|
866
|
+
const updateCompactState = (width: number) => {
|
|
867
|
+
setIsCompactFooter(width < COMPACT_FOOTER_MAX_WIDTH)
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
updateCompactState(element.getBoundingClientRect().width)
|
|
871
|
+
const observer = new ResizeObserver((entries) => {
|
|
872
|
+
const entry = entries[0]
|
|
873
|
+
if (!entry) return
|
|
874
|
+
updateCompactState(entry.contentRect.width)
|
|
875
|
+
})
|
|
876
|
+
observer.observe(element)
|
|
877
|
+
return () => observer.disconnect()
|
|
878
|
+
}, [])
|
|
743
879
|
|
|
744
880
|
const uploadedAttachmentIds = React.useMemo(
|
|
745
881
|
() =>
|
|
@@ -754,6 +890,66 @@ export function AiChat({
|
|
|
754
890
|
[attachmentIds, uploadedAttachmentIds],
|
|
755
891
|
)
|
|
756
892
|
|
|
893
|
+
const {
|
|
894
|
+
providers: modelProviders,
|
|
895
|
+
allowRuntimeModelOverride,
|
|
896
|
+
defaultLabel: modelDefaultLabel,
|
|
897
|
+
loaded: modelProvidersLoaded,
|
|
898
|
+
} = useAgentModels(agent)
|
|
899
|
+
|
|
900
|
+
const [modelPickerValue, setModelPickerValue] = React.useState<ModelPickerValue | null>(() =>
|
|
901
|
+
readModelPickerValue(agent),
|
|
902
|
+
)
|
|
903
|
+
|
|
904
|
+
const effectiveModelPickerValue = React.useMemo(() => {
|
|
905
|
+
if (!modelProvidersLoaded || !allowRuntimeModelOverride || modelProviders.length === 0) {
|
|
906
|
+
return null
|
|
907
|
+
}
|
|
908
|
+
if (modelPickerValue && isModelPickerValueAvailable(modelPickerValue, modelProviders)) {
|
|
909
|
+
return modelPickerValue
|
|
910
|
+
}
|
|
911
|
+
return firstAvailableModelPickerValue(modelProviders)
|
|
912
|
+
}, [
|
|
913
|
+
allowRuntimeModelOverride,
|
|
914
|
+
modelPickerValue,
|
|
915
|
+
modelProviders,
|
|
916
|
+
modelProvidersLoaded,
|
|
917
|
+
])
|
|
918
|
+
|
|
919
|
+
React.useEffect(() => {
|
|
920
|
+
setModelPickerValue(readModelPickerValue(agent))
|
|
921
|
+
}, [agent])
|
|
922
|
+
|
|
923
|
+
React.useEffect(() => {
|
|
924
|
+
if (!modelProvidersLoaded) return
|
|
925
|
+
if (!allowRuntimeModelOverride || modelProviders.length === 0) {
|
|
926
|
+
if (modelPickerValue !== null) {
|
|
927
|
+
setModelPickerValue(null)
|
|
928
|
+
writeModelPickerValue(agent, null)
|
|
929
|
+
}
|
|
930
|
+
return
|
|
931
|
+
}
|
|
932
|
+
if (modelPickerValue && !isModelPickerValueAvailable(modelPickerValue, modelProviders)) {
|
|
933
|
+
const fallback = firstAvailableModelPickerValue(modelProviders)
|
|
934
|
+
setModelPickerValue(fallback)
|
|
935
|
+
writeModelPickerValue(agent, fallback)
|
|
936
|
+
}
|
|
937
|
+
}, [
|
|
938
|
+
agent,
|
|
939
|
+
allowRuntimeModelOverride,
|
|
940
|
+
modelPickerValue,
|
|
941
|
+
modelProviders,
|
|
942
|
+
modelProvidersLoaded,
|
|
943
|
+
])
|
|
944
|
+
|
|
945
|
+
const handleModelPickerChange = React.useCallback(
|
|
946
|
+
(value: ModelPickerValue | null) => {
|
|
947
|
+
setModelPickerValue(value)
|
|
948
|
+
writeModelPickerValue(agent, value)
|
|
949
|
+
},
|
|
950
|
+
[agent],
|
|
951
|
+
)
|
|
952
|
+
|
|
757
953
|
const chat = useAiChat({
|
|
758
954
|
agent,
|
|
759
955
|
apiPath,
|
|
@@ -763,6 +959,8 @@ export function AiChat({
|
|
|
763
959
|
initialMessages,
|
|
764
960
|
onError,
|
|
765
961
|
conversationId,
|
|
962
|
+
providerOverride: effectiveModelPickerValue?.providerId ?? null,
|
|
963
|
+
modelOverride: effectiveModelPickerValue?.modelId ?? null,
|
|
766
964
|
})
|
|
767
965
|
|
|
768
966
|
const isStreaming = chat.status === 'streaming'
|
|
@@ -1065,15 +1263,15 @@ export function AiChat({
|
|
|
1065
1263
|
return (
|
|
1066
1264
|
<section
|
|
1067
1265
|
className={cn(
|
|
1068
|
-
'flex h-full min-h-[320px] flex-col gap-3 rounded-lg border border-border bg-background p-3',
|
|
1266
|
+
'flex h-full min-h-[320px] min-w-0 flex-col gap-3 overflow-hidden rounded-lg border border-border bg-background p-3',
|
|
1069
1267
|
className,
|
|
1070
1268
|
)}
|
|
1071
1269
|
aria-label={t('ai_assistant.chat.regionLabel', 'AI chat')}
|
|
1072
1270
|
data-ai-chat-agent={agent}
|
|
1073
1271
|
data-ai-chat-conversation-id={chat.conversationId}
|
|
1074
1272
|
>
|
|
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">
|
|
1273
|
+
<div className="flex min-w-0 items-center justify-between gap-2 border-b border-border pb-2">
|
|
1274
|
+
<div className="flex min-w-0 flex-1 items-center gap-2 text-xs text-muted-foreground">
|
|
1077
1275
|
{contextItems && contextItems.length > 0 ? (
|
|
1078
1276
|
<ContextItemsPill items={contextItems} />
|
|
1079
1277
|
) : (
|
|
@@ -1100,7 +1298,7 @@ export function AiChat({
|
|
|
1100
1298
|
role="log"
|
|
1101
1299
|
aria-live="polite"
|
|
1102
1300
|
aria-label={t('ai_assistant.chat.transcriptLabel', 'Chat transcript')}
|
|
1103
|
-
className="flex-1 space-y-2 overflow-y-auto pr-1"
|
|
1301
|
+
className="min-w-0 flex-1 space-y-2 overflow-y-auto overflow-x-hidden pr-1"
|
|
1104
1302
|
>
|
|
1105
1303
|
{chat.messages.length === 0 ? (
|
|
1106
1304
|
<WelcomeState
|
|
@@ -1153,7 +1351,7 @@ export function AiChat({
|
|
|
1153
1351
|
) : null}
|
|
1154
1352
|
|
|
1155
1353
|
<form
|
|
1156
|
-
className="flex flex-col gap-2"
|
|
1354
|
+
className="flex min-w-0 flex-col gap-2"
|
|
1157
1355
|
onSubmit={(event) => {
|
|
1158
1356
|
event.preventDefault()
|
|
1159
1357
|
handleSubmit()
|
|
@@ -1203,7 +1401,7 @@ export function AiChat({
|
|
|
1203
1401
|
onKeyDown={handleKeyDown}
|
|
1204
1402
|
rows={3}
|
|
1205
1403
|
aria-label={t('ai_assistant.chat.composerLabel', 'Message composer')}
|
|
1206
|
-
className="resize-none"
|
|
1404
|
+
className="min-w-0 resize-none"
|
|
1207
1405
|
/>
|
|
1208
1406
|
<input
|
|
1209
1407
|
ref={fileInputRef}
|
|
@@ -1214,8 +1412,13 @@ export function AiChat({
|
|
|
1214
1412
|
onChange={handleFileSelect}
|
|
1215
1413
|
data-ai-chat-file-input=""
|
|
1216
1414
|
/>
|
|
1217
|
-
<div
|
|
1218
|
-
|
|
1415
|
+
<div
|
|
1416
|
+
ref={footerRef}
|
|
1417
|
+
className="flex min-w-0 items-center justify-between gap-2"
|
|
1418
|
+
data-ai-chat-footer=""
|
|
1419
|
+
data-ai-chat-footer-compact={isCompactFooter ? 'true' : 'false'}
|
|
1420
|
+
>
|
|
1421
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
1219
1422
|
<IconButton
|
|
1220
1423
|
type="button"
|
|
1221
1424
|
variant="ghost"
|
|
@@ -1226,7 +1429,19 @@ export function AiChat({
|
|
|
1226
1429
|
>
|
|
1227
1430
|
<Paperclip className="size-4" aria-hidden />
|
|
1228
1431
|
</IconButton>
|
|
1229
|
-
|
|
1432
|
+
{allowRuntimeModelOverride && modelProviders.length > 0 ? (
|
|
1433
|
+
<ModelPicker
|
|
1434
|
+
agentId={agent}
|
|
1435
|
+
value={effectiveModelPickerValue}
|
|
1436
|
+
onChange={handleModelPickerChange}
|
|
1437
|
+
availableProviders={modelProviders}
|
|
1438
|
+
disabled={isBusy}
|
|
1439
|
+
compact={isCompactFooter}
|
|
1440
|
+
defaultLabel={modelDefaultLabel}
|
|
1441
|
+
className="shrink-0"
|
|
1442
|
+
/>
|
|
1443
|
+
) : null}
|
|
1444
|
+
<p className={cn('text-xs text-muted-foreground', isCompactFooter && 'hidden')}>
|
|
1230
1445
|
{hasUploadingFiles || isUploading
|
|
1231
1446
|
? t(
|
|
1232
1447
|
'ai_assistant.chat.uploadingHint',
|
|
@@ -1238,7 +1453,7 @@ export function AiChat({
|
|
|
1238
1453
|
)}
|
|
1239
1454
|
</p>
|
|
1240
1455
|
</div>
|
|
1241
|
-
<div className="flex items-center gap-2">
|
|
1456
|
+
<div className="flex shrink-0 items-center gap-2">
|
|
1242
1457
|
{isStreaming ? (
|
|
1243
1458
|
<IconButton
|
|
1244
1459
|
type="button"
|
|
@@ -1269,9 +1484,12 @@ export function AiChat({
|
|
|
1269
1484
|
? t('ai_assistant.chat.sendWaitingForUpload', 'Waiting for upload to finish…')
|
|
1270
1485
|
: undefined
|
|
1271
1486
|
}
|
|
1487
|
+
className={cn(isCompactFooter && 'w-8 px-0')}
|
|
1272
1488
|
>
|
|
1273
1489
|
<Send className="size-4" aria-hidden />
|
|
1274
|
-
|
|
1490
|
+
{!isCompactFooter ? (
|
|
1491
|
+
<span>{t('ai_assistant.chat.send', 'Send message')}</span>
|
|
1492
|
+
) : null}
|
|
1275
1493
|
</Button>
|
|
1276
1494
|
</div>
|
|
1277
1495
|
</div>
|
package/src/ai/AiDock.tsx
CHANGED
|
@@ -381,7 +381,7 @@ function AiDockPanel({
|
|
|
381
381
|
// primary surface (full-screen sheet) and a fixed side panel would
|
|
382
382
|
// crowd the viewport.
|
|
383
383
|
'hidden lg:flex',
|
|
384
|
-
'fixed top-0 right-0 z-overlay h-svh flex-col border-l bg-background shadow-lg',
|
|
384
|
+
'fixed top-0 right-0 z-overlay h-svh min-w-0 flex-col overflow-hidden border-l bg-background shadow-lg',
|
|
385
385
|
collapsed ? 'w-12' : '',
|
|
386
386
|
)}
|
|
387
387
|
style={collapsed ? undefined : { width }}
|
|
@@ -478,7 +478,7 @@ function DockedChatBody({ assistant }: { assistant: AiDockedAssistant }) {
|
|
|
478
478
|
return (
|
|
479
479
|
<>
|
|
480
480
|
<ChatPaneTabs agentId={assistant.agent} className="border-b" />
|
|
481
|
-
<div className="min-h-0 flex-1" data-ai-dock-chat-container="">
|
|
481
|
+
<div className="min-h-0 min-w-0 flex-1 overflow-hidden" data-ai-dock-chat-container="">
|
|
482
482
|
{session ? (
|
|
483
483
|
<React.Suspense fallback={null}>
|
|
484
484
|
<LazyAiChat
|
|
@@ -491,6 +491,7 @@ function DockedChatBody({ assistant }: { assistant: AiDockedAssistant }) {
|
|
|
491
491
|
conversationId={session.conversationId}
|
|
492
492
|
pageContext={assistant.pageContext}
|
|
493
493
|
className="h-full"
|
|
494
|
+
defaultCompactFooter
|
|
494
495
|
placeholder={assistant.placeholder}
|
|
495
496
|
suggestions={assistant.suggestions}
|
|
496
497
|
contextItems={assistant.contextItems}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { ChevronDown, Cpu } from 'lucide-react'
|
|
5
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
6
|
+
import { cn } from '@open-mercato/shared/lib/utils'
|
|
7
|
+
import { Button } from '../primitives/button'
|
|
8
|
+
|
|
9
|
+
export interface ModelPickerProviderModel {
|
|
10
|
+
id: string
|
|
11
|
+
name: string
|
|
12
|
+
contextWindow?: number | null
|
|
13
|
+
tags?: string[]
|
|
14
|
+
isDefault: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ModelPickerProvider {
|
|
18
|
+
id: string
|
|
19
|
+
name: string
|
|
20
|
+
isDefault: boolean
|
|
21
|
+
models: ModelPickerProviderModel[]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ModelPickerValue {
|
|
25
|
+
providerId: string
|
|
26
|
+
modelId: string
|
|
27
|
+
baseURL?: string | null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ModelPickerProps {
|
|
31
|
+
agentId: string
|
|
32
|
+
value: ModelPickerValue | null
|
|
33
|
+
onChange: (value: ModelPickerValue | null) => void
|
|
34
|
+
availableProviders: ModelPickerProvider[]
|
|
35
|
+
disabled?: boolean
|
|
36
|
+
compact?: boolean
|
|
37
|
+
defaultLabel?: string | null
|
|
38
|
+
className?: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Stateless dropdown for selecting a provider + model override in the chat UI.
|
|
43
|
+
*
|
|
44
|
+
* - Renders provider name + model id + "(default)" badge for the agent's resolved default.
|
|
45
|
+
* - Keyboard-accessible: trigger opens a floating list, arrow keys navigate, Enter selects,
|
|
46
|
+
* Escape closes.
|
|
47
|
+
* - Does NOT expose a baseURL input (R6 mitigation: only provider+model selection from the
|
|
48
|
+
* curated catalog is exposed to end users).
|
|
49
|
+
* - localStorage persistence keyed by agentId is handled by the consumer (AiChat / 4b.2).
|
|
50
|
+
* - When allowRuntimeModelOverride is false the consumer should not render this component
|
|
51
|
+
* at all; this component itself does no gating.
|
|
52
|
+
*/
|
|
53
|
+
export function ModelPicker({
|
|
54
|
+
agentId,
|
|
55
|
+
value,
|
|
56
|
+
onChange,
|
|
57
|
+
availableProviders,
|
|
58
|
+
disabled,
|
|
59
|
+
compact = false,
|
|
60
|
+
defaultLabel,
|
|
61
|
+
className,
|
|
62
|
+
}: ModelPickerProps) {
|
|
63
|
+
const t = useT()
|
|
64
|
+
const [open, setOpen] = React.useState(false)
|
|
65
|
+
const containerRef = React.useRef<HTMLDivElement>(null)
|
|
66
|
+
const triggerRef = React.useRef<HTMLButtonElement>(null)
|
|
67
|
+
|
|
68
|
+
const selectedLabel = React.useMemo(() => {
|
|
69
|
+
if (!value) {
|
|
70
|
+
return defaultLabel
|
|
71
|
+
? t('ai_assistant.modelPicker.defaultWithModelLabel', 'Default: {{model}}').replace('{{model}}', defaultLabel)
|
|
72
|
+
: t('ai_assistant.modelPicker.defaultLabel', 'Model: Default')
|
|
73
|
+
}
|
|
74
|
+
const provider = availableProviders.find((p) => p.id === value.providerId)
|
|
75
|
+
const model = provider?.models.find((m) => m.id === value.modelId)
|
|
76
|
+
const modelLabel = model?.name ?? value.modelId
|
|
77
|
+
const providerLabel = provider?.name ?? value.providerId
|
|
78
|
+
return `${providerLabel} / ${modelLabel}`
|
|
79
|
+
}, [value, availableProviders, defaultLabel, t])
|
|
80
|
+
|
|
81
|
+
const handleToggle = React.useCallback(() => {
|
|
82
|
+
if (disabled) return
|
|
83
|
+
setOpen((prev) => !prev)
|
|
84
|
+
}, [disabled])
|
|
85
|
+
|
|
86
|
+
const handleSelect = React.useCallback(
|
|
87
|
+
(providerId: string, modelId: string) => {
|
|
88
|
+
onChange({ providerId, modelId })
|
|
89
|
+
setOpen(false)
|
|
90
|
+
triggerRef.current?.focus()
|
|
91
|
+
},
|
|
92
|
+
[onChange],
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
const handleClearDefault = React.useCallback(() => {
|
|
96
|
+
onChange(null)
|
|
97
|
+
setOpen(false)
|
|
98
|
+
triggerRef.current?.focus()
|
|
99
|
+
}, [onChange])
|
|
100
|
+
|
|
101
|
+
React.useEffect(() => {
|
|
102
|
+
if (!open) return
|
|
103
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
104
|
+
if (event.key === 'Escape') {
|
|
105
|
+
setOpen(false)
|
|
106
|
+
triggerRef.current?.focus()
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
document.addEventListener('keydown', handleKeyDown)
|
|
110
|
+
return () => document.removeEventListener('keydown', handleKeyDown)
|
|
111
|
+
}, [open])
|
|
112
|
+
|
|
113
|
+
React.useEffect(() => {
|
|
114
|
+
if (!open) return
|
|
115
|
+
const handleClickOutside = (event: MouseEvent) => {
|
|
116
|
+
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
|
117
|
+
setOpen(false)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
document.addEventListener('mousedown', handleClickOutside)
|
|
121
|
+
return () => document.removeEventListener('mousedown', handleClickOutside)
|
|
122
|
+
}, [open])
|
|
123
|
+
|
|
124
|
+
if (availableProviders.length === 0) return null
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
<div ref={containerRef} className={cn('relative', className)} data-ai-model-picker={agentId}>
|
|
128
|
+
<Button
|
|
129
|
+
ref={triggerRef}
|
|
130
|
+
type="button"
|
|
131
|
+
variant="outline"
|
|
132
|
+
size="sm"
|
|
133
|
+
disabled={disabled}
|
|
134
|
+
onClick={handleToggle}
|
|
135
|
+
aria-haspopup="listbox"
|
|
136
|
+
aria-expanded={open}
|
|
137
|
+
aria-label={t('ai_assistant.modelPicker.triggerAriaLabel', 'Select AI model')}
|
|
138
|
+
title={selectedLabel}
|
|
139
|
+
data-ai-model-picker-trigger
|
|
140
|
+
className={cn(
|
|
141
|
+
'font-normal',
|
|
142
|
+
compact ? 'w-8 gap-0 px-0' : 'gap-1.5 px-2.5',
|
|
143
|
+
)}
|
|
144
|
+
>
|
|
145
|
+
<Cpu className="size-3.5 shrink-0" aria-hidden />
|
|
146
|
+
{!compact ? (
|
|
147
|
+
<span className="max-w-36 truncate text-xs">{selectedLabel}</span>
|
|
148
|
+
) : null}
|
|
149
|
+
<ChevronDown
|
|
150
|
+
className={cn(
|
|
151
|
+
'size-3 shrink-0 transition-transform',
|
|
152
|
+
compact && 'hidden',
|
|
153
|
+
open && 'rotate-180',
|
|
154
|
+
)}
|
|
155
|
+
aria-hidden
|
|
156
|
+
/>
|
|
157
|
+
</Button>
|
|
158
|
+
|
|
159
|
+
{open ? (
|
|
160
|
+
<div
|
|
161
|
+
role="listbox"
|
|
162
|
+
aria-label={t('ai_assistant.modelPicker.listAriaLabel', 'Available models')}
|
|
163
|
+
className={cn(
|
|
164
|
+
'absolute bottom-full left-0 z-50 mb-1 max-h-72 min-w-56 overflow-y-auto',
|
|
165
|
+
'rounded-md border border-border bg-popover shadow-md',
|
|
166
|
+
)}
|
|
167
|
+
data-ai-model-picker-dropdown
|
|
168
|
+
>
|
|
169
|
+
<div
|
|
170
|
+
role="option"
|
|
171
|
+
aria-selected={value === null}
|
|
172
|
+
tabIndex={0}
|
|
173
|
+
className={cn(
|
|
174
|
+
'cursor-pointer px-3 py-2 text-xs hover:bg-accent hover:text-accent-foreground',
|
|
175
|
+
'flex items-center gap-2',
|
|
176
|
+
value === null && 'bg-accent/50',
|
|
177
|
+
)}
|
|
178
|
+
onClick={handleClearDefault}
|
|
179
|
+
onKeyDown={(event) => {
|
|
180
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
181
|
+
event.preventDefault()
|
|
182
|
+
handleClearDefault()
|
|
183
|
+
}
|
|
184
|
+
}}
|
|
185
|
+
data-ai-model-picker-default-option
|
|
186
|
+
>
|
|
187
|
+
<span className="flex-1">
|
|
188
|
+
{defaultLabel
|
|
189
|
+
? t('ai_assistant.modelPicker.useDefaultWithModel', 'Use agent default: {{model}}').replace('{{model}}', defaultLabel)
|
|
190
|
+
: t('ai_assistant.modelPicker.useDefault', 'Use agent default')}
|
|
191
|
+
</span>
|
|
192
|
+
{value === null ? (
|
|
193
|
+
<span className="rounded bg-primary/10 px-1 py-0.5 text-overline font-medium text-primary">
|
|
194
|
+
{t('ai_assistant.modelPicker.activeBadge', 'active')}
|
|
195
|
+
</span>
|
|
196
|
+
) : null}
|
|
197
|
+
</div>
|
|
198
|
+
{availableProviders.map((provider) => (
|
|
199
|
+
<div key={provider.id} data-ai-model-picker-provider={provider.id}>
|
|
200
|
+
<div className="px-3 py-1.5 text-overline font-semibold uppercase tracking-wider text-muted-foreground">
|
|
201
|
+
{provider.name}
|
|
202
|
+
</div>
|
|
203
|
+
{provider.models.map((model) => {
|
|
204
|
+
const isSelected =
|
|
205
|
+
value?.providerId === provider.id && value?.modelId === model.id
|
|
206
|
+
return (
|
|
207
|
+
<div
|
|
208
|
+
key={model.id}
|
|
209
|
+
role="option"
|
|
210
|
+
aria-selected={isSelected}
|
|
211
|
+
tabIndex={0}
|
|
212
|
+
className={cn(
|
|
213
|
+
'cursor-pointer px-3 py-2 text-xs hover:bg-accent hover:text-accent-foreground',
|
|
214
|
+
'flex items-center gap-2 pl-5',
|
|
215
|
+
isSelected && 'bg-accent/50',
|
|
216
|
+
)}
|
|
217
|
+
onClick={() => handleSelect(provider.id, model.id)}
|
|
218
|
+
onKeyDown={(event) => {
|
|
219
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
220
|
+
event.preventDefault()
|
|
221
|
+
handleSelect(provider.id, model.id)
|
|
222
|
+
}
|
|
223
|
+
}}
|
|
224
|
+
data-ai-model-picker-model={`${provider.id}:${model.id}`}
|
|
225
|
+
>
|
|
226
|
+
<span className="flex-1 truncate">{model.name}</span>
|
|
227
|
+
{model.isDefault ? (
|
|
228
|
+
<span className="shrink-0 rounded bg-muted px-1 py-0.5 text-overline text-muted-foreground">
|
|
229
|
+
{t('ai_assistant.modelPicker.defaultBadge', 'default')}
|
|
230
|
+
</span>
|
|
231
|
+
) : null}
|
|
232
|
+
{isSelected ? (
|
|
233
|
+
<span className="shrink-0 rounded bg-primary/10 px-1 py-0.5 text-overline font-medium text-primary">
|
|
234
|
+
{t('ai_assistant.modelPicker.activeBadge', 'active')}
|
|
235
|
+
</span>
|
|
236
|
+
) : null}
|
|
237
|
+
</div>
|
|
238
|
+
)
|
|
239
|
+
})}
|
|
240
|
+
</div>
|
|
241
|
+
))}
|
|
242
|
+
</div>
|
|
243
|
+
) : null}
|
|
244
|
+
</div>
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export default ModelPicker
|