| 1 | import { useEffect, useMemo, useRef, useState } from 'react' |
| 2 | import { |
| 3 | Check, |
| 4 | ChevronDown, |
| 5 | FileText, |
| 6 | Image as ImageIcon, |
| 7 | Loader2, |
| 8 | Plus, |
| 9 | Send, |
| 10 | StopCircle, |
| 11 | Video, |
| 12 | X |
| 13 | } from 'lucide-react' |
| 14 | import { cn } from '@renderer/lib/utils' |
| 15 | import { useModelAction } from '@renderer/hooks/useModelAction' |
| 16 | import { |
| 17 | useGenerateStore, |
| 18 | useSessionDetailUiStore, |
| 19 | useSessionStore, |
| 20 | useToastStore |
| 21 | } from '@renderer/store' |
| 22 | import { Button } from '../../ui/Button' |
| 23 | import { ModelSplitButton } from '../../model/ModelActionButton' |
| 24 | import { |
| 25 | DropdownMenu, |
| 26 | DropdownMenuContent, |
| 27 | DropdownMenuItem, |
| 28 | DropdownMenuTrigger |
| 29 | } from '../../ui/DropdownMenu' |
| 30 | import { Textarea } from '../../ui/Input' |
| 31 | import { ScrollArea } from '../../ui/ScrollArea' |
| 32 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../ui/Select' |
| 33 | import { Tooltip, TooltipContent, TooltipTrigger } from '../../ui/Tooltip' |
| 34 | import { MessageBubble } from './MessageBubble' |
| 35 | import { useT } from '@renderer/i18n' |
| 36 | import { useChatPanelController } from '../hooks/useChatPanelController' |
| 37 | import { normalizePagesForSelection } from '../shared/pageUtils' |
| 38 | import { MAX_SELECTED_PAGES } from '@shared/generation' |
| 39 | |
| 40 | export function ChatPanel({ sessionId }: { sessionId: string }): React.JSX.Element { |
| 41 | const t = useT() |
| 42 | const modelAction = useModelAction() |
| 43 | const toastWarning = useToastStore((state) => state.warning) |
| 44 | const { |
| 45 | selectedPageExists, |
| 46 | selectedPageNumber, |
| 47 | isGenerating, |
| 48 | isPageEditing, |
| 49 | isDeckEditing, |
| 50 | deckEditRetry, |
| 51 | isPlanningPageEdit, |
| 52 | pendingPageEditPlan, |
| 53 | hasActivePageEditJob, |
| 54 | progress, |
| 55 | error, |
| 56 | uploadFiles, |
| 57 | chooseAssets, |
| 58 | send, |
| 59 | confirmPageEditPlan, |
| 60 | cancelPageEditPlan, |
| 61 | retryDeckEdit, |
| 62 | cancel |
| 63 | } = useChatPanelController(sessionId) |
| 64 | const messages = useSessionStore((state) => state.currentMessages) |
| 65 | const currentPages = useGenerateStore((state) => state.currentPages) |
| 66 | const chatType = useSessionDetailUiStore((state) => state.chatType) |
| 67 | const input = useSessionDetailUiStore((state) => state.input) |
| 68 | const selectedSelector = useSessionDetailUiStore((state) => state.selectedSelector) |
| 69 | const selectorLabel = useSessionDetailUiStore((state) => state.selectorLabel) |
| 70 | const elementTag = useSessionDetailUiStore((state) => state.elementTag) |
| 71 | const elementText = useSessionDetailUiStore((state) => state.elementText) |
| 72 | const selectedElementContext = useSessionDetailUiStore((state) => state.selectedElementContext) |
| 73 | const pendingAssets = useSessionDetailUiStore((state) => state.pendingAssets) |
| 74 | const assetDragActive = useSessionDetailUiStore((state) => state.assetDragActive) |
| 75 | const isUploadingAssets = useSessionDetailUiStore((state) => state.isUploadingAssets) |
| 76 | const setChatType = useSessionDetailUiStore((state) => state.setChatType) |
| 77 | const setInput = useSessionDetailUiStore((state) => state.setInput) |
| 78 | const setAssetDragActive = useSessionDetailUiStore((state) => state.setAssetDragActive) |
| 79 | const removePendingAsset = useSessionDetailUiStore((state) => state.removePendingAsset) |
| 80 | const clearSelectedElement = useSessionDetailUiStore((state) => state.clearSelectedElement) |
| 81 | const messagesEndRef = useRef<HTMLDivElement>(null) |
| 82 | const composingRef = useRef(false) |
| 83 | const [selectedMainPageIds, setSelectedMainPageIds] = useState<string[]>([]) |
| 84 | const pages = useMemo(() => normalizePagesForSelection(currentPages), [currentPages]) |
| 85 | const pageIds = useMemo(() => pages.map((page) => page.pageId), [pages]) |
| 86 | const effectiveMainPageIds = useMemo( |
| 87 | () => selectedMainPageIds.filter((id) => pageIds.includes(id)), |
| 88 | [pageIds, selectedMainPageIds] |
| 89 | ) |
| 90 | |
| 91 | useEffect(() => { |
| 92 | messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) |
| 93 | }, [messages, isGenerating]) |
| 94 | |
| 95 | useEffect(() => { |
| 96 | if (effectiveMainPageIds.length !== selectedMainPageIds.length) { |
| 97 | setSelectedMainPageIds(effectiveMainPageIds) |
| 98 | } |
| 99 | }, [effectiveMainPageIds, selectedMainPageIds.length]) |
| 100 | |
| 101 | const contextHint = |
| 102 | chatType === 'page' && selectedPageNumber |
| 103 | ? t('sessionDetail.pageContext', { pageNumber: selectedPageNumber }) |
| 104 | : t('sessionDetail.mainContext') |
| 105 | const inputPlaceholder = |
| 106 | pendingAssets.length > 0 |
| 107 | ? t('sessionDetail.assetPlaceholder') |
| 108 | : chatType === 'page' |
| 109 | ? t('sessionDetail.pagePlaceholder') |
| 110 | : t('sessionDetail.mainPlaceholder') |
| 111 | const displayLabel = (() => { |
| 112 | const raw = selectorLabel || selectedSelector || '' |
| 113 | const last = raw.split(/\s+/).pop() || raw |
| 114 | return last |
| 115 | })() |
| 116 | const selectorSummary = selectedSelector |
| 117 | ? [displayLabel, elementTag ? `<${elementTag}>${elementText ? ` ${elementText}` : ''}` : ''] |
| 118 | .filter(Boolean) |
| 119 | .join(' · ') |
| 120 | : '' |
| 121 | const selectedElementPropertyCount = |
| 122 | Object.keys(selectedElementContext?.attributes || {}).length + |
| 123 | Object.keys(selectedElementContext?.inlineStyle || {}).length + |
| 124 | Object.keys(selectedElementContext?.computedStyle || {}).length |
| 125 | const selectedElementBounds = selectedElementContext?.bounds |
| 126 | const selectorTitle = selectedSelector |
| 127 | ? [ |
| 128 | `selector: ${selectedSelector}`, |
| 129 | selectorLabel && selectorLabel !== selectedSelector ? `label: ${selectorLabel}` : '', |
| 130 | elementTag ? `element: <${elementTag}>` : '', |
| 131 | elementText ? `text: ${elementText}` : '', |
| 132 | selectedElementBounds |
| 133 | ? `bounds: x=${selectedElementBounds.x}, y=${selectedElementBounds.y}, w=${selectedElementBounds.width}, h=${selectedElementBounds.height}` |
| 134 | : '' |
| 135 | ] |
| 136 | .filter(Boolean) |
| 137 | .join('\n') |
| 138 | : undefined |
| 139 | const sendDisabled = |
| 140 | (!input.trim() && pendingAssets.length === 0) || |
| 141 | ((selectedSelector ? 'page' : chatType) === 'page' && !selectedPageExists) |
| 142 | |
| 143 | const handleSendWithModel = async (modelConfigId?: string): Promise<void> => { |
| 144 | if (sendDisabled) return |
| 145 | try { |
| 146 | if ( |
| 147 | chatType === 'main' && |
| 148 | effectiveMainPageIds.length === 0 && |
| 149 | pageIds.length > MAX_SELECTED_PAGES |
| 150 | ) { |
| 151 | toastWarning( |
| 152 | t('sessionDetail.mainPageScopeAllLimitReached', { |
| 153 | count: pageIds.length, |
| 154 | limit: MAX_SELECTED_PAGES |
| 155 | }) |
| 156 | ) |
| 157 | return |
| 158 | } |
| 159 | if ( |
| 160 | chatType === 'main' && |
| 161 | effectiveMainPageIds.length > 0 && |
| 162 | /\b(all|every|entire)\b|全部|所有|整套|全套|每一页|每页/i.test(input) |
| 163 | ) { |
| 164 | toastWarning(t('sessionDetail.mainPageScopeConflictWarning')) |
| 165 | } |
| 166 | const resolvedModelConfigId = await modelAction.ensureModelActive(modelConfigId) |
| 167 | if (!resolvedModelConfigId) return |
| 168 | const started = await send(resolvedModelConfigId, effectiveMainPageIds) |
| 169 | if (started) setSelectedMainPageIds([]) |
| 170 | } catch (err) { |
| 171 | console.error('[MessagePanel] send model activation failed', err) |
| 172 | } |
| 173 | } |
| 174 | |
| 175 | const handleRetryDeckEdit = async (): Promise<void> => { |
| 176 | const modelConfigId = await modelAction.ensureModelActive() |
| 177 | if (!modelConfigId) return |
| 178 | await retryDeckEdit(modelConfigId) |
| 179 | } |
| 180 | |
| 181 | const toggleMainPage = (pageId: string): void => { |
| 182 | if ( |
| 183 | !selectedMainPageIds.includes(pageId) && |
| 184 | effectiveMainPageIds.length >= MAX_SELECTED_PAGES |
| 185 | ) { |
| 186 | toastWarning(t('sessionDetail.mainPageScopeLimitReached', { count: MAX_SELECTED_PAGES })) |
| 187 | return |
| 188 | } |
| 189 | setSelectedMainPageIds((current) => |
| 190 | current.includes(pageId) ? current.filter((id) => id !== pageId) : [...current, pageId] |
| 191 | ) |
| 192 | } |
| 193 | |
| 194 | const selectedSingleMainPage = |
| 195 | effectiveMainPageIds.length === 1 |
| 196 | ? pages.find((page) => page.pageId === effectiveMainPageIds[0]) |
| 197 | : undefined |
| 198 | const mainPageScopeLabel = |
| 199 | effectiveMainPageIds.length === 0 |
| 200 | ? t('sessionDetail.mainPageScopeAll') |
| 201 | : effectiveMainPageIds.length === 1 |
| 202 | ? selectedSingleMainPage?.pageNumber |
| 203 | ? `P${selectedSingleMainPage.pageNumber}` |
| 204 | : effectiveMainPageIds[0] |
| 205 | : t('sessionDetail.mainPageScopeCount', { count: effectiveMainPageIds.length }) |
| 206 | |
| 207 | return ( |
| 208 | <> |
| 209 | <div className="relative mx-2.5 mt-2.5 overflow-hidden rounded-[1.35rem] border border-[#e1d6c4]/72 bg-[#fffaf1]/78 px-3 pb-2.5 pt-3 shadow-[0_4px_12px_rgba(77,61,43,0.06)]"> |
| 210 | <div className="pointer-events-none absolute -right-8 -top-10 h-28 w-28 rounded-[30%_70%_70%_30%/30%_30%_70%_70%] bg-[#c7d9b4]/12" /> |
| 211 | <div className="relative flex flex-col gap-2"> |
| 212 | <h3 className="text-sm font-semibold tracking-[0.04em] text-[#34402c]"> |
| 213 | {t('sessionDetail.messageTitle')} |
| 214 | </h3> |
| 215 | <div className="flex items-center justify-between gap-2 text-xs text-[#6d604d]"> |
| 216 | <span>{t('sessionDetail.context')}</span> |
| 217 | <Select |
| 218 | value={chatType} |
| 219 | onValueChange={(value) => setChatType(value === 'page' ? 'page' : 'main')} |
| 220 | > |
| 221 | <SelectTrigger className="h-8 w-[132px] rounded-full border-[#ded2bd]/70 bg-[#fffdf8]/82 px-3 py-1 text-xs text-[#3e4a32] shadow-none"> |
| 222 | <SelectValue placeholder={t('sessionDetail.contextPlaceholder')} /> |
| 223 | </SelectTrigger> |
| 224 | <SelectContent> |
| 225 | <SelectItem value="page" disabled={!selectedPageExists}> |
| 226 | {t('sessionDetail.currentPage')} |
| 227 | </SelectItem> |
| 228 | <SelectItem value="main">{t('sessionDetail.mainSession')}</SelectItem> |
| 229 | </SelectContent> |
| 230 | </Select> |
| 231 | </div> |
| 232 | </div> |
| 233 | </div> |
| 234 | |
| 235 | <ScrollArea |
| 236 | data-messages-container |
| 237 | className="min-h-0 flex-1" |
| 238 | viewportClassName="px-2.5 py-2" |
| 239 | > |
| 240 | {messages.length === 0 && !isGenerating ? ( |
| 241 | <div className="mt-24 flex min-h-full items-center justify-center text-sm text-[#7a6b56]"> |
| 242 | {t('sessionDetail.emptyMessages')} |
| 243 | </div> |
| 244 | ) : ( |
| 245 | <div className="flex min-h-full flex-col justify-end gap-2.5"> |
| 246 | {messages.map((msg) => ( |
| 247 | <MessageBubble key={msg.id} message={msg} /> |
| 248 | ))} |
| 249 | |
| 250 | {error && ( |
| 251 | <div className="rounded-[1.15rem] bg-[rgba(217,124,139,0.12)] px-3 py-2 text-sm text-destructive"> |
| 252 | {error} |
| 253 | </div> |
| 254 | )} |
| 255 | {(isPageEditing || isDeckEditing) && ( |
| 256 | <div className="flex items-center gap-2 rounded-[1.15rem] border border-[#c7d9b4]/70 bg-[#edf5e5]/76 px-3 py-2 text-sm text-[#4f6340]"> |
| 257 | <Loader2 className="h-4 w-4 shrink-0 animate-spin" /> |
| 258 | <span className="min-w-0 flex-1 break-words"> |
| 259 | {progress?.label || t('sessionDetail.activityProcessing')} |
| 260 | </span> |
| 261 | <span className="shrink-0 text-xs tabular-nums text-[#6d7b5d]"> |
| 262 | {Math.round(progress?.progress || 0)}% |
| 263 | </span> |
| 264 | </div> |
| 265 | )} |
| 266 | {deckEditRetry && !isDeckEditing && ( |
| 267 | <div className="flex items-center gap-2 rounded-lg border border-[#d8c48b]/75 bg-[#fff8df] px-3 py-2 text-sm text-[#765b18]"> |
| 268 | <span className="min-w-0 flex-1 break-words"> |
| 269 | {t('sessionDetail.activityPartialCompleted', { |
| 270 | count: deckEditRetry.failedPageCount |
| 271 | })} |
| 272 | </span> |
| 273 | <Button size="sm" onClick={() => void handleRetryDeckEdit()} className="h-8 px-2.5 text-xs"> |
| 274 | {t('sessionDetail.activityRetryFailedPages', { |
| 275 | count: deckEditRetry.failedPageCount |
| 276 | })} |
| 277 | </Button> |
| 278 | </div> |
| 279 | )} |
| 280 | {isPlanningPageEdit && ( |
| 281 | <div className="flex items-center gap-2 rounded-lg border border-[#c7d9b4]/70 bg-[#edf5e5]/76 px-3 py-2 text-sm text-[#4f6340]" aria-live="polite"> |
| 282 | <Loader2 className="h-4 w-4 shrink-0 animate-spin" /> |
| 283 | <span>{t('sessionDetail.pageEditPlanning')}</span> |
| 284 | </div> |
| 285 | )} |
| 286 | {pendingPageEditPlan && ( |
| 287 | <section className="rounded-lg border border-[#c7d9b4]/80 bg-[#f7fbf2] px-3 py-2.5 text-sm text-[#405333]" aria-live="polite"> |
| 288 | <div className="flex items-start justify-between gap-3"> |
| 289 | <h4 className="font-semibold">{t('sessionDetail.pageEditPlanTitle')}</h4> |
| 290 | <span className="shrink-0 text-xs text-[#6d7b5d]"> |
| 291 | {pendingPageEditPlan.targetPageNumber |
| 292 | ? t('sessionDetail.pageContext', { |
| 293 | pageNumber: pendingPageEditPlan.targetPageNumber |
| 294 | }) |
| 295 | : pendingPageEditPlan.targetPageId} |
| 296 | </span> |
| 297 | </div> |
| 298 | <p className="mt-1.5 break-words leading-5">{pendingPageEditPlan.plan.summary}</p> |
| 299 | <ul className="mt-2 list-disc space-y-1 pl-4 text-xs leading-5 text-[#536847]"> |
| 300 | {pendingPageEditPlan.plan.changes.map((change, index) => ( |
| 301 | <li key={`${index}-${change}`}>{change}</li> |
| 302 | ))} |
| 303 | </ul> |
| 304 | <p className="mt-2 break-words text-xs leading-5 text-[#5f7150]"> |
| 305 | {pendingPageEditPlan.plan.confirmationQuestion} |
| 306 | </p> |
| 307 | <div className="mt-2.5 flex justify-end gap-2"> |
| 308 | <Button |
| 309 | variant="outline" |
| 310 | size="sm" |
| 311 | onClick={cancelPageEditPlan} |
| 312 | className="h-8 px-2.5 text-xs" |
| 313 | > |
| 314 | {t('sessionDetail.pageEditPlanCancel')} |
| 315 | </Button> |
| 316 | <Button |
| 317 | size="sm" |
| 318 | disabled={hasActivePageEditJob} |
| 319 | onClick={() => void confirmPageEditPlan()} |
| 320 | className="h-8 px-2.5 text-xs" |
| 321 | > |
| 322 | {t('sessionDetail.pageEditPlanConfirm')} |
| 323 | </Button> |
| 324 | </div> |
| 325 | </section> |
| 326 | )} |
| 327 | <div ref={messagesEndRef} /> |
| 328 | </div> |
| 329 | )} |
| 330 | </ScrollArea> |
| 331 | |
| 332 | <div |
| 333 | className={cn( |
| 334 | 'mx-2.5 mb-2.5 rounded-[1.4rem] border border-[#ded2bd]/72 bg-[#fffaf1]/84 px-2.5 pb-3 pt-2 shadow-[0_12px_24px_rgba(74,59,42,0.11)] transition-colors', |
| 335 | assetDragActive && 'border-[#afc79a]/75 bg-[#f3f8ec]/88' |
| 336 | )} |
| 337 | onDragEnter={(event) => { |
| 338 | event.preventDefault() |
| 339 | if (event.dataTransfer.types.includes('Files')) setAssetDragActive(true) |
| 340 | }} |
| 341 | onDragOver={(event) => { |
| 342 | event.preventDefault() |
| 343 | if (event.dataTransfer.types.includes('Files')) setAssetDragActive(true) |
| 344 | }} |
| 345 | onDragLeave={(event) => { |
| 346 | if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { |
| 347 | setAssetDragActive(false) |
| 348 | } |
| 349 | }} |
| 350 | onDrop={(event) => { |
| 351 | event.preventDefault() |
| 352 | void uploadFiles(Array.from(event.dataTransfer.files)) |
| 353 | }} |
| 354 | > |
| 355 | {selectedSelector && ( |
| 356 | <div className="mb-2 flex items-center gap-2 rounded-[1rem] border border-[#ded2bd]/65 bg-[#f4ebdc]/70 px-2 py-1.5"> |
| 357 | <span className="shrink-0 rounded-full bg-[#dcebcf]/82 px-1.5 py-0.5 text-[10px] font-medium tracking-wide text-[#4f6340]"> |
| 358 | {t('sessionDetail.selectorBadge')} |
| 359 | </span> |
| 360 | <Tooltip> |
| 361 | <TooltipTrigger asChild> |
| 362 | <span className="min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-xs leading-5 text-[#4f5f3f]"> |
| 363 | {selectorSummary} |
| 364 | </span> |
| 365 | </TooltipTrigger> |
| 366 | {selectorTitle && ( |
| 367 | <TooltipContent className="whitespace-pre-wrap">{selectorTitle}</TooltipContent> |
| 368 | )} |
| 369 | </Tooltip> |
| 370 | {selectedElementPropertyCount > 0 && ( |
| 371 | <span |
| 372 | className="shrink-0 rounded-full bg-[#e5ddd0]/82 px-1.5 py-0.5 text-[10px] font-medium text-[#6a5c48]" |
| 373 | title={t('sessionDetail.selectorPropertiesReady', { |
| 374 | count: selectedElementPropertyCount |
| 375 | })} |
| 376 | > |
| 377 | {t('sessionDetail.selectorPropertiesReady', { |
| 378 | count: selectedElementPropertyCount |
| 379 | })} |
| 380 | </span> |
| 381 | )} |
| 382 | <button |
| 383 | type="button" |
| 384 | onClick={clearSelectedElement} |
| 385 | className="inline-flex h-5 w-5 items-center justify-center rounded-full text-[#64735a] transition-colors hover:bg-[#d4e4c1]/78 hover:text-[#3e4a32]" |
| 386 | aria-label={t('sessionDetail.clearSelector')} |
| 387 | title={t('sessionDetail.clearSelector')} |
| 388 | > |
| 389 | <X className="h-3.5 w-3.5" /> |
| 390 | </button> |
| 391 | </div> |
| 392 | )} |
| 393 | {chatType === 'main' && ( |
| 394 | <div className="mb-2 flex items-center gap-2 rounded-[1rem] border border-[#ded2bd]/65 bg-[#f4ebdc]/70 px-2.5 py-2 text-xs text-[#6a5c48]"> |
| 395 | <span className="min-w-0 flex-1">{t('sessionDetail.mainDeckHint')}</span> |
| 396 | <DropdownMenu> |
| 397 | <DropdownMenuTrigger asChild> |
| 398 | <button |
| 399 | type="button" |
| 400 | disabled={isGenerating || pages.length === 0} |
| 401 | className="inline-flex h-7 max-w-[116px] shrink-0 items-center gap-1.5 rounded-full border border-[#c7d9b4]/72 bg-[#fffdf8]/86 px-2 text-[11px] font-medium text-[#405333] transition-colors hover:bg-[#edf5e5] disabled:pointer-events-none disabled:opacity-45" |
| 402 | title={ |
| 403 | effectiveMainPageIds.length > 0 |
| 404 | ? effectiveMainPageIds.map((id) => `/${id}.html`).join('\n') |
| 405 | : t('sessionDetail.mainPageScopeAll') |
| 406 | } |
| 407 | > |
| 408 | <span className="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap"> |
| 409 | {mainPageScopeLabel} |
| 410 | </span> |
| 411 | <ChevronDown className="h-3.5 w-3.5 shrink-0" /> |
| 412 | </button> |
| 413 | </DropdownMenuTrigger> |
| 414 | <DropdownMenuContent align="end" side="top" className="max-h-72 w-56 overflow-y-auto"> |
| 415 | <DropdownMenuItem |
| 416 | onSelect={(event) => { |
| 417 | event.preventDefault() |
| 418 | setSelectedMainPageIds([]) |
| 419 | }} |
| 420 | className="text-xs" |
| 421 | > |
| 422 | <Check |
| 423 | className={cn( |
| 424 | 'h-3.5 w-3.5', |
| 425 | effectiveMainPageIds.length === 0 ? 'opacity-100' : 'opacity-0' |
| 426 | )} |
| 427 | /> |
| 428 | {t('sessionDetail.mainPageScopeAll')} |
| 429 | </DropdownMenuItem> |
| 430 | {pages.map((page) => { |
| 431 | const checked = effectiveMainPageIds.includes(page.pageId) |
| 432 | const limitReached = !checked && effectiveMainPageIds.length >= MAX_SELECTED_PAGES |
| 433 | return ( |
| 434 | <DropdownMenuItem |
| 435 | key={page.pageId} |
| 436 | onSelect={(event) => { |
| 437 | event.preventDefault() |
| 438 | toggleMainPage(page.pageId) |
| 439 | }} |
| 440 | className={cn('text-xs', limitReached && 'opacity-55')} |
| 441 | title={`/${page.pageId}.html`} |
| 442 | > |
| 443 | <Check className={cn('h-3.5 w-3.5', checked ? 'opacity-100' : 'opacity-0')} /> |
| 444 | <span className="min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap"> |
| 445 | P{page.pageNumber} · {page.title || page.pageId} |
| 446 | </span> |
| 447 | </DropdownMenuItem> |
| 448 | ) |
| 449 | })} |
| 450 | </DropdownMenuContent> |
| 451 | </DropdownMenu> |
| 452 | </div> |
| 453 | )} |
| 454 | {pendingAssets.length > 0 && ( |
| 455 | <div className="mb-2 flex flex-wrap gap-1.5"> |
| 456 | {pendingAssets.map((asset) => ( |
| 457 | <div |
| 458 | key={asset.id} |
| 459 | className="flex max-w-full items-center gap-1.5 rounded-full border border-[#c7d9b4]/66 bg-[#e6f1dc]/76 px-2 py-1 text-[11px] text-[#405333] shadow-[0_3px_8px_rgba(93,107,77,0.06)]" |
| 460 | title={`${asset.originalName}\n${asset.relativePath}`} |
| 461 | > |
| 462 | {asset.mimeType.startsWith('video/') ? ( |
| 463 | <Video className="h-3.5 w-3.5 shrink-0" /> |
| 464 | ) : ( |
| 465 | <ImageIcon className="h-3.5 w-3.5 shrink-0" /> |
| 466 | )} |
| 467 | <span className="min-w-0 max-w-[180px] overflow-hidden text-ellipsis whitespace-nowrap"> |
| 468 | {asset.originalName || asset.fileName} |
| 469 | </span> |
| 470 | <button |
| 471 | type="button" |
| 472 | onClick={() => removePendingAsset(asset.id)} |
| 473 | className="inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-[#657552] hover:bg-[#c8ddb2]" |
| 474 | aria-label={t('sessionDetail.removeAsset')} |
| 475 | > |
| 476 | <X className="h-3 w-3" /> |
| 477 | </button> |
| 478 | </div> |
| 479 | ))} |
| 480 | </div> |
| 481 | )} |
| 482 | <Textarea |
| 483 | placeholder={inputPlaceholder} |
| 484 | value={input} |
| 485 | onChange={(event) => setInput(event.target.value)} |
| 486 | onCompositionStart={() => { |
| 487 | composingRef.current = true |
| 488 | }} |
| 489 | onCompositionEnd={() => { |
| 490 | composingRef.current = false |
| 491 | }} |
| 492 | onKeyDown={(event) => { |
| 493 | if (event.key === 'Enter' && !event.shiftKey) { |
| 494 | if (composingRef.current || event.nativeEvent.isComposing) return |
| 495 | event.preventDefault() |
| 496 | void handleSendWithModel() |
| 497 | } |
| 498 | }} |
| 499 | disabled={isGenerating} |
| 500 | rows={4} |
| 501 | className="min-h-[96px] resize-none rounded-[1.15rem] border border-[#ded2bd]/72 bg-[#fffdf8]/88 px-3 py-2 text-[13px] leading-5 text-[#3f4b35] shadow-[inset_0_1px_2px_rgba(74,59,42,0.05)] focus-visible:border-[#9bb98a] focus-visible:ring-0 focus-visible:ring-offset-0" |
| 502 | /> |
| 503 | <div className="mt-2 grid grid-cols-[minmax(0,1fr)_auto] items-start gap-2"> |
| 504 | <div className="flex min-w-0 items-center gap-2"> |
| 505 | <DropdownMenu> |
| 506 | <DropdownMenuTrigger asChild> |
| 507 | <button |
| 508 | type="button" |
| 509 | disabled={isGenerating || isUploadingAssets} |
| 510 | className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-[38%_62%_44%_56%/55%_45%_55%_45%] border border-[#c7d9b4]/66 bg-[#e6f1dc]/80 text-[#526942] shadow-[0_4px_10px_rgba(93,107,77,0.09)] transition-colors hover:bg-[#d7e8c8] disabled:pointer-events-none disabled:opacity-45" |
| 511 | aria-label={t('sessionDetail.addAsset')} |
| 512 | title={t('sessionDetail.addAsset')} |
| 513 | > |
| 514 | {isUploadingAssets ? ( |
| 515 | <Loader2 className="h-4 w-4 animate-spin" /> |
| 516 | ) : ( |
| 517 | <Plus className="h-4 w-4" /> |
| 518 | )} |
| 519 | </button> |
| 520 | </DropdownMenuTrigger> |
| 521 | <DropdownMenuContent align="start" side="top" className="w-40"> |
| 522 | <DropdownMenuItem onSelect={() => void chooseAssets('image')}> |
| 523 | <ImageIcon className="h-4 w-4" /> |
| 524 | {t('sessionDetail.chooseImage')} |
| 525 | </DropdownMenuItem> |
| 526 | <DropdownMenuItem onSelect={() => void chooseAssets('video')}> |
| 527 | <Video className="h-4 w-4" /> |
| 528 | {t('sessionDetail.chooseVideo')} |
| 529 | </DropdownMenuItem> |
| 530 | <DropdownMenuItem disabled> |
| 531 | <FileText className="h-4 w-4" /> |
| 532 | {t('sessionDetail.chooseFileSoon')} |
| 533 | </DropdownMenuItem> |
| 534 | </DropdownMenuContent> |
| 535 | </DropdownMenu> |
| 536 | <div className="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-xs leading-5 text-[#6d604d]"> |
| 537 | {contextHint} |
| 538 | </div> |
| 539 | </div> |
| 540 | |
| 541 | {isGenerating ? ( |
| 542 | <Button |
| 543 | variant="destructive" |
| 544 | onClick={() => void cancel()} |
| 545 | size="sm" |
| 546 | className="shrink-0 whitespace-nowrap rounded-full px-3 text-xs shadow-[0_8px_18px_rgba(177,90,88,0.22)]" |
| 547 | > |
| 548 | <StopCircle className="mr-1 h-4 w-4" /> |
| 549 | {isDeckEditing ? t('common.cancel') : t('sessionDetail.stop')} |
| 550 | </Button> |
| 551 | ) : ( |
| 552 | <ModelSplitButton |
| 553 | modelAction={modelAction} |
| 554 | label={t('sessionDetail.send')} |
| 555 | disabled={sendDisabled} |
| 556 | icon={Send} |
| 557 | size="sm" |
| 558 | className="shrink-0 whitespace-nowrap" |
| 559 | mainClassName="h-8 px-2.5 text-xs" |
| 560 | triggerClassName="h-8 px-1.5" |
| 561 | onRun={handleSendWithModel} |
| 562 | /> |
| 563 | )} |
| 564 | </div> |
| 565 | </div> |
| 566 | </> |
| 567 | ) |
| 568 | } |
| 569 |